cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

e2e: kitty keyboard on and off, keys held to preview, vim repeat, registers, marks and :w!

Dami committed 4074c28
A legacyKeys session hides the keyboard protocol from libghostty and
sends Enter, Ctrl+letter and Alt+letter as legacy terminals do, so the
same flows run with the protocol and without it. The key tables move to
keys_test.go with hold and release helpers. Golden screens: the chart
selection says hold Space to zoom, and the : line's keys name Up for
history.

Fixes the context line of a sequence begun with c, whose binding has a
label rather than a command.

Co-Authored-By: Claude <noreply@anthropic.com>
commit
4074c28f70d6b62647d7870d08880f0fb9896d11
parent
317b5c45cd6e1e0c1458303b2ecbbd3cb901e8fb
author
Dami <dami.etoile@gmail.com>
kind
test risk score 0.9 of 3, Jev's estimate
9 files changed, +392 -54
Me2e/harness_test.go75+++++---------
Ae2e/keys_test.go164++++++++++++++++++++++++++++++
Ae2e/kitty_test.go105+++++++++++++++++++
Me2e/testdata/screens/chart-selected.html2+-
Me2e/testdata/screens/vim-command-light.html2+-
Me2e/testdata/screens/vim-command.html2+-
Ae2e/vimmore_test.go66++++++++++++
Minternal/ui/vim.go6+-
Minternal/ui/vimmore_test.go24++++-
M e2e/harness_test.go +28 -47
@@ -59,6 +59,9 @@ type session struct {
5959 mev *ghostty.MouseEvent6060 cols uint166161 rows uint1662+ // legacy, for options.legacyKeys, hides the keyboard protocol from63+ // the terminal and sends keys as legacy terminals do: keys_test.go.64+ legacy *keyFilter6265 6366 exited chan struct{} // closed when the pty reader stops6467 }
@@ -79,6 +82,10 @@ type options struct {
7982 // images; off by default, so screens show the text charts every8083 // terminal gets.8184 graphics bool85+ // legacyKeys makes the terminal one without the kitty keyboard86+ // protocol or modifyOtherKeys: keys are sent as legacy terminals87+ // send them, and releases never (see keys_test.go).88+ legacyKeys bool8289 // program runs instead of 012, such as an ssh client reaching 0128390 // serve.8491 program string
@@ -156,6 +163,10 @@ func startWith(t *testing.T, o options, args ...string) *session {
156163 return ghostty.SizeReportSize{Columns: s.cols, Rows: s.rows, CellWidth: cellW, CellHeight: cellH}, true157164 })158165 166+ if o.legacyKeys {167+ s.legacy = &keyFilter{}168+ }169+ legacy := s.legacy159170 go func() {160171 defer close(s.exited)161172 buf := make([]byte, 32*1024)
@@ -163,7 +174,7 @@ func startWith(t *testing.T, o options, args ...string) *session {
163174 n, err := s.pty.Read(buf)164175 if n > 0 {165176 s.mu.Lock()166- s.vt.Write(buf[:n])177+ s.vt.Write(legacy.filter(buf[:n]))167178 s.raw = append(s.raw, buf[:n]...)168179 s.mu.Unlock()169180 }
@@ -323,9 +334,19 @@ func (s *session) typeText(text string) {
323334 }324335 325336 func (s *session) press(key ghostty.Key, mods ghostty.Mods, text string, unshifted rune) {337+ s.keyEvent(ghostty.KeyActionPress, key, mods, text, unshifted)338+}339+340+// keyEvent sends a press, repeat or release of key, encoded for the341+// modes the program turned on.342+func (s *session) keyEvent(action ghostty.KeyAction, key ghostty.Key, mods ghostty.Mods, text string, unshifted rune) {343+ if data, ok := legacyBytes(action, key, mods, unshifted); ok && s.legacy != nil {344+ s.write(data)345+ return346+ }326347 s.mu.Lock()327348 s.enc.SetOptFromTerminal(s.vt)328- s.ev.SetAction(ghostty.KeyActionPress)349+ s.ev.SetAction(action)329350 s.ev.SetKey(key)330351 s.ev.SetMods(mods)331352 // Shift that produced the text is consumed by the layout, as a real
@@ -342,6 +363,11 @@ func (s *session) press(key ghostty.Key, mods ghostty.Mods, text string, unshift
342363 if err != nil {343364 s.t.Fatalf("encoding key %v: %v", key, err)344365 }366+ s.write(data)367+}368+369+// write sends a key's bytes.370+func (s *session) write(data []byte) {345371 if _, err := s.pty.Write(data); err != nil {346372 s.t.Fatal(err)347373 }
@@ -433,51 +459,6 @@ func (s *session) activeScreen() ghostty.TerminalScreen {
433459 return scr434460 }435461 436-var namedKeys = map[string]ghostty.Key{437- "enter": ghostty.KeyEnter, "esc": ghostty.KeyEscape, "backspace": ghostty.KeyBackspace,438- "tab": ghostty.KeyTab, "delete": ghostty.KeyDelete, "home": ghostty.KeyHome, "end": ghostty.KeyEnd,439- "up": ghostty.KeyArrowUp, "down": ghostty.KeyArrowDown,440- "left": ghostty.KeyArrowLeft, "right": ghostty.KeyArrowRight,441- "pgup": ghostty.KeyPageUp, "pgdown": ghostty.KeyPageDown,442- "f1": ghostty.KeyF1, "f2": ghostty.KeyF2, "f5": ghostty.KeyF5, "f10": ghostty.KeyF10, "f11": ghostty.KeyF11,443- "space": ghostty.KeySpace,444-}445-446-type physKey struct {447- key ghostty.Key448- mods ghostty.Mods449- base rune // unshifted character, for shifted symbols450-}451-452-// charKeys maps typed characters to physical keys on a US layout.453-var charKeys = func() map[rune]physKey {454- m := map[rune]physKey{' ': {key: ghostty.KeySpace}}455- for i := range 26 {456- k := ghostty.KeyA + ghostty.Key(i)457- m[rune('a'+i)] = physKey{key: k}458- m[rune('A'+i)] = physKey{key: k, mods: ghostty.ModShift, base: rune('a' + i)}459- }460- digits := ")!@#$%^&*("461- for i := range 10 {462- k := ghostty.KeyDigit0 + ghostty.Key(i)463- m[rune('0'+i)] = physKey{key: k}464- m[rune(digits[i])] = physKey{key: k, mods: ghostty.ModShift, base: rune('0' + i)}465- }466- for _, p := range []struct {467- plain, shifted rune468- key ghostty.Key469- }{470- {'-', '_', ghostty.KeyMinus}, {'=', '+', ghostty.KeyEqual},471- {',', '<', ghostty.KeyComma}, {'.', '>', ghostty.KeyPeriod},472- {'/', '?', ghostty.KeySlash}, {';', ':', ghostty.KeySemicolon},473- {'\'', '"', ghostty.KeyQuote},474- } {475- m[p.plain] = physKey{key: p.key}476- m[p.shifted] = physKey{key: p.key, mods: ghostty.ModShift, base: p.plain}477- }478- return m479-}()480-481462 // configEnv gives a session its own config directory, with o.config as482463 // its config file, and turns off the telemetry, theme and locale variables a483464 // developer's shell may set.
A e2e/keys_test.go +164 -0
@@ -0,0 +1,164 @@
1+package e2e2+3+import (4+ "bytes"5+ "regexp"6+7+ ghostty "go.mitchellh.com/libghostty"8+)9+10+// Keys as a terminal sends them. The program asks for the kitty keyboard11+// protocol (Bubble Tea asks for its first level, 012 for key events too)12+// and libghostty encodes keys for what it turned on, so sessions are13+// Ghostty's. A session with legacyKeys never sees those requests, as a14+// terminal without the protocol ignores them, and sends keys the legacy15+// way.16+17+// keyboardModes are the sequences that push, pop, set and query the kitty18+// keyboard protocol's flags, and turn modifyOtherKeys on or off.19+var keyboardModes = regexp.MustCompile(`\x1b\[(?:[>=<?][0-9;]*u|>4(?:;[0-9]*)?m)`)20+21+// keyFilter takes keyboardModes out of the program's output, keeping a22+// sequence split between reads until its end arrives.23+type keyFilter struct {24+ carry []byte25+}26+27+// filter returns data without keyboardModes. A nil filter changes28+// nothing.29+func (f *keyFilter) filter(data []byte) []byte {30+ if f == nil {31+ return data32+ }33+ data = append(f.carry, data...)34+ f.carry = nil35+ if i := bytes.LastIndexByte(data, 0x1b); i >= 0 && unfinished(data[i:]) {36+ f.carry = append([]byte(nil), data[i:]...)37+ data = data[:i]38+ }39+ return keyboardModes.ReplaceAll(data, nil)40+}41+42+// unfinished reports whether seq, which starts with Esc, may be the43+// start of a CSI sequence yet to end.44+func unfinished(seq []byte) bool {45+ if len(seq) < 2 {46+ return true47+ }48+ if seq[1] != '[' {49+ return false50+ }51+ for _, c := range seq[2:] {52+ if c >= 0x40 && c <= 0x7e {53+ return false54+ }55+ }56+ return true57+}58+59+// legacyBytes is what a terminal without the kitty keyboard protocol or60+// modifyOtherKeys sends for the keys libghostty tells apart even then,61+// and whether key is one: no release at all, Enter whatever the62+// modifiers, Ctrl and a letter as a control character, and Alt and a63+// letter as Esc before the letter.64+func legacyBytes(action ghostty.KeyAction, key ghostty.Key, mods ghostty.Mods, unshifted rune) ([]byte, bool) {65+ letter := unshifted >= 'a' && unshifted <= 'z'66+ switch {67+ case action == ghostty.KeyActionRelease:68+ return nil, true69+ case key == ghostty.KeyEnter && mods&ghostty.ModAlt != 0:70+ return []byte("\x1b\r"), true71+ case key == ghostty.KeyEnter:72+ return []byte("\r"), true73+ case letter && mods == ghostty.ModCtrl:74+ return []byte{byte(unshifted) & 0x1f}, true75+ case letter && mods == ghostty.ModAlt:76+ return []byte{0x1b, byte(unshifted)}, true77+ case letter && mods == ghostty.ModAlt|ghostty.ModShift:78+ return []byte{0x1b, byte(unshifted) - 'a' + 'A'}, true79+ }80+ return nil, false81+}82+83+// hold presses a key written as for keys ("<space>", "<shift+enter>")84+// and keeps it down: the terminal repeats it once, as it does for a key85+// held.86+func (s *session) hold(name string) {87+ s.t.Helper()88+ s.keys(name)89+ s.namedKeyAction(ghostty.KeyActionRepeat, name)90+}91+92+// release lets go of a key held.93+func (s *session) release(name string) {94+ s.t.Helper()95+ s.namedKeyAction(ghostty.KeyActionRelease, name)96+}97+98+// namedKeyAction sends a repeat or release of a key in angle brackets.99+func (s *session) namedKeyAction(action ghostty.KeyAction, name string) {100+ s.t.Helper()101+ name = name[1 : len(name)-1]102+ key, ok := namedKeys[name]103+ if !ok {104+ s.t.Fatalf("unknown key <%s>", name)105+ }106+ var cp rune107+ if name == "space" {108+ cp = ' '109+ }110+ s.keyEvent(action, key, 0, "", cp)111+}112+113+// kittyFlags are the keyboard protocol flags the program has on.114+func (s *session) kittyFlags() ghostty.KittyKeyFlags {115+ s.mu.Lock()116+ defer s.mu.Unlock()117+ f, _ := s.vt.KittyKeyboardFlags()118+ return f119+}120+121+var namedKeys = map[string]ghostty.Key{122+ "enter": ghostty.KeyEnter, "esc": ghostty.KeyEscape, "backspace": ghostty.KeyBackspace,123+ "tab": ghostty.KeyTab, "delete": ghostty.KeyDelete, "home": ghostty.KeyHome, "end": ghostty.KeyEnd,124+ "up": ghostty.KeyArrowUp, "down": ghostty.KeyArrowDown,125+ "left": ghostty.KeyArrowLeft, "right": ghostty.KeyArrowRight,126+ "pgup": ghostty.KeyPageUp, "pgdown": ghostty.KeyPageDown,127+ "f1": ghostty.KeyF1, "f2": ghostty.KeyF2, "f5": ghostty.KeyF5, "f10": ghostty.KeyF10, "f11": ghostty.KeyF11,128+ "space": ghostty.KeySpace,129+}130+131+type physKey struct {132+ key ghostty.Key133+ mods ghostty.Mods134+ base rune // unshifted character, for shifted symbols135+}136+137+// charKeys maps typed characters to physical keys on a US layout.138+var charKeys = func() map[rune]physKey {139+ m := map[rune]physKey{' ': {key: ghostty.KeySpace}}140+ for i := range 26 {141+ k := ghostty.KeyA + ghostty.Key(i)142+ m[rune('a'+i)] = physKey{key: k}143+ m[rune('A'+i)] = physKey{key: k, mods: ghostty.ModShift, base: rune('a' + i)}144+ }145+ digits := ")!@#$%^&*("146+ for i := range 10 {147+ k := ghostty.KeyDigit0 + ghostty.Key(i)148+ m[rune('0'+i)] = physKey{key: k}149+ m[rune(digits[i])] = physKey{key: k, mods: ghostty.ModShift, base: rune('0' + i)}150+ }151+ for _, p := range []struct {152+ plain, shifted rune153+ key ghostty.Key154+ }{155+ {'-', '_', ghostty.KeyMinus}, {'=', '+', ghostty.KeyEqual},156+ {',', '<', ghostty.KeyComma}, {'.', '>', ghostty.KeyPeriod},157+ {'/', '?', ghostty.KeySlash}, {';', ':', ghostty.KeySemicolon},158+ {'\'', '"', ghostty.KeyQuote}, {'`', '~', ghostty.KeyBackquote},159+ } {160+ m[p.plain] = physKey{key: p.key}161+ m[p.shifted] = physKey{key: p.key, mods: ghostty.ModShift, base: p.plain}162+ }163+ return m164+}()
A e2e/kitty_test.go +105 -0
@@ -0,0 +1,105 @@
1+package e2e2+3+import (4+ "strings"5+ "testing"6+7+ ghostty "go.mitchellh.com/libghostty"8+)9+10+// With the kitty keyboard protocol, as Ghostty has it, 012 turns on key11+// events; Shift+Enter moves up after an entry and Ctrl+I is italic. A12+// terminal without it sends Shift+Enter as Enter and Ctrl+I as Tab, and13+// 012 does what those do.14+func TestKittyKeyboard(t *testing.T) {15+ t.Run("on", func(t *testing.T) {16+ s := start(t, "")17+ s.eventually("key events on", func() bool {18+ return s.kittyFlags()&ghostty.KittyKeyReportEvents != 019+ })20+ s.keys("<down>", "<down>", "5", "<shift+enter>")21+ s.waitForBar("A2", "")22+ s.keys("<down>", "<ctrl+i>")23+ s.waitFor("Italic on for A3")24+ // Alt+letter opens its menu; Esc then a letter, however quick, is25+ // Esc, then the letter typed.26+ s.keys("<alt+f>")27+ s.waitFor("MENU")28+ s.keys("<esc>", "j")29+ s.waitFor("ENTER")30+ s.waitForBar("A3", "j")31+ })32+ t.Run("off", func(t *testing.T) {33+ s := startWith(t, options{legacyKeys: true})34+ if f := s.kittyFlags(); f != 0 {35+ t.Fatalf("flags %v on a terminal without the protocol", f)36+ }37+ s.keys("<down>", "<down>", "5", "<shift+enter>")38+ s.waitForBar("A4", "")39+ s.keys("<up>", "<ctrl+i>")40+ s.waitForBar("B3", "")41+ s.keys("<alt+f>")42+ s.waitFor("MENU")43+ })44+}45+46+// Holding Space on a selected chart shows it across the grid until it's47+// let go; without key events Space deselects the chart and starts an48+// entry, as it always has.49+func TestHoldSpaceOnChart(t *testing.T) {50+ t.Run("on", func(t *testing.T) {51+ s := start(t, "")52+ spending(s)53+ insertChart(s)54+ s.keys("<enter>")55+ s.waitFor("hold Space to zoom")56+ s.hold("<space>")57+ s.eventually("chart over column A", func() bool {58+ return !strings.Contains(s.line(gridRow1), "Month") && strings.Contains(s.screen(), "let go")59+ })60+ s.release("<space>")61+ s.eventually("chart back at D1", func() bool { return strings.Contains(s.line(gridRow1), "Month") })62+ s.waitFor("hold Space to zoom")63+ if !strings.Contains(s.line(0), "CHART") {64+ t.Errorf("released: %q", s.line(0))65+ }66+ })67+ t.Run("off", func(t *testing.T) {68+ s := startWith(t, options{legacyKeys: true})69+ spending(s)70+ insertChart(s)71+ s.keys("<enter>")72+ s.waitFor("drag the corner to resize")73+ if strings.Contains(s.screen(), "hold Space") {74+ t.Errorf("hint without key events:\n%s", s.screen())75+ }76+ s.keys(" ") // as typed: the terminal sends the space itself77+ s.waitFor("ENTER")78+ })79+}80+81+// Holding Space in the theme picker hides the list, showing the sheet in82+// the highlighted theme, until it's let go; with a search typed, or83+// without key events, Space is typed.84+func TestHoldSpaceInThemePicker(t *testing.T) {85+ t.Run("on", func(t *testing.T) {86+ s := start(t, "")87+ openTheme(s)88+ s.waitFor("hold to peek")89+ s.hold("<space>")90+ s.eventually("list hidden", func() bool { return !strings.Contains(s.screen(), "─ Theme ─") })91+ s.release("<space>")92+ s.waitFor("─ Theme ─")93+ s.keys("tokyo", "<space>")94+ s.waitFor("› tokyo ")95+ })96+ t.Run("off", func(t *testing.T) {97+ s := startWith(t, options{legacyKeys: true})98+ openTheme(s)99+ if strings.Contains(s.screen(), "hold to peek") {100+ t.Errorf("hint without key events:\n%s", s.screen())101+ }102+ s.keys(" x")103+ s.waitFor("› x")104+ })105+}
M e2e/testdata/screens/chart-selected.html +1 -1
@@ -1,6 +1,6 @@
11 <pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">CHART</span><span style="background:var(--vt-palette-6)"> </span>22 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A1</span><span style="background:var(--vt-palette-8)"> </span> Month3-<span style="font-weight:bold">Column</span> <span style="font-weight:bold">chart</span> <span style="color:var(--vt-palette-8)">of</span> A1:C5 <span style="color:var(--vt-palette-8)">drag</span> <span style="color:var(--vt-palette-8)">to</span> <span style="color:var(--vt-palette-8)">move,</span> <span style="color:var(--vt-palette-8)">drag</span> <span style="color:var(--vt-palette-8)">the</span> <span style="color:var(--vt-palette-8)">corner</span> <span style="color:var(--vt-palette-8)">to</span> <span style="color:var(--vt-palette-8)">resize</span>3+<span style="font-weight:bold">Column</span> <span style="font-weight:bold">chart</span> <span style="color:var(--vt-palette-8)">of</span> A1:C5 <span style="color:var(--vt-palette-8)">drag</span> <span style="color:var(--vt-palette-8)">to</span> <span style="color:var(--vt-palette-8)">move,</span> <span style="color:var(--vt-palette-8)">drag</span> <span style="color:var(--vt-palette-8)">the</span> <span style="color:var(--vt-palette-8)">corner</span> <span style="color:var(--vt-palette-8)">to</span> <span style="color:var(--vt-palette-8)">resize,</span> <span style="color:var(--vt-palette-8)">hold</span> <span style="color:var(--vt-palette-8)">Space</span> <span style="color:var(--vt-palette-8)">to</span> <span style="color:var(--vt-palette-8)">zoom</span>44 <span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>55 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Month</span><span style="background:var(--vt-palette-6)"> </span> Rent Food <span style="color:var(--vt-palette-6);font-weight:bold">┌─</span> <span style="font-weight:bold">Rent</span> <span style="font-weight:bold">and</span> <span style="font-weight:bold">Food</span> <span style="color:var(--vt-palette-6);font-weight:bold">──────────────────────────────┐</span>66 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> Jan $1,450 $600 <span style="color:var(--vt-palette-6);font-weight:bold">│</span> <span style="color:var(--vt-palette-6);font-weight:bold">│</span>
M e2e/testdata/screens/vim-command-light.html +1 -1
@@ -27,4 +27,4 @@
2727 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>2828 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>2929 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30-Find text and replace it <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Tab</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">complete</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">run</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">cancel</span></pre>\ No newline at end of file30+Find text and replace it <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Tab</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">complete</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Up</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">history</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">run</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">cancel</span></pre>\ No newline at end of file
M e2e/testdata/screens/vim-command.html +1 -1
@@ -27,4 +27,4 @@
2727 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>2828 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>2929 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30-Find text and replace it <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Tab</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">complete</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">cancel</span></pre>\ No newline at end of file30+Find text and replace it <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Tab</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">complete</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">history</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">cancel</span></pre>\ No newline at end of file
A e2e/vimmore_test.go +66 -0
@@ -0,0 +1,66 @@
1+package e2e2+3+import (4+ "os"5+ "path/filepath"6+ "testing"7+ "time"8+)9+10+// . repeats a change, "a keeps a copy of its own, marks and two quotes11+// jump back, cc clears a row to retype, Up on the : line recalls a line,12+// and :w! writes over a file changed on disk.13+func TestVimRepeatRegistersMarks(t *testing.T) {14+ dir := t.TempDir()15+ s := start(t, dir)16+ vimOn(s)17+ s.keys("i", "a", "<tab>", "i", "1", "<enter>")18+ s.keys("i", "b", "<tab>", "i", "2", "<enter>")19+ s.keys("i", "c", "<tab>", "i", "3", "<enter>")20+ s.waitForBar("A4", "")21+22+ // A mark on B1, a jump away and back, then back to the row jumped from.23+ s.keys("gg", "l", "mq", "G", "`q")24+ s.waitForBar("B1", "1")25+ s.keys("''")26+ s.waitForBar("A3", "c")27+28+ // x and . clear two cells; "ayy keeps row 1 while dd fills the29+ // clipboard, and "ap pastes row 1 back as a new row.30+ s.keys("gg", "l", "x", "j", ".")31+ s.waitForBar("B2", "")32+ s.keys("gg", `"ayy`, "j", "dd", "G", "0", `"ap`)33+ s.waitForBar("A3", "a")34+35+ // cc clears the row and starts typing; . does it again below.36+ s.keys("gg", "cc", "top", "<enter>")37+ s.waitForBar("A2", "c")38+ s.keys(".")39+ s.waitForBar("A3", "a")40+ s.keys("k")41+ s.waitForBar("A2", "top")42+43+ // The : line remembers what ran.44+ s.keys(":B3", "<enter>", "gg", ":", "<up>")45+ s.waitFor(":B3")46+ s.keys("<enter>")47+ s.waitForBar("B3", "")48+49+ s.keys(":w vim", "<enter>")50+ s.eventually("saved", func() bool { return s.title() == "012 - vim.012" })51+ name := filepath.Join(dir, "vim.012")52+ later := time.Now().Add(time.Minute)53+ if err := os.WriteFile(name, []byte("{}"), 0o644); err != nil {54+ t.Fatal(err)55+ }56+ if err := os.Chtimes(name, later, later); err != nil {57+ t.Fatal(err)58+ }59+ s.keys("gg", "0", "x", ":w", "<enter>")60+ s.waitFor("changed on disk")61+ s.keys("<esc>", ":w!", "<enter>")62+ s.eventually("written", func() bool {63+ data, _ := os.ReadFile(name)64+ return string(data) != "{}" && s.title() == "012 - vim.012"65+ })66+}
M internal/ui/vim.go +4 -2
M internal/ui/vimmore_test.go +22 -2
@@ -58,6 +58,22 @@ func TestVimRepeat(t *testing.T) {
5858 }5959 }6060 61+// Every sequence begun shows what finishes it.62+func TestVimPendingLines(t *testing.T) {63+ m := vimModel(t)64+ for _, prefix := range []string{"d", "y", "c", "g", `"`, "m", "'", "`", `"a`, "3"} {65+ press(t, m, prefix)66+ if l := line(m, contextLine); !strings.HasPrefix(l, prefix) || !strings.Contains(l, "Esc") {67+ t.Errorf("%s: %q", prefix, l)68+ }69+ press(t, m, "<esc>")70+ }71+ press(t, m, "V", "3")72+ if l := line(m, contextLine); !strings.HasPrefix(l, "3") {73+ t.Errorf("V 3: %q", l)74+ }75+}76+6177 // Registers: "a keeps its own copy, "0 the last copy, "1 to "9 deleted6278 // rows and "- deleted cells, while the clipboard follows every copy.6379 func TestVimRegisters(t *testing.T) {
@@ -143,7 +159,11 @@ func TestVimSystemClipboard(t *testing.T) {
143159 // what they cleared to paste.144160 func TestVimChange(t *testing.T) {145161 m := vimModel(t)146- press(t, m, "j", "l", "cc")162+ press(t, m, "j", "l", "c")163+ if l := line(m, contextLine); !strings.Contains(l, "clear the row and start typing") {164+ t.Errorf("c: %q", l)165+ }166+ press(t, m, "c")147167 if m.mode != modeEnter || m.cur != addr("B2") || input(m, "A2") != "" || input(m, "C2") != "" {148168 t.Fatalf("cc: mode %v at %v, A2 %q", m.mode, m.cur, input(m, "A2"))149169 }
@@ -173,7 +193,7 @@ func TestVimChange(t *testing.T) {
173193 }174194 175195 // Marks: m sets one, ` goes back to the cell and ' to its row, across176-// sheets; '' goes back from a jump.196+// sheets; a quote typed twice goes back from a jump.177197 func TestVimMarks(t *testing.T) {178198 m := vimModel(t)179199 press(t, m, "j", "l", "ma")