cat / 012
clone

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

Merge vim keys and the undo byte cap; rank palette titles with a word starting with the query first

Dami committed 0e752a3
Resolved against 012 serve: :w, :e and downloads go through the confined
paths and the on-disk change check. With the new Command line entry, "col"
ranked it above every column command; titles whose words start with the
query now come first.
commit
0e752a3e80fdac6ed211963e21ea35e429f97903
parent
117cd0d605ba7afd1ce564c8b6c6733f88835763
parent
6f25f2ba07950e4efe8e89fc1c1ef2deda6e887d
author
Dami <dami.etoile@gmail.com>
45 files changed, +2378 -138
MREADME.md4+-
MROADMAP.md2+-
Mdocs/architecture.md2+
Mdocs/extending.md3+-
Mdocs/keys.md62+++++-
Mdocs/limits.md34++-
Me2e/screenlist_test.go23++-
Me2e/testdata/screens/menu-settings.html4+-
Me2e/testdata/screens/palette-narrow.html4+-
Me2e/testdata/screens/palette-search-light.html2+-
Me2e/testdata/screens/palette-search.html2+-
Me2e/testdata/screens/palette-wide.html2+-
Me2e/testdata/screens/palette.html6+-
Ae2e/testdata/screens/vim-command-light.html30++
Ae2e/testdata/screens/vim-command.html30++
Ae2e/testdata/screens/vim-normal-light.html30++
Ae2e/testdata/screens/vim-normal.html30++
Ae2e/testdata/screens/vim-visual-light.html30++
Ae2e/testdata/screens/vim-visual.html30++
Ae2e/vim_test.go64+++++
Minternal/sheet/history.go73++++--
Ainternal/sheet/historysize.go106++++++++
Ainternal/sheet/historysize_test.go132++++++++++
Minternal/sheet/stress_test.go20++
Minternal/ui/charts.go2+-
Ainternal/ui/cmdline.go362++++++++++++++++++++++++++++
Minternal/ui/commands.go35++-
Minternal/ui/download.go42++-
Minternal/ui/file.go37++-
Minternal/ui/filter.go2+-
Minternal/ui/find.go44+++
Minternal/ui/help.go18+-
Minternal/ui/help_test.go6+-
Minternal/ui/menubar.go2+-
Minternal/ui/menuoverlay.go2+-
Minternal/ui/model.go12+-
Minternal/ui/palette.go37++-
Minternal/ui/palette_test.go20++
Minternal/ui/panel.go11+-
Ainternal/ui/prefs.go48++++
Ainternal/ui/rows.go103++++++++
Minternal/ui/selection.go59+++--
Ainternal/ui/vim.go320+++++++++++++++++++++++++
Ainternal/ui/vim_test.go390++++++++++++++++++++++++++++++
Ainternal/ui/vimkeys.go239++++++++++++++++++
M README.md +3 -1
M ROADMAP.md +1 -1
M docs/architecture.md +2 -0
M docs/extending.md +1 -2
@@ -15,7 +15,7 @@ shows it is derived from the table rather than listing it again.
1515 | Registry | Entry | Derived from it |1616 |---|---|---|1717 | `sheet.FuncDef` | a function: name, signature, description, arity, eval | parsing, autocomplete, argument hints, help, [functions.md](functions.md) |18-| `ui.command` | an action: id, title, description, run, enabled, checked | key bindings, menu bar, context menus, palette, shortcuts help |18+| `ui.command` | an action: id, title, description, run, enabled, checked | key bindings (Sheets and vim), menu bar, context menus, palette, shortcuts help, the `:` command line and its completions |1919 | `fileio` formats (`formats.go`) | a format: name, extensions, labels, traits, importer, exporter | `Import`, `Export`, detection, import picker, File > Download, command line |2020 | `chart.types`, with `sheet.ChartTypes` | a type: name and order (sheet, saved in files), a layout drawing text and image (chart) | `chart.Draw`, `chart.Image`, chart editor, Insert > Chart |2121 | theme roles | a role: dark and light styles on the 16 ANSI colors | every style in the UI |
@@ -114,7 +114,6 @@ a golden screen reviewed in the gallery for anything visible (see
114114 115115 Where the code doesn't follow the patterns yet:116116 117-- Undo history is capped by step count, not bytes (pattern 8).118117 - Components in `internal/ui` are handed the whole `*Model` rather than a119118 narrower interface, so they stay in package `ui` (pattern 2).120119 - Movement keys, typing, F4 in formulas, Alt+letter menus and direct mouse
M docs/keys.md +60 -2
@@ -6,8 +6,6 @@ Alt+letter) and the command palette (Ctrl+K), which shows each command's
66 shortcut. F1 or Ctrl+/ lists every binding, generated from the same table77 the app uses, so it never drifts from what the keys do.88 9-Inside the grid, 012 works like Google Sheets.10-119 | Key | Action |1210 |---|---|1311 | Type | Replace the cell. `=` starts a formula, `'` forces text; `$1,200`, `12%`, `9/26/2026` and `14:30` are numbers that keep their format |
@@ -51,6 +49,66 @@ Inside the grid, 012 works like Google Sheets.
5149 | Click a chart, then Arrows / Shift+arrows / Del | Move, resize or delete it; drag the chart or its corner with the mouse; Enter edits it |5250 | Cmd- or Ctrl-click a URL | Open it: cells holding a URL, and `=HYPERLINK(url, [label])`, are terminal hyperlinks |5351 52+## Vim keys53+54+File > Settings > Vim keys (also `:settings.vim` or the palette) turns55+on a vim keymap in the spirit of sc-im and VisiData. It is off by56+default and belongs to you, not the sheet: it isn't saved in `.012`57+files, and File > New and Open keep it. The mode indicator says58+**NORMAL** where it would say READY, **VISUAL** while selecting with59+`v` or `V`, and **COMMAND** on the `:` line; typing into a cell is60+ENTER or EDIT, as always, and Enter, Tab or Esc there return to NORMAL.61+Keys vim doesn't take keep their Sheets meaning (arrows, Del, Ctrl+S,62+Ctrl+C/V, Alt+letter menus, F-keys); Ctrl+D, Ctrl+U and Ctrl+R are63+vim's, so fill down and fill right are in the Edit menu. F1 lists the64+vim keys first, and the menus and palette show them where Sheets' keys65+are taken.66+67+| Keys (NORMAL) | Action |68+|---|---|69+| `h` `j` `k` `l`, arrows | Left, down, up, right |70+| `w` `b` | Jump to the edge of the data, right or left (as Ctrl+arrows) |71+| `gg` `G` | First row, last row with data; with a count, that row (`12G`) |72+| `0` `^` `$` | Column A, the last filled cell of the row |73+| `H` `M` `L` | Top, middle, bottom row on screen |74+| Ctrl+D, Ctrl+U | Half a screen down, up |75+| A count (`5j`, `3dd`, `4x`) | Repeats a move or command, or says how many rows or cells an operator takes |76+| `i` `a`, Enter | Edit the cell, caret at the start or the end |77+| `=` | Start a formula |78+| `o` `O` | Insert a row below or above and start typing in it |79+| `x` | Clear the cell (with a count, that many to the right) |80+| `dd` `yy` | Cut or copy the row (with a count, that many rows); `p` pastes them back |81+| `p` `P` | Paste rows cut or copied with `dd` or `yy` as new rows below or above; other copied cells paste at the active cell |82+| `u`, Ctrl+R | Undo, redo |83+| `v` `V` | Select cells or whole rows (VISUAL); motions stretch the selection |84+| `/`, `n` `N` | Find (Enter stays on the match), next and previous match |85+| `gt` `gT` | Next, previous sheet |86+| `:` | The command line |87+| Esc | Cancel a count or sequence, leave VISUAL, deselect |88+89+In VISUAL mode `d` or `x` deletes the selection (whole rows with `V`,90+the cells' contents with `v`) and keeps a copy to paste, `y` copies it,91+`p` pastes over it, `o` goes to the other corner, and `v`, `V` or Esc go92+back to NORMAL.93+94+The `:` line takes:95+96+| Command | Action |97+|---|---|98+| `:B12`, `:Sheet2!A1`, `:C3:D9`, `:Sales` | Go to a cell, range or named range |99+| `:40` | Go to row 40 |100+| `:w`, `:w name`, `:w out.csv` | Save, Save as, or Download as another format, just as the File menu does |101+| `:q`, `:q!` | Quit (asking about unsaved changes), quit discarding them |102+| `:wq`, `:x` | Save and quit; `:x` saves only if something changed |103+| `:e name`, `:e!` | Open a sheet or import a file; refused with unsaved changes unless `:e!` |104+| `:edit.fill_down`, `:fill down` | Any command, by its ID or title |105+106+Completions for commands appear under the line as you type, drawn from107+the same registry as the menus and palette: Tab puts the highlighted108+one on the line (Tab again for the next, Shift+Tab back), Up and Down109+move, and Enter runs the line, or the highlighted completion when the110+line isn't a command itself (`:fill d` Enter fills down).111+54112 ## Mouse55113 56114 Click a cell to select it, drag to select a range, Shift+click to extend,
M docs/limits.md +26 -8
@@ -25,7 +25,7 @@ it lags, and past a second it stalls.
2525 | Rendering | Any sheet at up to 200 x 60: 1 ms a frame; 400 x 120: 4.5 ms | 20 charts at 400 x 120: 7 ms | | View building styled strings, then Bubble Tea parsing and diffing them |2626 | Selection statistics | Any selection, once computed (cached) | Extending a selection over 2 M cells: 30 ms per key | | Summing 2 M map entries per change |2727 | Imports | CSV, SQLite, Parquet: 1 to 3 M cells/s (a full 2.1 M-cell sheet in about 1 s) | XLSX with formulas: 200 k cells/s | Data past the limits (dropped, with a note) | Building cells one at a time; XLSX formula translation |28-| Undo | One step of any size: undo costs what the edit cost | 100 steps that each rewrite a whole column: 217 MB held | | Whole-cell before-images, 264 B per cell per step |28+| Undo | One step of any size: undo costs what the edit cost | History capped at 100 steps and 256 MB of before-images: 100 whole-column steps hold 250 MB | | Whole-cell before-images, about 300 B per cell per step |2929 | JEV | Up to about 1000 JEV cells: 0.3 ms of CPU per answer | 4000 JEV cells: 1.3 ms per answer, 5 s of CPU to answer them all | | Every answer recalculates every JEV cell (they're volatile) |3030 | Formula depth | 10,000 nested parentheses or IFs: under 5 ms | | No explicit limit; recursion grows the stack | Recursive parser and evaluator |3131 | Find, filter, sort, fill | Filter or sort 8191 rows: 1 to 32 ms; find over 213 k cells: 38 ms; fill 8192 rows: 4 ms | Replace all over 213 k cells: 280 ms | | Per-cell string conversion and regexp |
@@ -132,11 +132,28 @@ XLSX worksheet in memory while its rows are read.
132132 133133 ## Undo134134 135-Each step keeps a copy of every cell it changed (`MaxUndo = 100`136-steps). Undoing costs about what the change cost, since it recalculates137-the same cells. Clearing 213 k cells and undoing it takes 158 ms. The138-history is bounded by steps, not bytes: 100 steps that each rewrite a139-whole column of 8192 cells hold 217 MB.135+Each step keeps a copy of every cell it changed. Undoing costs about136+what the change cost, since it recalculates the same cells. Clearing137+213 k cells and undoing it takes 158 ms.138+139+The history is bounded by steps (`MaxUndo = 100`) and by an estimate of140+the memory its before-images hold (`MaxUndoBytes = 256 MB`); past either,141+the oldest steps are dropped first, and the newest step is kept however142+large it is. Each cell is counted as it is recorded (a fixed cost per143+cell plus its text, more for a formula, whose parsed tree stays alive),144+so the budget costs nothing to check. The estimate follows the measured145+heap within about 10% (`BenchmarkHistoryFull`, `BenchmarkHistoryWide`,146+3 runs each, same machine):147+148+| Benchmark | Before | After |149+|---|---|---|150+| 100 edits rewriting a whole column (819 k before-images) | 250 MB held, all 100 kept | 250 MB held (239 MB estimated), all 100 kept |151+| 12 edits rewriting all of 8192 x 26 (2.6 M before-images) | 705 MB held, all 12 kept | 230 MB held (248 MB estimated), the last 4 kept |152+| BigUndo: clear 8192 x 26, undo | 154 ms, 430 k allocs | 158 ms, 430 k allocs |153+| Undo + redo of one cell (dense, names) | 0.98 us, 106 us | 1.0 us, 106 us |154+155+Counting cells as they're recorded costs about 20 ns a cell, which is156+BigUndo's 3%; everything else is unchanged.140157 141158 ## JEV142159
@@ -279,8 +296,9 @@ it.
279296 answered (and their dependents), and coalesce answers that arrive in280297 the same frame: answering 4000 questions would cost 4000 small281298 recalcs instead of 4000 full ones.282-6. **Undo bounded by bytes** (S, 1 day). Cap the history by the memory283- its before-images hold, and store formatting-only changes as diffs.299+6. **Smaller undo steps** (S, 1 day). The history is capped by the300+ memory its before-images hold; storing formatting-only changes as301+ diffs rather than whole cells would let it keep more of them.284302 7. **Frame rate** (S, hours). Bubble Tea draws at most 60 frames a285303 second; asking for 120 would halve the key-to-screen floor from about286304 16 ms to 8 ms at the cost of more redraws.
M e2e/screenlist_test.go +22 -1
@@ -365,6 +365,27 @@ var screens = []screen{
365365 s.keys("<alt+shift+k>")366366 s.waitFor("Go to sheet")367367 }},368+ {name: "vim-normal", setup: func(s *session) {369+ budget(s)370+ vimOn(s)371+ s.keys("gg", "2d")372+ s.waitFor("2d")373+ }},374+ {name: "vim-visual", setup: func(s *session) {375+ budget(s)376+ vimOn(s)377+ s.keys("gg", "2j", "v", "2j", "h")378+ s.waitFor("VISUAL")379+ s.waitFor("Sum 2158.4")380+ }},381+ {name: "vim-command", setup: func(s *session) {382+ budget(s)383+ vimOn(s)384+ s.keys(":", "find")385+ s.waitFor("COMMAND")386+ s.waitFor("edit.find")387+ s.keys("<tab>", "<tab>")388+ }},368389 {name: "find-all-sheets", setup: func(s *session) {369390 summary(s)370391 s.keys("<ctrl+f>", "total", "<alt+s>")
@@ -378,7 +399,7 @@ var screens = []screen{
378399 // Key screens are also recorded on a light terminal, where the app picks379400 // its light theme from the reported background color.380401 func init() {381- for _, name := range []string{"budget", "point-range", "selection-stats", "menu", "palette-search", "context-menu-column", "functions", "quit-confirm", "help", "resizing-column", "copy-marker", "copy-marker-selected", "formats", "menu-format-number", "find", "replace", "jev", "import-picker", "import-progress", "import-xlsx", "frozen", "filter-picker", "filtered", "sort-bar", "fill-handle", "chart", "chart-editor", "chart-line", "chart-pie", "links-errors", "autocomplete", "signature", "named-ranges", "trace-precedents", "sheets", "sheets-point", "sheets-menu", "sheets-many"} {402+ for _, name := range []string{"budget", "point-range", "selection-stats", "menu", "palette-search", "context-menu-column", "functions", "quit-confirm", "help", "resizing-column", "copy-marker", "copy-marker-selected", "formats", "menu-format-number", "find", "replace", "jev", "import-picker", "import-progress", "import-xlsx", "frozen", "filter-picker", "filtered", "sort-bar", "fill-handle", "chart", "chart-editor", "chart-line", "chart-pie", "links-errors", "autocomplete", "signature", "named-ranges", "trace-precedents", "sheets", "sheets-point", "sheets-menu", "sheets-many", "vim-normal", "vim-visual", "vim-command"} {382403 for _, sc := range screens {383404 if sc.name == name {384405 sc.name += "-light"
M e2e/testdata/screens/menu-settings.html +2 -2
@@ -9,8 +9,8 @@
99 <span style="color:var(--vt-palette-8)">│</span> Download › <span style="color:var(--vt-palette-8)">│</span>1010 <span style="color:var(--vt-palette-8)">├─────────────────────┤┌────────────────────────┐</span>1111 <span style="color: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)">Settings</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">›</span><span style="background:var(--vt-palette-6)"> </span><span style="color: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)">Decimal</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">arithmetic</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">✓</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>12-<span style="color:var(--vt-palette-8)">├─────────────────────┤└────────────────────────┘</span>13-<span style="color:var(--vt-palette-8)">│</span> Quit <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Q</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>12+<span style="color:var(--vt-palette-8)">├─────────────────────┤│</span> Vim keys <span style="color:var(--vt-palette-8)">│</span>13+<span style="color:var(--vt-palette-8)">│</span> Quit <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Q</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│└────────────────────────┘</span>1414 <span style="color:var(--vt-palette-8)">└─────────────────────┘</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>
M e2e/testdata/screens/palette-narrow.html +2 -2
@@ -3,14 +3,14 @@
33 <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> sa <span style="color:var(--vt-palette-8)">│</span>44 <span style="color:var(--vt-palette-8)">├──────────────────────────────────────────────────────────┤</span>55 <span style="color: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;text-decoration:underline">S</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">ave</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold;text-decoration:underline">a</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">s</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">File</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">Sa</span>ve <span style="color:var(--vt-palette-8)">File</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+S</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>67 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>elect <span style="color:var(--vt-palette-3);font-weight:bold">a</span>ll <span style="color:var(--vt-palette-8)">Edit</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+A</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>78 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>ort range <span style="color:var(--vt-palette-3);font-weight:bold">A</span> to Z <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Sor…</span> <span style="color:var(--vt-palette-8)">│</span>89 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>ort range Z to <span style="color:var(--vt-palette-3);font-weight:bold">A</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Sor…</span> <span style="color:var(--vt-palette-8)">│</span>9-<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">Sa</span>ve <span style="color:var(--vt-palette-8)">File</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+S</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>1010 <span style="color:var(--vt-palette-8)">│</span> Sort <span style="color:var(--vt-palette-3);font-weight:bold">s</span>heet <span style="color:var(--vt-palette-3);font-weight:bold">A</span> to Z <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Sor…</span> <span style="color:var(--vt-palette-8)">│</span>1111 <span style="color:var(--vt-palette-8)">│</span> Sort <span style="color:var(--vt-palette-3);font-weight:bold">s</span>heet Z to <span style="color:var(--vt-palette-3);font-weight:bold">A</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Sor…</span> <span style="color:var(--vt-palette-8)">│</span>1212 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Ask</span> <span style="color:var(--vt-palette-8)">JEV</span> <span style="color:var(--vt-palette-8)">again</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">│</span>1313 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>ort r<span style="color:var(--vt-palette-3);font-weight:bold">a</span>nge <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Sor…</span> <span style="color:var(--vt-palette-8)">│</span>1414 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Select</span> <span style="color:var(--vt-palette-8)">chart</span> <span style="color:var(--vt-palette-8)">│</span>15-<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">24</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">100</span> <span style="color:var(--vt-palette-8)">─┘</span>15+<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">25</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">110</span> <span style="color:var(--vt-palette-8)">─┘</span>1616 Save the sheet u… <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</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)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/palette-search-light.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns right <span style="color:var(--vt-palette-0)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete rows or <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">Edit</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Alt+-</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert rows or <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">Insert</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Alt+=</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">15</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">100</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">16</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">110</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>1919 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>2020 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>
M e2e/testdata/screens/palette-search.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns right <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete rows or <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">Edit</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+-</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert rows or <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">Insert</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+=</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">15</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">100</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">16</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">110</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>
M e2e/testdata/screens/palette-wide.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt rows be<span style="color:var(--vt-palette-3);font-weight:bold">l</span>ow <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Re<span style="color:var(--vt-palette-3);font-weight:bold">se</span>t co<span style="color:var(--vt-palette-3);font-weight:bold">l</span>umn width <span style="color:var(--vt-palette-8)">Format</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt co<span style="color:var(--vt-palette-3);font-weight:bold">l</span>umns right <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">21</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">100</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">22</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">110</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>
M e2e/testdata/screens/palette.html +3 -3
@@ -12,9 +12,9 @@
1212 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Download as XLSX <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1313 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Download as SQLite <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Decimal arithmetic <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Settings</span> <span style="color:var(--vt-palette-8)">│</span>15-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Quit <span style="color:var(--vt-palette-8)">File</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Q</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>16-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Undo <span style="color:var(--vt-palette-8)">Edit</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Z</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">100</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">100</span> <span style="color:var(--vt-palette-8)">─┘</span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Vim keys <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Settings</span> <span style="color:var(--vt-palette-8)">│</span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Quit <span style="color:var(--vt-palette-8)">File</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Q</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">110</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">110</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>
A e2e/testdata/screens/vim-command-light.html +30 -0
@@ -0,0 +1,30 @@
1+<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">COMMAND</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B7</span><span style="background:var(--vt-palette-7)"> </span> =SUM(B3:B5)3+<span style="font-weight:bold">:</span>edit.replace4+<span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Commands</span> <span style="color:var(--vt-palette-8)">─────────────────────────────────┐</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="color:var(--vt-palette-8)">│</span> Find <span style="color:var(--vt-palette-0)">edit.find</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+F</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>6+<span style="color: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)">Find</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">and</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">replace</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">edit.replace</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Ctrl+H</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>7+<span style="color:var(--vt-palette-8)">│</span> Find next <span style="color:var(--vt-palette-0)">edit.find_next</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">n</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>8+<span style="color:var(--vt-palette-8)">│</span> Find previous <span style="color:var(--vt-palette-0)">edit.find_prev</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">N</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>9+<span style="color:var(--vt-palette-8)">└───────────────────────────────────</span> <span style="color:var(--vt-palette-0)">2</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">4</span> <span style="color:var(--vt-palette-8)">─┘</span>10+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span>11+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">7</span><span style="background:var(--vt-palette-6)"> </span> Total <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">2158.4</span><span style="background:var(--vt-palette-6)"> </span>12+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<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>28+<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>29+<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 file
A e2e/testdata/screens/vim-command.html +30 -0
@@ -0,0 +1,30 @@
1+<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">COMMAND</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B7</span><span style="background:var(--vt-palette-8)"> </span> =SUM(B3:B5)3+<span style="font-weight:bold">:</span>edit.replace4+<span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Commands</span> <span style="color:var(--vt-palette-8)">─────────────────────────────────┐</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>5+<span style="color:var(--vt-palette-8)">│</span> Find <span style="color:var(--vt-palette-8)">edit.find</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+F</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>6+<span style="color: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)">Find</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">and</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">replace</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">edit.replace</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Ctrl+H</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>7+<span style="color:var(--vt-palette-8)">│</span> Find next <span style="color:var(--vt-palette-8)">edit.find_next</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">n</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>8+<span style="color:var(--vt-palette-8)">│</span> Find previous <span style="color:var(--vt-palette-8)">edit.find_prev</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">N</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>9+<span style="color:var(--vt-palette-8)">└───────────────────────────────────</span> <span style="color:var(--vt-palette-8)">2</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">4</span> <span style="color:var(--vt-palette-8)">─┘</span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span>11+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">7</span><span style="background:var(--vt-palette-6)"> </span> Total <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">2158.4</span><span style="background:var(--vt-palette-6)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<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>28+<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>29+<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 file
A e2e/testdata/screens/vim-normal-light.html +30 -0
@@ -0,0 +1,30 @@
1+<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">NORMAL</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B1</span><span style="background:var(--vt-palette-7)"> </span>3+<span style="font-weight:bold">2d</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">d</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">cut</span> <span style="color:var(--vt-palette-0)">rows</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>4+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A</span><span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<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> Household<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">budget</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">20</span>266+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span>7+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span> Rent 14508+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> Groceries 612.49+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span> Transit 9610+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span>11+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span> Total 2158.412+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<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>28+<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>29+<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+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-0)">modified</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">:</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">command</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+K</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">search</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+/</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">shortcuts</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/vim-normal.html +30 -0
@@ -0,0 +1,30 @@
1+<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">NORMAL</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B1</span><span style="background:var(--vt-palette-8)"> </span>3+<span style="font-weight:bold">2d</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-8)">cut</span> <span style="color:var(--vt-palette-8)">rows</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>4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><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">B</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)">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>5+<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> Household<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">budget</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">20</span>266+<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>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent 14508+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Groceries 612.49+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Transit 9610+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span>11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span> Total 2158.412+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<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>28+<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>29+<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+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">:</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">command</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/vim-visual-light.html +30 -0
@@ -0,0 +1,30 @@
1+<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">VISUAL</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A3:B5</span><span style="background:var(--vt-palette-7)"> </span> 14503+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">d</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">delete</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">y</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">copy</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">p</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">paste</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">o</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">other</span> <span style="color:var(--vt-palette-0)">corner</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">V</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">rows</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)">back</span>4+<span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-4)">A</span><span style="background:var(--vt-palette-4)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span> Household budget 20266+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span>7+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">3</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-4)">Rent</span><span style="background:var(--vt-palette-4)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">1450</span><span style="background:var(--vt-palette-6)"> </span>8+<span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-4)">4</span><span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-4)">Groceries</span><span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-4)">612.4</span><span style="background:var(--vt-palette-4)"> </span>9+<span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-4)">5</span><span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-4)">Transit</span><span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-4)">96</span><span style="background:var(--vt-palette-4)"> </span>10+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span>11+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span> Total 2158.412+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<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>28+<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>29+<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+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-0)">modified</span> <span style="font-weight:bold">A3:B5</span> <span style="color:var(--vt-palette-0)">Sum</span> 2158.4 <span style="color:var(--vt-palette-0)">Avg</span> 719.47 <span style="color:var(--vt-palette-0)">Count</span> 6</pre>\ No newline at end of file
A e2e/testdata/screens/vim-visual.html +30 -0
@@ -0,0 +1,30 @@
1+<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">VISUAL</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A3:B5</span><span style="background:var(--vt-palette-8)"> </span> 14503+<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-8)">delete</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">y</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">copy</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">p</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">paste</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">o</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">other</span> <span style="color:var(--vt-palette-8)">corner</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">V</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">rows</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)">back</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-4)">A</span><span style="background:var(--vt-palette-4)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</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)">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>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> Household budget 20266+<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>7+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">3</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-4)">Rent</span><span style="background:var(--vt-palette-4)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">1450</span><span style="background:var(--vt-palette-6)"> </span>8+<span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-4)">4</span><span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-4)">Groceries</span><span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-4)">612.4</span><span style="background:var(--vt-palette-4)"> </span>9+<span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-4)">5</span><span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-4)">Transit</span><span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-4)">96</span><span style="background:var(--vt-palette-4)"> </span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span>11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span> Total 2158.412+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<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>28+<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>29+<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+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="font-weight:bold">A3:B5</span> <span style="color:var(--vt-palette-8)">Sum</span> 2158.4 <span style="color:var(--vt-palette-8)">Avg</span> 719.47 <span style="color:var(--vt-palette-8)">Count</span> 6</pre>\ No newline at end of file
A e2e/vim_test.go +64 -0
@@ -0,0 +1,64 @@
1+package e2e2+3+import (4+ "os"5+ "path/filepath"6+ "testing"7+)8+9+// vimOn turns on File > Settings > Vim keys through the menus.10+func vimOn(s *session) {11+ s.t.Helper()12+ s.keys("<alt+f>", "<up>", "<up>", "<right>", "<down>")13+ s.waitFor("Move with hjkl")14+ s.keys("<enter>")15+ s.waitFor("NORMAL")16+}17+18+// Vim keys through a real terminal: letters move and act, counts size19+// operators, : goes to cells and writes the file, and :q quits.20+func TestVimKeys(t *testing.T) {21+ dir := t.TempDir()22+ s := start(t, dir)23+ vimOn(s)24+ s.keys("i", "Rent", "<tab>", "i", "1450", "<enter>")25+ s.keys("i", "Food", "<tab>", "a", "600", "<enter>")26+ s.keys("=", "SUM(B1:B2)", "<enter>")27+ s.waitForLine(gridRow1+2, numRow(3, "2050"))28+29+ // Shift+letters, $ and counts arrive as typed. Pasted rows go in30+ // below, widening the SUM; deleting rows narrows it again.31+ s.keys("gg", "$")32+ s.waitForBar("B1", "1450")33+ s.keys("0", "yy", "2p")34+ s.waitForBar("A2", "Rent")35+ s.keys("G")36+ s.waitForBar("A5", "=SUM(B1:B4)")37+ s.waitForLine(gridRow1+4, numRow(5, "4950"))38+ s.keys("2k", "2dd")39+ s.waitForBar("A3", "=SUM(B1:B2)")40+ s.waitForLine(gridRow1+2, numRow(3, "2900"))41+42+ s.keys("V", "j")43+ s.waitFor("VISUAL")44+ s.keys("<esc>")45+ s.waitFor("NORMAL")46+47+ s.keys(":", "fill")48+ s.waitFor("COMMAND")49+ s.waitFor("edit.fill_down")50+ s.keys("<esc>", ":B2", "<enter>")51+ s.waitForBar("B2", "1450")52+ s.keys("x")53+ s.waitForBar("B2", "")54+ s.keys("u")55+ s.waitForBar("B2", "1450")56+57+ s.keys(":w vim", "<enter>")58+ s.eventually("saved", func() bool { return s.title() == "012 - vim.012" })59+ if _, err := os.Stat(filepath.Join(dir, "vim.012")); err != nil {60+ t.Fatal(err)61+ }62+ s.keys(":q", "<enter>")63+ s.waitExit()64+}
M internal/sheet/history.go +47 -26
@@ -7,7 +7,8 @@ import (
77 "github.com/FelineStateMachine/012/internal/formula"88 )99 10-// MaxUndo is how many steps of undo history a workbook keeps.10+// MaxUndo is how many steps of undo history a workbook keeps, at most;11+// MaxUndoBytes also bounds it.1112 const MaxUndo = 1001213 1314 // History is a command log of before-images, shared by every sheet of a
@@ -27,6 +28,7 @@ type history struct {
2728 depth int // nesting of change calls2829 dirty []loc2930 lastID int31+ bytes int64 // estimated heap held by the undo steps; see historysize.go3032 // mergeWidths lets the next width-only step join the top one, so a3133 // live preview of a column width and its final value (or its3234 // cancellation) are one step. Seal ends the run.
@@ -34,14 +36,18 @@ type history struct {
3436 }3537 3638 type step struct {37- id int38- label string // what the step did, e.g. "clear B3:B5"39- sheet *Sheet // the sheet the UI shows when the step is undone or redone40- focus Rect // what the UI selects there41- cells map[loc]*Cell // before the step; nil for blank42- widths map[colKey]int // before the step; 0 for the default width43- names map[string]*Name // before the step, by key; nil for undefined44- views map[*Sheet]*viewState39+ id int40+ bytes int64 // estimated heap held, set when pushed on the undo stack41+ // cellBytes is the estimated heap held by cells, kept as they are42+ // recorded so sizing a step doesn't walk them again.43+ cellBytes int6444+ label string // what the step did, e.g. "clear B3:B5"45+ sheet *Sheet // the sheet the UI shows when the step is undone or redone46+ focus Rect // what the UI selects there47+ cells map[loc]*Cell // before the step; nil for blank48+ widths map[colKey]int // before the step; 0 for the default width49+ names map[string]*Name // before the step, by key; nil for undefined50+ views map[*Sheet]*viewState4551 // charts holds each touched sheet's charts before the step.4652 charts map[*Sheet][]Chart4753 // sheets is the sheet list before the step, when it changed.
@@ -145,7 +151,7 @@ func (s *Sheet) record(a Addr) {
145151 }146152 l := loc{s, a}147153 if _, seen := st.cells[l]; !seen {148- st.cells[l] = s.cells.get(a).clone()154+ st.keep(l, s.cells.get(a).clone())149155 }150156 s.wb.hist.dirty = append(s.wb.hist.dirty, l)151157 }
@@ -252,10 +258,7 @@ func (w *Workbook) push(st *step) {
252258 return253259 }254260 st.id = h.lastID255- h.undo = append(h.undo, st)256- if len(h.undo) > MaxUndo {257- h.undo = h.undo[len(h.undo)-MaxUndo:]258- }261+ w.pushUndo(st)259262 h.mergeWidths = widthOnly260263 }261264
@@ -269,6 +272,7 @@ func (w *Workbook) dropUnchanged(st *step) {
269272 for l, c := range st.cells {270273 if c == nil && l.s.cells.get(l.a) == nil {271274 delete(st.cells, l)275+ st.cellBytes -= entryBytes272276 }273277 }274278 for k, n := range st.names {
@@ -318,8 +322,11 @@ func (h *history) joinWidths(top, st *step) {
318322 }319323 }320324 top.id, top.focus = h.lastID, union(top.focus, st.focus)325+ h.bytes -= top.bytes326+ top.bytes = top.size()327+ h.bytes += top.bytes321328 if top.empty() {322- h.undo = h.undo[:len(h.undo)-1]329+ h.popUndo()323330 }324331 }325332
@@ -335,10 +342,10 @@ func (h *history) top() *step {
335342 func (w *Workbook) Seal() { w.hist.mergeWidths = false }336343 337344 // Undo reverts the last step and describes it.338-func (w *Workbook) Undo() (Change, bool) { return w.swap(&w.hist.undo, &w.hist.redo) }345+func (w *Workbook) Undo() (Change, bool) { return w.swap(true) }339346 340347 // Redo reapplies the last undone step and describes it.341-func (w *Workbook) Redo() (Change, bool) { return w.swap(&w.hist.redo, &w.hist.undo) }348+func (w *Workbook) Redo() (Change, bool) { return w.swap(false) }342349 343350 // CanUndo and CanRedo report whether there is a step to undo or redo.344351 func (w *Workbook) CanUndo() bool { return len(w.hist.undo) > 0 }
@@ -368,14 +375,24 @@ func (s *Sheet) CanRedo() bool { return s.wb.CanRedo() }
368375 func (s *Sheet) StateID() int { return s.wb.StateID() }369376 func (s *Sheet) ClearHistory() { s.wb.ClearHistory() }370377 371-// swap pops a step from one stack, restores its before-image, and pushes372-// the state it replaced onto the other stack.373-func (w *Workbook) swap(from, to *[]*step) (Change, bool) {374- if len(*from) == 0 || w.hist.open != nil {378+// swap pops a step from the undo stack (or the redo stack), restores its379+// before-image, and pushes the state it replaced onto the other stack.380+func (w *Workbook) swap(undo bool) (Change, bool) {381+ h := &w.hist382+ from := &h.redo383+ if undo {384+ from = &h.undo385+ }386+ if len(*from) == 0 || h.open != nil {375387 return Change{}, false376388 }377- st := (*from)[len(*from)-1]378- *from = (*from)[:len(*from)-1]389+ var st *step390+ if undo {391+ st = h.popUndo()392+ } else {393+ st = h.redo[len(h.redo)-1]394+ h.redo = h.redo[:len(h.redo)-1]395+ }379396 // The inverse keeps the step's ID: on the redo stack, an ID names the380397 // state the step leads back to.381398 inv := newStep(st.label, st.sheet, st.focus)
@@ -387,7 +404,7 @@ func (w *Workbook) swap(from, to *[]*step) (Change, bool) {
387404 }388405 changed := make([]loc, 0, len(st.cells))389406 for l, c := range st.cells {390- inv.cells[l] = l.s.cells.get(l.a).clone()407+ inv.keep(l, l.s.cells.get(l.a).clone())391408 l.s.place(l.a, c.clone())392409 changed = append(changed, l)393410 }
@@ -420,8 +437,12 @@ func (w *Workbook) swap(from, to *[]*step) (Change, bool) {
420437 } else {421438 w.recalc(changed)422439 }423- *to = append(*to, inv)424- w.hist.mergeWidths = false440+ if undo {441+ h.redo = append(h.redo, inv)442+ } else {443+ w.pushUndo(inv)444+ }445+ h.mergeWidths = false425446 return Change{st.label, st.focus, st.sheet, st.sheets != nil}, true426447 }427448
A internal/sheet/historysize.go +106 -0
@@ -0,0 +1,106 @@
1+package sheet2+3+import "slices"4+5+// MaxUndoBytes caps the memory the undo history's before-images hold, as6+// estimated by step.size. When a new step takes the history past it, the7+// oldest steps are dropped first; the newest step is always kept, however8+// large, so any single change can be undone. 256 MB keeps MaxUndo steps9+// that each rewrite a whole column (about 250 MB, see docs/limits.md).10+const MaxUndoBytes = 256 << 2011+12+// undoBudget is MaxUndoBytes; tests lower it.13+var undoBudget int64 = MaxUndoBytes14+15+// Estimated heap held by the parts of a step. A before-image is a shallow16+// copy of a Cell (216 B) plus its map entry and boxed literal; a formula17+// also keeps its parsed tree and reference lists alive, which grow with18+// its text. The constants match the heap measured by the stress19+// benchmarks (BenchmarkHistoryFull) within about 10%.20+const (21+ stepBytes = 512 // the step and its empty maps22+ entryBytes = 64 // a map entry: the key and a pointer23+ cellBytes = 240 // a copied Cell and its boxed literal24+ formulaBytes = 256 // a parsed formula's tree and references, plus perFormulaByte per byte of text25+ perFormulaByte = 1626+ chartBytes = 12827+)28+29+// keep records c as the before-image of the cell at l and counts it.30+func (st *step) keep(l loc, c *Cell) {31+ st.cells[l] = c32+ st.cellBytes += entryBytes + cellSize(c)33+}34+35+// size estimates the heap a finished step holds: its cells, counted as36+// they were recorded, and the rest, which is small.37+func (st *step) size() int64 {38+ n := stepBytes + st.cellBytes39+ n += int64(len(st.widths)+len(st.names)+len(st.views)) * entryBytes40+ for _, charts := range st.charts {41+ n += entryBytes + int64(len(charts))*chartBytes42+ }43+ if st.sheets != nil {44+ n += int64(len(st.sheets.order)) * 2 * entryBytes45+ }46+ return n47+}48+49+// cellSize estimates the heap one before-image holds; nil stands for a50+// blank cell.51+func cellSize(c *Cell) int64 {52+ if c == nil {53+ return 054+ }55+ n := int64(cellBytes + len(c.Input))56+ if c.IsFormula() {57+ n += formulaBytes + perFormulaByte*int64(len(c.Input))58+ }59+ return n60+}61+62+// deletedSize estimates the cells held by sheets a step's sheet list63+// has that the workbook no longer does: a deleted sheet keeps its cells64+// so undo can bring it back.65+func (w *Workbook) deletedSize(st *step) int64 {66+ if st.sheets == nil {67+ return 068+ }69+ var n int6470+ for _, s := range st.sheets.order {71+ if !slices.Contains(w.sheets, s) {72+ n += int64(s.Len()) * (entryBytes + cellBytes)73+ }74+ }75+ return n76+}77+78+// pushUndo adds st to the undo stack with its size, then drops the79+// oldest steps while the history holds more than MaxUndo steps or more80+// than the byte budget.81+func (w *Workbook) pushUndo(st *step) {82+ h := &w.hist83+ st.bytes = st.size() + w.deletedSize(st)84+ h.undo = append(h.undo, st)85+ h.bytes += st.bytes86+ drop := 087+ for len(h.undo)-drop > MaxUndo || h.bytes > undoBudget && len(h.undo)-drop > 1 {88+ h.bytes -= h.undo[drop].bytes89+ drop++90+ }91+ if drop > 0 {92+ clear(h.undo[:drop])93+ h.undo = h.undo[drop:]94+ }95+}96+97+// popUndo removes and returns the top of the undo stack.98+func (h *history) popUndo() *step {99+ st := h.undo[len(h.undo)-1]100+ h.undo = h.undo[:len(h.undo)-1]101+ h.bytes -= st.bytes102+ return st103+}104+105+// HistoryBytes estimates the memory the undo steps hold.106+func (w *Workbook) HistoryBytes() int64 { return w.hist.bytes }
A internal/sheet/historysize_test.go +132 -0
@@ -0,0 +1,132 @@
1+package sheet2+3+import (4+ "strconv"5+ "testing"6+)7+8+// fillColumn writes v into rows 0..n-1 of column A as one step.9+func fillColumn(t *testing.T, s *Sheet, n int, v string) {10+ t.Helper()11+ col := NewRect(Addr{}, Addr{Row: n - 1})12+ if err := s.FillEntry(col, col.From, v); err != nil {13+ t.Fatal(err)14+ }15+}16+17+// The history drops its oldest steps once it holds more than the byte18+// budget, keeps a running total that matches its steps, and always keeps19+// the newest step.20+func TestUndoByteBudget(t *testing.T) {21+ s := New()22+ fillColumn(t, s, 1000, "x")23+ w := s.Book()24+ w.ClearHistory()25+ fillColumn(t, s, 1000, "0") // before-images of 1000 cells26+ one := w.HistoryBytes()27+ if one < 1000*cellBytes/2 || one > 1000*(entryBytes+cellBytes+64) {28+ t.Fatalf("a 1000-cell step is estimated at %d bytes", one)29+ }30+ defer func(b int64) { undoBudget = b }(undoBudget)31+ undoBudget = 5 * one32+33+ for i := 1; i < 20; i++ {34+ fillColumn(t, s, 1000, strconv.Itoa(i))35+ }36+ if w.HistoryBytes() > undoBudget {37+ t.Errorf("history holds %d bytes, over the budget of %d", w.HistoryBytes(), undoBudget)38+ }39+ if got := historySum(w); got != w.HistoryBytes() {40+ t.Errorf("running total %d, steps add up to %d", w.HistoryBytes(), got)41+ }42+ n := 043+ for s.CanUndo() {44+ s.Undo()45+ n++46+ }47+ if n < 3 || n > 5 {48+ t.Errorf("kept %d steps under a budget of about 5", n)49+ }50+ // The oldest steps went first: undoing everything kept lands on the51+ // value written n steps before the last.52+ if want := strconv.Itoa(19 - n); inputs(s)["A1"] != want {53+ t.Errorf("after undoing all: A1 %q, want %q", inputs(s)["A1"], want)54+ }55+ if w.HistoryBytes() != 0 {56+ t.Errorf("empty undo stack holds %d bytes", w.HistoryBytes())57+ }58+ // Redoing brings the steps back onto the undo stack, counted again.59+ for s.CanRedo() {60+ s.Redo()61+ }62+ if got := historySum(w); got != w.HistoryBytes() || got == 0 || got > undoBudget {63+ t.Errorf("after redo: running total %d, steps %d", w.HistoryBytes(), got)64+ }65+66+ // A single step bigger than the budget is still kept.67+ undoBudget = one / 268+ fillColumn(t, s, 1000, "big")69+ if !s.CanUndo() {70+ t.Fatal("the newest step was dropped")71+ }72+ s.Undo()73+ if s.CanUndo() {74+ t.Error("older steps survived a budget smaller than one step")75+ }76+ if inputs(s)["A1"] != "19" {77+ t.Errorf("undo big: A1 %q", inputs(s)["A1"])78+ }79+}80+81+// Formulas are estimated larger than numbers: their parsed trees stay82+// alive in the before-image.83+func TestUndoBytesFormulas(t *testing.T) {84+ nums, forms := New(), New()85+ fillColumn(t, nums, 100, "1")86+ fillColumn(t, forms, 100, "=SUM(B1:B10)*2")87+ nums.Book().ClearHistory()88+ forms.Book().ClearHistory()89+ fillColumn(t, nums, 100, "")90+ fillColumn(t, forms, 100, "")91+ if n, f := nums.Book().HistoryBytes(), forms.Book().HistoryBytes(); f < n+100*formulaBytes {92+ t.Errorf("clearing 100 numbers %d bytes, 100 formulas %d", n, f)93+ }94+}95+96+// A deleted sheet keeps its cells for undo, and the step that deleted it97+// counts them.98+func TestUndoBytesCountDeletedSheet(t *testing.T) {99+ s := New()100+ w := s.Book()101+ data, err := w.AddSheet("Data", 1)102+ if err != nil {103+ t.Fatal(err)104+ }105+ for i := range 500 {106+ data.Set(Addr{Row: i}, "x")107+ }108+ w.ClearHistory()109+ if err := w.DeleteSheet(data); err != nil {110+ t.Fatal(err)111+ }112+ if got := w.HistoryBytes(); got < 500*cellBytes {113+ t.Errorf("deleting a 500-cell sheet is estimated at %d bytes", got)114+ }115+}116+117+// historySum adds up the undo steps' sizes, checking each step's118+// running count of its cells against a walk over them.119+func historySum(w *Workbook) int64 {120+ var n int64121+ for _, st := range w.hist.undo {122+ var cells int64123+ for _, c := range st.cells {124+ cells += entryBytes + cellSize(c)125+ }126+ if cells != st.cellBytes {127+ return -1128+ }129+ n += st.bytes130+ }131+ return n132+}
M internal/sheet/stress_test.go +20 -0
M internal/ui/charts.go +1 -1
A internal/ui/cmdline.go +362 -0
@@ -0,0 +1,362 @@
1+package ui2+3+import (4+ "cmp"5+ "slices"6+ "strconv"7+ "strings"8+9+ tea "charm.land/bubbletea/v2"10+ "github.com/charmbracelet/x/ansi"11+12+ "github.com/FelineStateMachine/012/internal/fileio"13+ "github.com/FelineStateMachine/012/internal/sheet"14+ "github.com/FelineStateMachine/012/internal/ui/theme"15+)16+17+// The command line (: with vim keys, or from the palette) takes vim's18+// file commands (:w, :q, :wq, :x, :e file), a cell, range, named range or19+// row number to go to (:B12, :Sheet2!A1, :40), or any registered command20+// by its ID or title (:edit.fill_down, :fill down). Completions come from21+// the command registry as you type, in a box under the context line, so22+// there's no second list of commands to keep up.23+24+type cmdLine struct {25+ list26+ shown []cmdItem27+ tabbed bool // the text is a completion Tab put there; Tab again moves on28+}29+30+// cmdItem is a completion: a file command or a registered command.31+type cmdItem struct {32+ word string // what Tab puts on the line: "w", or a command's ID33+ title string34+ desc string35+ key string // the command's shortcut36+ off bool // unavailable right now37+}38+39+// fileWords are vim's file commands.40+var fileWords = []cmdItem{41+ {word: "w", title: "Write", desc: "Save the sheet; :w name saves it as name, :w name.csv downloads it"},42+ {word: "q", title: "Quit", desc: "Close 012, asking about unsaved changes"},43+ {word: "q!", title: "Quit without saving", desc: "Close 012, discarding unsaved changes"},44+ {word: "wq", title: "Write and quit", desc: "Save the sheet, then close 012"},45+ {word: "x", title: "Write if changed and quit", desc: "Save the sheet if it changed, then close 012"},46+ {word: "e", title: "Edit a file", desc: "Open a sheet or import a file: :e name"},47+}48+49+const cmdLineID = "cmdline"50+51+func init() {52+ register(&command{id: "vim.command", title: "Command line",53+ desc: "Type a command: a cell to go to (B12), w, q, wq, e file, or any command by name",54+ run: func(m *Model) tea.Cmd {55+ c := &cmdLine{}56+ m.line.clear()57+ m.openOverlay(c)58+ c.changed(m)59+ return nil60+ }})61+}62+63+func (c *cmdLine) indicator() string { return "COMMAND" }64+65+// changed completes what's typed: file commands, then commands whose ID66+// or title starts with it, then those that contain it. Nothing is67+// completed once a file command's argument follows.68+func (c *cmdLine) changed(m *Model) {69+ c.sel, c.top, c.tabbed = 0, 0, false70+ c.shown = c.shown[:0]71+ q := strings.ToLower(strings.TrimPrefix(strings.TrimLeft(m.line.text(), " "), ":"))72+ if word, _, arg := strings.Cut(q, " "); arg && isFileWord(word) {73+ return74+ }75+ for _, w := range fileWords {76+ if strings.HasPrefix(w.word, q) {77+ c.shown = append(c.shown, w)78+ }79+ }80+ var starts, contains []cmdItem81+ for _, id := range commandIDs() {82+ cm := commands[id]83+ title := strings.ToLower(cm.title)84+ it := cmdItem{word: id, title: cm.title, desc: cm.desc, key: m.shortcut(id), off: !cm.available(m)}85+ switch {86+ case id == "vim.command":87+ case strings.HasPrefix(id, q) || strings.HasPrefix(title, q):88+ starts = append(starts, it)89+ case strings.Contains(id, q) || strings.Contains(title, q):90+ contains = append(contains, it)91+ }92+ }93+ c.shown = append(append(c.shown, starts...), contains...)94+}95+96+// isFileWord reports whether word is one of vim's file commands, which97+// take an argument.98+func isFileWord(word string) bool {99+ return slices.ContainsFunc(fileWords, func(it cmdItem) bool { return it.word == strings.TrimSuffix(word, "!") })100+}101+102+// commandIDs are the registered commands' IDs, by title.103+func commandIDs() []string {104+ ids := make([]string, 0, len(commands))105+ for id := range commands {106+ ids = append(ids, id)107+ }108+ slices.SortFunc(ids, func(a, b string) int {109+ return cmp.Or(cmp.Compare(commands[a].title, commands[b].title), cmp.Compare(a, b))110+ })111+ return ids112+}113+114+func (c *cmdLine) key(m *Model, k tea.KeyPressMsg) tea.Cmd {115+ switch key := k.String(); {116+ case key == "esc", key == "backspace" && len(m.line.buf) == 0:117+ m.closeOverlay()118+ case key == "enter":119+ return c.run(m)120+ case key == "tab":121+ c.complete(m, 1)122+ case key == "shift+tab":123+ c.complete(m, -1)124+ case key == "up", key == "ctrl+p":125+ c.move(-1, len(c.shown))126+ case key == "down", key == "ctrl+n":127+ c.move(1, len(c.shown))128+ default:129+ before := m.line.text()130+ m.line.key(k)131+ if m.line.text() != before {132+ c.changed(m)133+ }134+ }135+ return nil136+}137+138+// complete puts the highlighted completion on the line; pressed again,139+// it moves on to the next one (d = 1) or the previous one (d = -1).140+func (c *cmdLine) complete(m *Model, d int) {141+ if len(c.shown) == 0 {142+ return143+ }144+ if c.tabbed {145+ c.move(d, len(c.shown))146+ }147+ m.line.set(c.shown[c.sel].word)148+ c.tabbed = true149+}150+151+// run closes the line and carries it out. Text that isn't a command,152+// cell or range runs the highlighted completion, so ":fill d" Enter153+// fills down.154+func (c *cmdLine) run(m *Model) tea.Cmd {155+ text := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(m.line.text()), ":"))156+ fallback := ""157+ if c.sel < len(c.shown) {158+ fallback = c.shown[c.sel].word159+ }160+ m.closeOverlay()161+ if text == "" {162+ return nil163+ }164+ if cmd, ok := m.runCmdLine(text); ok {165+ return cmd166+ }167+ if fallback != "" {168+ if cmd, ok := m.runCmdLine(fallback); ok {169+ return cmd170+ }171+ }172+ m.fail("Not a command, cell or range: " + text)173+ return nil174+}175+176+// runCmdLine carries out a command line and reports whether it was one.177+func (m *Model) runCmdLine(text string) (tea.Cmd, bool) {178+ word, arg, _ := strings.Cut(text, " ")179+ arg = strings.TrimSpace(arg)180+ bang := strings.HasSuffix(word, "!")181+ switch strings.TrimSuffix(word, "!") {182+ case "w":183+ return m.writeTo(arg), true184+ case "q":185+ if bang {186+ return m.exit(), true187+ }188+ return m.runCommand("quit"), true189+ case "wq", "x":190+ if word == "x" && !m.changed && arg == "" {191+ return m.exit(), true192+ }193+ m.quitAfterSave = true194+ return m.writeTo(arg), true195+ case "e":196+ return m.editFile(arg, bang), true197+ }198+ if n, err := strconv.Atoi(text); err == nil {199+ m.clearSelection()200+ m.cur.Row = m.visibleRow(clamp(n-1, 0, sheet.MaxRows-1))201+ return nil, true202+ }203+ if m.gotoText(text) {204+ return nil, true205+ }206+ id, ok := commandNamed(text)207+ if !ok {208+ return nil, false209+ }210+ if c := commands[id]; !c.available(m) {211+ m.note = c.title + " isn't available now"212+ return nil, true213+ }214+ return m.runCommand(id), true215+}216+217+// commandNamed finds a command by its ID or title, in any case.218+func commandNamed(text string) (string, bool) {219+ ids := commandIDs()220+ for _, id := range ids {221+ if strings.EqualFold(id, text) {222+ return id, true223+ }224+ }225+ for _, id := range ids {226+ if strings.EqualFold(commands[id].title, text) {227+ return id, true228+ }229+ }230+ return "", false231+}232+233+// writeTo is :w. Without a name it saves, as File > Save; with one it234+// saves as that name, as File > Save as, or downloads when the name is235+// another format's (:w out.csv), as File > Download. It goes the menus'236+// ways, so files are named, checked and replaced the same.237+func (m *Model) writeTo(name string) tea.Cmd {238+ if name == "" {239+ return m.runCommand("file.save")240+ }241+ k, ok := fileio.KindOf(name)242+ switch {243+ case !ok:244+ return m.saveAsFile(name)245+ case !k.CanExport():246+ m.quitAfterSave = false247+ m.fail("012 can't write " + k.String() + " files")248+ return nil249+ }250+ m.quitAfterSave = false // a download isn't the sheet's file251+ return m.downloadFile(k, sheet.Rect{}, name)252+}253+254+// editFile is :e name: open a sheet or import a file, refusing to drop255+// unsaved changes unless forced with :e!.256+func (m *Model) editFile(name string, force bool) tea.Cmd {257+ switch {258+ case name == "":259+ return m.runCommand("file.open")260+ case m.changed && !force:261+ m.fail("Unsaved changes: :w saves them, :e! " + name + " discards them")262+ return nil263+ }264+ return m.openFile(name)265+}266+267+func (c *cmdLine) contextLine(m *Model) (string, string) {268+ return m.th.Title.Render(":") + m.line.text(), ""269+}270+271+func (c *cmdLine) cursor(m *Model) (int, int) {272+ return 1 + ansi.StringWidth(m.line.head()), contextLine273+}274+275+func (c *cmdLine) status(m *Model) (string, string) {276+ keys := m.th.KeyHints("Tab", "complete", "Enter", "run", "Esc", "cancel")277+ if c.sel >= len(c.shown) {278+ return "", keys279+ }280+ it := c.shown[c.sel]281+ if it.off {282+ return it.desc + " (not available now)", keys283+ }284+ return it.desc, keys285+}286+287+// rows is how many completions show.288+func (c *cmdLine) rows(m *Model) int {289+ return max(min(len(c.shown), 8, m.height-contextLine-4), 0)290+}291+292+// layout draws the completions in a box under the context line: title,293+// what Tab types, and the shortcut.294+func (c *cmdLine) layout(m *Model) []box {295+ rows := c.rows(m)296+ if rows == 0 {297+ return nil298+ }299+ c.show(rows)300+ tw, ww, kw := 0, 0, 0301+ for _, it := range c.shown {302+ tw = max(tw, ansi.StringWidth(it.title))303+ ww = max(ww, ansi.StringWidth(it.word))304+ if it.key != "" {305+ kw = max(kw, ansi.StringWidth(it.key)+2)306+ }307+ }308+ // As wide as the completions, within the screen and a line's reach.309+ inner := min(max(1+tw+2+ww+2+kw+1, 40), 72, m.width-2)310+ tw = min(tw, inner/2)311+ ww = max(min(ww, inner-1-tw-2-kw-2), 0)312+ lines := make([]string, rows)313+ for r := range rows {314+ i := c.top + r315+ it := c.shown[i]316+ base, dim := m.th.MenuBar, m.th.Muted317+ switch {318+ case i == c.sel:319+ base, dim = m.th.MenuSelected, m.th.MenuSelected320+ case it.off:321+ base, dim = m.th.Disabled, m.th.Disabled322+ }323+ row := base.Render(" "+theme.PadRight(ansi.Truncate(it.title, tw, "…"), tw)+" ") +324+ dim.Render(theme.PadRight(ansi.Truncate(it.word, ww, "…"), ww))325+ k := ""326+ switch {327+ case it.key == "":328+ case i == c.sel || it.off:329+ k = base.Render(" " + it.key + " ")330+ default:331+ k = m.th.Chip(it.key)332+ }333+ row += base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row)-ansi.StringWidth(k)-1, 0))) + k + base.Render(" ")334+ lines[r] = ansi.Truncate(row, inner, "")335+ }336+ footer := strconv.Itoa(c.sel+1) + " of " + strconv.Itoa(len(c.shown))337+ return []box{{id: cmdLineID, x: 0, y: contextLine + 1, lines: m.th.Frame(inner, "Commands", footer, lines)}}338+}339+340+func (c *cmdLine) mouse(m *Model, e mouseEvent) tea.Cmd {341+ if e.box != cmdLineID {342+ if e.kind == mousePress {343+ m.closeOverlay()344+ }345+ return nil346+ }347+ i := c.top + e.row - 1 // under the top border348+ switch {349+ case e.kind == mouseWheel && e.button == tea.MouseWheelUp:350+ c.move(-1, len(c.shown))351+ case e.kind == mouseWheel && e.button == tea.MouseWheelDown:352+ c.move(1, len(c.shown))353+ case e.row < 1 || i >= len(c.shown) || i >= c.top+c.rows(m):354+ case e.kind == mouseMotion:355+ c.sel = i356+ case e.kind == mousePress && e.button == tea.MouseLeft:357+ m.line.set(c.shown[i].word)358+ c.sel = i359+ return c.run(m)360+ }361+ return nil362+}
M internal/ui/commands.go +29 -6
@@ -69,17 +69,21 @@ var keymap = map[string]string{
6969 7070 // keysFor returns the shortcuts bound to a command, the one to show first7171 // leading: Ctrl combinations (what Sheets shows), then function keys, then72-// Alt combinations and named keys.73-func keysFor(id string) []string {72+// Alt combinations and named keys. With vim keys on, it leaves out the73+// Sheets keys vim takes and adds the vim keys (dd, u) last.74+func keysFor(id string, vim bool) []string {7475 var keys []string7576 for k, c := range keymap {76- if c == id {77+ if c == id && !(vim && vimShadows(k)) {7778 keys = append(keys, k)7879 }7980 }8081 slices.SortFunc(keys, func(a, b string) int {8182 return cmp.Or(cmp.Compare(keyRank(a), keyRank(b)), cmp.Compare(a, b))8283 })84+ if vim {85+ keys = append(keys, vimKeysFor(id)...)86+ }8387 return keys8488 }8589
@@ -97,11 +101,30 @@ func keyRank(k string) int {
97101 return 498102 }99103 104+// keyLabel is how a key is shown: "Ctrl+S", "Del", or a vim key as typed,105+// e.g. "dd" or "G".106+func keyLabel(k string) string {107+ if isVimKey(k) {108+ return k109+ }110+ return theme.KeyLabel(k)111+}112+100113 // shortcut is the key shown next to a command in menus and the palette,101-// e.g. "Ctrl+S", or "" if it has none.114+// e.g. "Ctrl+S", or "" if it has none. It ignores vim keys; see115+// Model.shortcut.102116 func shortcut(id string) string {103- if keys := keysFor(id); len(keys) > 0 {104- return theme.KeyLabel(keys[0])117+ if keys := keysFor(id, false); len(keys) > 0 {118+ return keyLabel(keys[0])119+ }120+ return ""121+}122+123+// shortcut is the key shown for a command with the keys in use: with vim124+// keys on, the vim key when Sheets' key is taken or there is none.125+func (m *Model) shortcut(id string) string {126+ if keys := keysFor(id, m.prefs.vim); len(keys) > 0 {127+ return keyLabel(keys[0])105128 }106129 return ""107130 }
M internal/ui/download.go +24 -18
M internal/ui/file.go +22 -15
@@ -63,32 +63,39 @@ func (m *Model) openSave() tea.Cmd {
6363 if name == "" {6464 name = m.displayBase() + sheet.FileExt6565 }66- m.openText("Save as:", name, func(m *Model, text string) tea.Cmd {67- return m.saveAs(withExt(text), true)68- })66+ m.openText("Save as:", name, (*Model).saveAsFile)6967 return nil7068 }7169 70+// saveAsFile saves the sheet under a name typed for Save as or :w.71+func (m *Model) saveAsFile(text string) tea.Cmd {72+ return m.saveAs(withExt(text), true)73+}74+7275 func (m *Model) openRetrieve() tea.Cmd {73- m.openText("Open file:", "", func(m *Model, text string) tea.Cmd {74- if _, ok := fileio.KindOf(text); ok {75- return m.confirmImport(text, fileio.Options{})76- }77- name := withExt(text)78- p, ok := m.path("open", name)79- if !ok {80- return nil81- }82- return loadCmd(name, p)83- })76+ m.openText("Open file:", "", (*Model).openFile)8477 return listFilesCmd(m.root)8578 }8679 80+// openFile opens a sheet, or imports a file of another format, named for81+// Open or :e.82+func (m *Model) openFile(text string) tea.Cmd {83+ if _, ok := fileio.KindOf(text); ok {84+ return m.confirmImport(text, fileio.Options{})85+ }86+ name := withExt(text)87+ p, ok := m.path("open", name)88+ if !ok {89+ return nil90+ }91+ return loadCmd(name, p)92+}93+8794 // reset starts over on s, as File > New and Open do. What belongs to the8895 // session rather than the sheet carries over: the window, theme, terminal8996 // state, served directory and the JEV connection.9097 func (m *Model) reset(s *sheet.Sheet, filename string) {91- *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, root: m.root, charts: chartState{last: -1}}98+ *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, root: m.root, charts: chartState{last: -1}, prefs: m.prefs}9299 if m.jev != nil {93100 s.Book().SetRemote(m.jev.cache)94101 }
M internal/ui/filter.go +1 -1
M internal/ui/find.go +44 -0
@@ -63,10 +63,48 @@ func init() {
6363 return nil6464 }},6565 )66+ register(67+ &command{id: "edit.find_next", title: "Find next", desc: "Go to the next match of the last search", run: func(m *Model) tea.Cmd {68+ m.findAgain(1)69+ return nil70+ }},71+ &command{id: "edit.find_prev", title: "Find previous", desc: "Go to the previous match of the last search", run: func(m *Model) tea.Cmd {72+ m.findAgain(-1)73+ return nil74+ }},75+ )6676 keymap["ctrl+f"] = "edit.find"6777 keymap["ctrl+h"] = "edit.replace"6878 }6979 80+// findAgain goes to the next (d = 1) or previous (d = -1) match of the81+// last search from the active cell, without opening the bar, as n and N82+// do in vim and less.83+func (m *Model) findAgain(d int) {84+ f := m.find85+ if f == nil || f.fields[0] == "" {86+ m.note = "No search yet: " + m.shortcut("edit.find") + " finds"87+ return88+ }89+ if f.where == inRange && !f.home.Live() {90+ f.where = inSheet91+ }92+ here := cellOn{m.sheet, m.cur}93+ f.from = here94+ f.search(m) // lands on the first match at or after here95+ switch {96+ case f.err != "":97+ m.note = f.err98+ return99+ case len(f.matches) == 0:100+ m.note = "No matches for " + f.fields[0]101+ return102+ case d > 0 && f.matches[f.cur] == here, d < 0:103+ f.step(m, d)104+ }105+ m.note = "Match " + strconv.Itoa(f.cur+1) + " of " + strconv.Itoa(len(f.matches)) + " for " + f.fields[0]106+}107+70108 // openFind opens the bar, keeping the last search. A multi-cell selection71109 // becomes the search scope, as Sheets' "specific range".72110 func (m *Model) openFind(replace bool) {
@@ -214,6 +252,12 @@ func (f *findBar) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
214252 m.find = f215253 m.closeOverlay()216254 case "enter":255+ if m.prefs.vim && !f.replace {256+ // As / in vim: Enter stays on the match; n and N go on.257+ m.find = f258+ m.closeOverlay()259+ return nil260+ }217261 if f.replace && f.field == 1 {218262 f.replaceOne(m)219263 } else {
M internal/ui/help.go +13 -5
@@ -78,20 +78,28 @@ type helpRow struct {
7878 // helpRows lists the shortcuts, generated from the keymap and the command7979 // registry so help can't drift from behavior. Movement and typing, which8080 // aren't commands, come first; then commands grouped by the menu they're81-// in.82-func helpRows() []helpRow {81+// in. With vim keys on, the vim keys come first.82+func helpRows(vim bool) []helpRow {83+ if vim {84+ return append(vimHelpRows(), keyHelpRows(true)...)85+ }86+ return keyHelpRows(false)87+}88+89+// keyHelpRows are the Sheets keys and every command's shortcuts.90+func keyHelpRows(vim bool) []helpRow {8391 listed := map[string]bool{}8492 var rows []helpRow8593 group := func(title string, static []helpRow, ids ...string) {8694 var cmds []helpRow8795 for _, id := range ids {88- keys := keysFor(id)96+ keys := keysFor(id, vim)8997 if listed[id] || len(keys) == 0 || commands[id] == nil {9098 continue9199 }92100 listed[id] = true93101 for i, k := range keys {94- keys[i] = theme.KeyLabel(k)102+ keys[i] = keyLabel(k)95103 }96104 cmds = append(cmds, helpRow{keys: keys, action: commands[id].title})97105 }
@@ -169,7 +177,7 @@ func (s *shortcuts) indicator() string { return "HELP" }
169177 170178 // lines renders the rows, as one or two columns, and returns their width.171179 func (s *shortcuts) lines(m *Model) ([]string, int) {172- rows := helpRows()180+ rows := helpRows(m.prefs.vim)173181 keyW, actW := 0, 0174182 for _, r := range rows {175183 keyW = max(keyW, ansi.StringWidth(m.th.Chips(r.keys)))
M internal/ui/help_test.go +3 -3
M internal/ui/menubar.go +1 -1
M internal/ui/menuoverlay.go +1 -1
M internal/ui/model.go +10 -2
@@ -87,6 +87,9 @@ type Model struct {
8787 jev *jevRunner // answers JEV functions; nil without an API key: jev.go8888 term terminal // what the terminal supports: graphics.go8989 90+ prefs prefs // the user's preferences, kept across files: prefs.go91+ vim vimState // a vim key sequence in progress: vim.go92+9093 keyAt time.Time // when the key the next frame answers was pressed, for telemetry9194 9295 th theme.Theme
@@ -94,7 +97,7 @@ type Model struct {
9497 9598 // New returns a model editing s. filename may be empty.9699 func New(s *sheet.Sheet, filename string) *Model {97- m := &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(os.Getenv), charts: chartState{last: -1}}100+ m := &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(os.Getenv), charts: chartState{last: -1}, prefs: initialPrefs()}98101 if filename != "" {99102 m.disk = diskStamp(filename)100103 }
@@ -238,6 +241,11 @@ func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {
238241 }239242 240243 func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {244+ if m.prefs.vim {245+ if cmd, ok := m.vimKeyPress(k); ok {246+ return cmd247+ }248+ }241249 key := k.String()242250 if m.moveKey(key) {243251 m.entry.tabbing = false
@@ -288,7 +296,7 @@ func (m *Model) focus() *sheet.Addr {
288296 return &m.point.at289297 case m.mouse.drag == dragFill:290298 return &m.mouse.fillAt291- case m.selecting && m.whole == wholeNone:299+ case m.selecting && (m.whole == wholeNone || m.vim.visual == visualRows):292300 return &m.ext293301 }294302 return &m.cur
M internal/ui/palette.go +30 -7
@@ -40,7 +40,7 @@ func paletteItems(m *Model) []pickItem {
4040 seen[id] = true4141 c := commands[id]4242 items = append(items, pickItem{43- title: c.title, name: len(c.title), detail: path, key: shortcut(id), desc: c.desc,43+ title: c.title, name: len(c.title), detail: path, key: m.shortcut(id), desc: c.desc,4444 off: !c.available(m), pick: func(m *Model) tea.Cmd { return m.runFromOverlay(id) },4545 })4646 }
@@ -136,20 +136,23 @@ func (p *picker) changed(m *Model) {
136136 }137137 // Rank title and path matches together by fuzzy score, so a tight138138 // match like "file" on the File menu beats letters scattered across a139- // title. Title matches get a small bonus and win ties.139+ // title. Title matches get a small bonus and win ties. Titles with a140+ // word starting with the query ("col" in Column width) come before141+ // the rest, such as "Command line", which only spells it across words.140142 names, hay := make([]string, len(p.items)), make([]string, len(p.items))141143 for i, it := range p.items {142144 names[i], hay[i] = it.title[:it.name], it.haystack()143145 }144146 const titleBonus = 10145147 type ranked struct {146- pm pickMatch147- score int148- order int148+ pm pickMatch149+ score int150+ order int151+ prefix bool // a word of the title starts with the query149152 }150153 best := map[int]*ranked{}151154 for _, mt := range fuzzy.FindNoSort(q, names) {152- best[mt.Index] = &ranked{pickMatch{item: &p.items[mt.Index], inTitle: mt.MatchedIndexes}, mt.Score + titleBonus, mt.Index}155+ best[mt.Index] = &ranked{pickMatch{item: &p.items[mt.Index], inTitle: mt.MatchedIndexes}, mt.Score + titleBonus, mt.Index, false}153156 }154157 for _, mt := range fuzzy.FindNoSort(q, hay) {155158 if r, ok := best[mt.Index]; ok && r.score >= mt.Score {
@@ -165,10 +168,19 @@ func (p *picker) changed(m *Model) {
165168 pm.inDesc = append(pm.inDesc, i-it.name-2)166169 }167170 }168- best[mt.Index] = &ranked{pm, mt.Score, mt.Index}171+ best[mt.Index] = &ranked{pm, mt.Score, mt.Index, false}172+ }173+ for i, r := range best {174+ r.prefix = wordPrefix(names[i], q)169175 }170176 all := slices.Collect(maps.Values(best))171177 slices.SortFunc(all, func(a, b *ranked) int {178+ if a.prefix != b.prefix {179+ if a.prefix {180+ return -1181+ }182+ return 1183+ }172184 if c := cmp.Compare(b.score, a.score); c != 0 {173185 return c174186 }
@@ -179,6 +191,17 @@ func (p *picker) changed(m *Model) {
179191 }180192 }181193 194+// wordPrefix reports whether a word of title starts with q, ignoring case.195+func wordPrefix(title, q string) bool {196+ q = strings.ToLower(q)197+ for w := range strings.FieldsSeq(strings.ToLower(title)) {198+ if strings.HasPrefix(w, q) {199+ return true200+ }201+ }202+ return false203+}204+182205 func (p *picker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {183206 rows := p.rows(m)184207 switch k.String() {
M internal/ui/palette_test.go +20 -0
M internal/ui/panel.go +10 -1
A internal/ui/prefs.go +48 -0
@@ -0,0 +1,48 @@
1+package ui2+3+import tea "charm.land/bubbletea/v2"4+5+// Preferences belong to the user, not the sheet: they aren't saved in6+// .012 files or undone, and File > New and Open keep them. Each one is a7+// checked command in File > Settings.8+9+// prefs are the user's preferences.10+type prefs struct {11+ vim bool // vim keys in READY mode: see vim.go12+}13+14+// initialPrefs is what a new Model starts with. It is the seam where15+// preferences are read from the user's configuration (for vim keys, the16+// option keymap = default|vim); until that's wired, the defaults.17+func initialPrefs() prefs {18+ return prefs{}19+}20+21+// prefsChanged is called after the user changes a preference, with the22+// new preferences in m.prefs. It is the seam where they are written back23+// to the user's configuration; nothing persists them yet.24+func (m *Model) prefsChanged() tea.Cmd {25+ return nil26+}27+28+// SetVimKeys turns vim keys on or off, as the setting does, for callers29+// that know the user's choice at startup.30+func (m *Model) SetVimKeys(on bool) {31+ m.prefs.vim = on32+ m.vim = vimState{}33+}34+35+func init() {36+ register(&command{id: "settings.vim", title: "Vim keys",37+ desc: "Move with hjkl, counts and operators (dd, yy, p), visual selection with v and V, and : commands",38+ run: func(m *Model) tea.Cmd {39+ m.SetVimKeys(!m.prefs.vim)40+ m.note = "Vim keys off: typing replaces the cell, as in Sheets"41+ if m.prefs.vim {42+ m.note = "Vim keys on: i or Enter edits, : runs a command, F1 lists the keys"43+ }44+ return m.prefsChanged()45+ },46+ checked: func(m *Model) bool { return m.prefs.vim },47+ })48+}
A internal/ui/rows.go +103 -0
@@ -0,0 +1,103 @@
1+package ui2+3+import (4+ tea "charm.land/bubbletea/v2"5+6+ "github.com/FelineStateMachine/012/internal/sheet"7+)8+9+// Whole-row commands, as vim's line operators: copy rows (yy), cut them10+// (dd), paste them back as new rows (p, P), and open a new row to type in11+// (o, O). They work without vim keys too, from the palette.12+13+func init() {14+ register(15+ &command{id: "row.yank", title: "Copy rows", desc: "Copy the selected rows whole, to paste as new rows", run: func(m *Model) tea.Cmd {16+ r := m.selectedRows()17+ cmd := m.copyRows(r)18+ m.note = "Copied " + rowCount(r.To.Row-r.From.Row+1)19+ return cmd20+ }},21+ &command{id: "row.cut", title: "Cut rows", desc: "Delete the selected rows, keeping them to paste as new rows", run: func(m *Model) tea.Cmd {22+ r := m.selectedRows()23+ cmd := m.copyRows(r)24+ n := r.To.Row - r.From.Row + 125+ m.sheet.DeleteRows(r.From.Row, n)26+ m.note = "Deleted " + rowCount(n) + "; p pastes them back"27+ return cmd28+ }},29+ &command{id: "row.paste_below", title: "Paste rows below", desc: "Insert the copied rows below the active cell's row",30+ run: func(m *Model) tea.Cmd { return m.pasteRows(m.cur.Row + 1) }, enabled: (*Model).rowsCopied},31+ &command{id: "row.paste_above", title: "Paste rows above", desc: "Insert the copied rows above the active cell's row",32+ run: func(m *Model) tea.Cmd { return m.pasteRows(m.cur.Row) }, enabled: (*Model).rowsCopied},33+ &command{id: "row.open_below", title: "Open a row below", desc: "Insert a row below the active cell and start typing in it",34+ run: func(m *Model) tea.Cmd { return m.openRow(m.cur.Row + 1) }},35+ &command{id: "row.open_above", title: "Open a row above", desc: "Insert a row above the active cell and start typing in it",36+ run: func(m *Model) tea.Cmd { return m.openRow(m.cur.Row) }},37+ )38+}39+40+// selectedRows is the rows of the selection across the columns in use.41+func (m *Model) selectedRows() sheet.Rect {42+ r := m.selection()43+ last := 044+ if used, ok := m.sheet.UsedRange(); ok {45+ last = used.To.Col46+ }47+ return sheet.Rect{From: sheet.Addr{Row: r.From.Row}, To: sheet.Addr{Col: last, Row: r.To.Row}}48+}49+50+// copyRows puts rows r on the clipboard, marked as whole rows so pasting51+// inserts them, and on the system clipboard.52+func (m *Model) copyRows(r sheet.Rect) tea.Cmd {53+ clip := m.sheet.Copy(r)54+ m.copied = clipboard{clip: clip, sheet: m.sheet}55+ m.vim.rows = clip56+ return tea.SetClipboard(formatTSV(clip.Text()))57+}58+59+// rowsCopied reports whether the clipboard holds whole rows.60+func (m *Model) rowsCopied() bool {61+ return m.vim.rows != nil && m.copied.clip == m.vim.rows62+}63+64+// pasteRows inserts the copied rows at row at, as many times as the vim65+// count says, as one undo step, and moves to the first of them.66+func (m *Model) pasteRows(at int) tea.Cmd {67+ clip := m.copied.clip68+ cols, rows := clip.Size()69+ n := rows * m.vim.n()70+ dst := sheet.Rect{From: sheet.Addr{Row: at}, To: sheet.Addr{Col: cols - 1, Row: at + n - 1}}71+ if !dst.To.Valid() {72+ return m.structural(sheet.ErrPushedOff)73+ }74+ err := m.sheet.Batch(sheet.Change{Label: "paste " + rowCount(n), Focus: dst, Sheet: m.sheet}, func() error {75+ if err := m.sheet.InsertRows(at, n); err != nil {76+ return err77+ }78+ _, err := m.sheet.Paste(clip, dst, false)79+ return err80+ })81+ if err != nil {82+ return m.structural(err)83+ }84+ m.clearSelection()85+ m.cur.Row = at86+ m.note = "Pasted " + rowCount(n)87+ return nil88+}89+90+// openRow inserts a blank row at row at and starts an entry in it, in the91+// active cell's column.92+func (m *Model) openRow(at int) tea.Cmd {93+ if at >= sheet.MaxRows {94+ return m.structural(sheet.ErrPushedOff)95+ }96+ if err := m.sheet.InsertRows(at, 1); err != nil {97+ return m.structural(err)98+ }99+ m.clearSelection()100+ m.cur.Row = at101+ m.startEntry(modeEnter, "")102+ return nil103+}
M internal/ui/selection.go +33 -26
@@ -212,35 +212,42 @@ func isMoveKey(key string) bool {
212212 213213 func (m *Model) openGoto() {214214 m.openText("Go to:", m.cur.String(), func(m *Model, text string) tea.Cmd {215- text = strings.TrimSpace(text)216- // A sheet may lead: Sheet2!A1, 'Q3 plan'!B2:C9, or just Sheet2!.217- target := m.sheet218- if name, rest := sheet.SplitSheet(text); name != "" {219- if target = m.book().Lookup(name); target == nil {220- m.fail("There's no sheet named " + name)221- return nil222- }223- if text = rest; text == "" {224- m.showSheet(target)225- return nil226- }227- }228- r, ok := sheet.ParseRange(text)229- if n, named := m.sheet.LookupName(text); named && !n.Gone() && target == m.sheet {230- r, ok, target = n.Range, true, n.Sheet231- }232- if !ok {215+ if !m.gotoText(strings.TrimSpace(text)) {233216 m.fail("Not a cell, range or named range: " + text)234- return nil235217 }236- m.showSheet(target)237- if r.From == r.To {238- m.clearSelection()239- m.cur = r.From240- return nil241- }242- m.selectRect(r)243218 return nil244219 })245220 m.prompt.indicator = "POINT"246221 }222+223+// gotoText goes to a cell, range or named range, on any sheet, and224+// reports whether text named one. A sheet may lead: Sheet2!A1,225+// 'Q3 plan'!B2:C9, or just Sheet2!.226+func (m *Model) gotoText(text string) bool {227+ target := m.sheet228+ if name, rest := sheet.SplitSheet(text); name != "" {229+ if target = m.book().Lookup(name); target == nil {230+ m.fail("There's no sheet named " + name)231+ return true232+ }233+ if text = rest; text == "" {234+ m.showSheet(target)235+ return true236+ }237+ }238+ r, ok := sheet.ParseRange(text)239+ if n, named := m.sheet.LookupName(text); named && !n.Gone() && target == m.sheet {240+ r, ok, target = n.Range, true, n.Sheet241+ }242+ if !ok {243+ return false244+ }245+ m.showSheet(target)246+ if r.From == r.To {247+ m.clearSelection()248+ m.cur = r.From249+ return true250+ }251+ m.selectRect(r)252+ return true253+}
A internal/ui/vim.go +320 -0
@@ -0,0 +1,320 @@
1+package ui2+3+import (4+ "slices"5+ "strconv"6+ "strings"7+8+ tea "charm.land/bubbletea/v2"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// Vim keys (File > Settings > Vim keys), in the spirit of sc-im and14+// VisiData: in READY mode, which the indicator calls NORMAL, letters move15+// and act instead of starting an entry. Counts repeat a motion or say how16+// many rows or cells an operator takes (5j, 3dd, 4x); v and V select17+// cells or whole rows (VISUAL); : opens a command line (COMMAND). Keys18+// vim doesn't claim keep their Sheets meaning, so arrows, Ctrl+S, Alt19+// menus and F-keys work as always. Every action but moving and selecting20+// runs a registered command; the bindings are tables in vimkeys.go.21+22+// vimState is where a vim key sequence has got to.23+type vimState struct {24+ count int // the count typed so far, 0 for none25+ keys string // a sequence begun but not finished, e.g. "d" or "g"26+ visual visualKind27+ // rows is the clip dd or yy last copied: pasting it inserts rows.28+ rows *sheet.Clip29+}30+31+type visualKind int32+33+const (34+ visualNone visualKind = iota35+ visualCells36+ visualRows37+)38+39+// maxCount caps a count, so a slip of the finger can't loop for long.40+const maxCount = 999941+42+// n is the count, 1 when none was typed.43+func (v *vimState) n() int { return max(v.count, 1) }44+45+func (v *vimState) reset() { v.count, v.keys = 0, "" }46+47+// pending is what's been typed of an unfinished sequence, e.g. "3d".48+func (v *vimState) pending() string {49+ if v.count == 0 {50+ return v.keys51+ }52+ return strconv.Itoa(v.count) + v.keys53+}54+55+// vimKey names a key press as the vim tables do: the character typed,56+// or the key's name with its modifiers, e.g. "G", "$", "ctrl+d", "up".57+func vimKey(k tea.KeyPressMsg) string {58+ if t := typed(k); t != "" && t != " " {59+ return t60+ }61+ return k.String()62+}63+64+// vimActive reports whether vim keys have READY mode.65+func (m *Model) vimActive() bool {66+ return m.prefs.vim && m.mode == modeReady67+}68+69+// visual reports whether a visual selection is in progress; a selection70+// made any other way (a click, Shift+arrows, a command) ends it.71+func (m *Model) visual() visualKind {72+ if !m.selecting {73+ m.vim.visual = visualNone74+ }75+ return m.vim.visual76+}77+78+// vimKeyPress handles a key in NORMAL or VISUAL mode and reports whether79+// it took it. Keys it leaves go to the Sheets bindings.80+func (m *Model) vimKeyPress(k tea.KeyPressMsg) (tea.Cmd, bool) {81+ v := &m.vim82+ key := vimKey(k)83+ if key == "esc" {84+ return m.vimEscape()85+ }86+ if d := digit(key); v.keys == "" && (d > 0 || d == 0 && v.count > 0) {87+ v.count = min(v.count*10+d, maxCount)88+ return nil, true89+ }90+ seq := v.keys + key91+ if mv, ok := vimMotions[seq]; ok {92+ m.vimMove(mv)93+ v.reset()94+ return nil, true95+ }96+ table := vimNormal97+ if m.visual() != visualNone {98+ table = vimVisual99+ }100+ if b, ok := table[seq]; ok {101+ cmd := b.run(m, v.n())102+ v.reset()103+ return cmd, true104+ }105+ if vimPrefix(table, seq) {106+ v.keys = seq107+ return nil, true108+ }109+ begun := v.pending() != ""110+ v.reset()111+ // A broken sequence and stray letters do nothing, as in vim; other112+ // keys (arrows, Ctrl+S, F1) fall through to their Sheets meaning.113+ return nil, begun || typed(k) != ""114+}115+116+// vimEscape cancels a sequence being typed, then a visual selection, then117+// deselects, as Esc does in Sheets.118+func (m *Model) vimEscape() (tea.Cmd, bool) {119+ switch {120+ case m.vim.pending() != "":121+ m.vim.reset()122+ case m.visual() != visualNone:123+ m.vim.visual = visualNone124+ m.clearSelection()125+ default:126+ return m.runCommand("select.none"), true127+ }128+ return nil, true129+}130+131+func digit(key string) int {132+ if len(key) == 1 && key[0] >= '0' && key[0] <= '9' {133+ return int(key[0] - '0')134+ }135+ return -1136+}137+138+// vimPrefix reports whether seq begins a longer binding or motion.139+func vimPrefix(table map[string]vimBinding, seq string) bool {140+ for k := range table {141+ if len(k) > len(seq) && strings.HasPrefix(k, seq) {142+ return true143+ }144+ }145+ for k := range vimMotions {146+ if len(k) > len(seq) && strings.HasPrefix(k, seq) {147+ return true148+ }149+ }150+ return false151+}152+153+// motion moves a, the active cell or a visual selection's moving corner;154+// counted is whether a count was typed, which some motions read as a row.155+type motion func(m *Model, a *sheet.Addr, n int, counted bool)156+157+// vimMove applies a motion: in NORMAL mode to the active cell, dropping158+// any selection; in VISUAL mode to the moving corner.159+func (m *Model) vimMove(mv motion) {160+ a := &m.cur161+ if m.visual() != visualNone {162+ a = &m.ext163+ }164+ mv(m, a, m.vim.n(), m.vim.count > 0)165+ *a = clampAddr(*a)166+ a.Row = m.visibleRow(a.Row)167+ if m.visual() == visualNone {168+ m.clearSelection()169+ }170+ m.entry.tabbing = false171+ m.clampView()172+}173+174+// nav repeats a Sheets movement key n times.175+func nav(key string) motion {176+ return func(m *Model, a *sheet.Addr, n int, _ bool) {177+ for range n {178+ before := *a179+ m.navigate(key, a)180+ if *a == before {181+ return // at an edge; the rest of the count does nothing182+ }183+ }184+ }185+}186+187+// toRow goes to row n when a count was typed, else to the row first()188+// picks.189+func toRow(first func(m *Model) int) motion {190+ return func(m *Model, a *sheet.Addr, n int, counted bool) {191+ if counted {192+ a.Row = n - 1193+ } else {194+ a.Row = first(m)195+ }196+ }197+}198+199+// screenLine goes to a row on screen: pick chooses from the rows shown.200+func screenLine(pick func(rows []int, n int) int) motion {201+ return func(m *Model, a *sheet.Addr, n int, _ bool) {202+ var rows []int203+ for _, r := range m.screenRows() {204+ if fr, _ := m.frozen(); r != divider && r >= fr {205+ rows = append(rows, r)206+ }207+ }208+ if len(rows) > 0 {209+ a.Row = rows[clamp(pick(rows, n), 0, len(rows)-1)]210+ }211+ }212+}213+214+// halfPage moves the cell and the view half a screen, d = 1 down or -1 up.215+func halfPage(d int) motion {216+ return func(m *Model, a *sheet.Addr, n int, _ bool) {217+ rows := d * max(m.scrollRows()/2, 1) * n218+ a.Row = m.stepRow(a.Row, rows)219+ m.top = m.stepRow(m.top, rows)220+ }221+}222+223+// vimMotions move in NORMAL and VISUAL modes. The arrows and Sheets'224+// movement keys are here too, so they move a visual selection's corner225+// rather than dropping it.226+var vimMotions = map[string]motion{227+ "h": nav("left"), "j": nav("down"), "k": nav("up"), "l": nav("right"),228+ "left": nav("left"), "down": nav("down"), "up": nav("up"), "right": nav("right"),229+ "w": nav("ctrl+right"), "b": nav("ctrl+left"),230+ "ctrl+left": nav("ctrl+left"), "ctrl+right": nav("ctrl+right"), "ctrl+up": nav("ctrl+up"), "ctrl+down": nav("ctrl+down"),231+ "pgup": nav("pgup"), "pgdown": nav("pgdown"), "home": nav("home"), "end": nav("end"),232+ "ctrl+home": nav("ctrl+home"), "ctrl+end": nav("ctrl+end"),233+ "0": nav("home"), "^": nav("home"),234+ "$": func(m *Model, a *sheet.Addr, _ int, _ bool) {235+ last := sheet.Addr{Col: sheet.MaxCols - 1, Row: a.Row}236+ if m.sheet.Cell(last).Blank() {237+ last = m.sheet.Edge(last, -1, 0)238+ }239+ a.Col = last.Col240+ },241+ "gg": toRow(func(*Model) int { return 0 }),242+ "G": toRow(func(m *Model) int {243+ used, _ := m.sheet.UsedRange()244+ return used.To.Row245+ }),246+ "H": screenLine(func(_ []int, n int) int { return n - 1 }),247+ "M": screenLine(func(rows []int, _ int) int { return (len(rows) - 1) / 2 }),248+ "L": screenLine(func(rows []int, n int) int { return len(rows) - n }),249+ "ctrl+d": halfPage(1),250+ "ctrl+u": halfPage(-1),251+}252+253+// vimLine is the context line while a sequence is being typed (the keys254+// so far, as vim's showcmd, and the keys that finish it) or in VISUAL255+// mode (what applies).256+func (m *Model) vimLine() string {257+ if p := m.vim.pending(); p != "" {258+ return m.th.Key.Render(p) + " " + m.th.KeyHints(append(m.vimNext(), "Esc", "cancel")...)259+ }260+ if m.vim.visual == visualRows {261+ return m.th.KeyHints("d", "delete rows", "y", "copy rows", "o", "other end", ":", "command", "Esc", "back")262+ }263+ return m.th.KeyHints("d", "delete", "y", "copy", "p", "paste", "o", "other corner", "V", "rows", "Esc", "back")264+}265+266+// vimNext lists the keys that finish the sequence begun, and what they267+// do, as pairs for KeyHints: after d, "d" and "cut rows".268+func (m *Model) vimNext() []string {269+ if m.vim.keys == "" {270+ return nil // just a count: anything may follow271+ }272+ table := vimNormal273+ if m.visual() != visualNone {274+ table = vimVisual275+ }276+ var seqs []string277+ for seq := range table {278+ if len(seq) > len(m.vim.keys) && strings.HasPrefix(seq, m.vim.keys) {279+ seqs = append(seqs, seq)280+ }281+ }282+ if m.vim.keys == "g" {283+ seqs = append(seqs, "gg")284+ }285+ slices.Sort(seqs)286+ var out []string287+ for _, seq := range seqs {288+ what := "first row"289+ if b, ok := table[seq]; ok {290+ what = strings.ToLower(commands[b.id].title)291+ }292+ out = append(out, seq[len(m.vim.keys):], what)293+ }294+ return out295+}296+297+// startVisual selects from the active cell: cells, or whole rows.298+func (m *Model) startVisual(kind visualKind) {299+ if m.visual() == visualNone {300+ m.selecting, m.ext = true, m.cur301+ }302+ m.vim.visual = kind303+ m.whole = wholeNone304+ if kind == visualRows {305+ m.whole = wholeRows306+ }307+}308+309+// spanRows selects n whole rows from the active cell's, for an operator310+// with a count (3dd).311+func (m *Model) spanRows(n int) {312+ m.ext = sheet.Addr{Col: m.cur.Col, Row: min(m.cur.Row+n-1, sheet.MaxRows-1)}313+ m.selecting, m.whole = true, wholeRows314+}315+316+// spanCols selects n cells from the active cell rightwards (4x).317+func (m *Model) spanCols(n int) {318+ m.ext = sheet.Addr{Col: min(m.cur.Col+n-1, sheet.MaxCols-1), Row: m.cur.Row}319+ m.selecting, m.whole = n > 1, wholeNone320+}
A internal/ui/vim_test.go +390 -0
@@ -0,0 +1,390 @@
1+package ui2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ tea "charm.land/bubbletea/v2"10+)11+12+// vimModel is a model with vim keys on and a small table: A1:C6 filled13+// with their own addresses, row 1 as a header.14+func vimModel(t *testing.T) *Model {15+ t.Helper()16+ m := newModel()17+ for _, col := range "ABC" {18+ for row := 1; row <= 6; row++ {19+ a := string(col) + string(rune('0'+row))20+ if err := m.sheet.Set(addr(a), a); err != nil {21+ t.Fatal(err)22+ }23+ }24+ }25+ m.sheet.ClearHistory()26+ m.saved, m.changed = m.sheet.StateID(), false27+ m.runCommand("settings.vim")28+ return m29+}30+31+func indicator(m *Model) string {32+ f := strings.Fields(line(m, menuLine))33+ return f[len(f)-1]34+}35+36+// File > Settings > Vim keys turns NORMAL mode on and off; letters move37+// instead of typing, and the setting survives File > New.38+func TestVimSetting(t *testing.T) {39+ m := newModel()40+ press(t, m, "<alt+f>", "<up>", "<up>", "<right>")41+ if s := screen(m); !strings.Contains(s, "Vim keys") {42+ t.Fatalf("settings submenu:\n%s", s)43+ }44+ press(t, m, "<down>", "<enter>")45+ if !m.prefs.vim || indicator(m) != "NORMAL" || !strings.Contains(line(m, contextLine), "Vim keys on") {46+ t.Fatalf("vim %v, indicator %q, context %q", m.prefs.vim, indicator(m), line(m, contextLine))47+ }48+ press(t, m, "j", "l", "x")49+ if m.cur != addr("B2") || m.mode != modeReady || m.sheet.Len() != 0 {50+ t.Errorf("hjkl: cur %v mode %v cells %d", m.cur, m.mode, m.sheet.Len())51+ }52+ if !strings.Contains(line(m, m.height-1), ": command") {53+ t.Errorf("status line: %q", line(m, m.height-1))54+ }55+ m.runCommand("file.new")56+ if !m.prefs.vim || indicator(m) != "NORMAL" {57+ t.Errorf("File > New dropped vim keys")58+ }59+ m.runCommand("settings.vim")60+ press(t, m, "j")61+ if m.prefs.vim || indicator(m) != "ENTER" || bar(m) != "j" {62+ t.Errorf("off: indicator %q, bar %q", indicator(m), bar(m))63+ }64+}65+66+func TestVimMotionsAndCounts(t *testing.T) {67+ m := vimModel(t)68+ steps := []struct {69+ keys []string70+ want string71+ }{72+ {[]string{"3j"}, "A4"},73+ {[]string{"2l"}, "C4"},74+ {[]string{"k"}, "C3"},75+ {[]string{"h"}, "B3"},76+ {[]string{"gg"}, "B1"},77+ {[]string{"G"}, "B6"},78+ {[]string{"3G"}, "B3"},79+ {[]string{"5gg"}, "B5"},80+ {[]string{"0"}, "A5"},81+ {[]string{"$"}, "C5"},82+ {[]string{"b"}, "A5"},83+ {[]string{"w"}, "C5"},84+ {[]string{"<down>"}, "C6"},85+ {[]string{"10k"}, "C1"},86+ {[]string{"<ctrl+d>"}, "C8"},87+ {[]string{"<ctrl+u>"}, "C1"},88+ {[]string{"L"}, "C15"},89+ {[]string{"M"}, "C8"},90+ {[]string{"H"}, "C1"},91+ {[]string{"j", "20", "<esc>", "j"}, "C3"},92+ }93+ for _, s := range steps {94+ press(t, m, s.keys...)95+ if m.cur.String() != s.want || m.hasRange() {96+ t.Fatalf("%v: at %v (range %v), want %s", s.keys, m.cur, m.hasRange(), s.want)97+ }98+ }99+ // A count or a sequence being typed shows on the context line.100+ press(t, m, "3d")101+ if l := line(m, contextLine); !strings.HasPrefix(l, "3d") || indicator(m) != "NORMAL" {102+ t.Errorf("pending: %q", l)103+ }104+ press(t, m, "<esc>")105+ if m.vim.pending() != "" || strings.HasPrefix(line(m, contextLine), "3d") {106+ t.Errorf("Esc kept %q", m.vim.pending())107+ }108+ // A broken sequence does nothing.109+ press(t, m, "dz", "j")110+ if m.cur != addr("C4") || m.sheet.Len() != 18 {111+ t.Errorf("dz: at %v, %d cells", m.cur, m.sheet.Len())112+ }113+}114+115+func TestVimRowOperators(t *testing.T) {116+ m := vimModel(t)117+ press(t, m, "j", "2dd")118+ if input(m, "A2") != "A4" || m.sheet.Len() != 12 || m.cur != addr("A2") {119+ t.Fatalf("2dd: A2 %q, %d cells, at %v", input(m, "A2"), m.sheet.Len(), m.cur)120+ }121+ if !strings.Contains(line(m, contextLine), "Deleted 2 rows") {122+ t.Errorf("context: %q", line(m, contextLine))123+ }124+ // p puts the rows back below, P above; each is one undo step.125+ press(t, m, "p")126+ if input(m, "A3") != "A2" || input(m, "A4") != "A3" || input(m, "C4") != "C3" || m.cur != addr("A3") {127+ t.Fatalf("p: A3 %q A4 %q C4 %q at %v", input(m, "A3"), input(m, "A4"), input(m, "C4"), m.cur)128+ }129+ press(t, m, "u")130+ if input(m, "A3") != "A5" || m.sheet.Len() != 12 {131+ t.Fatalf("u: A3 %q", input(m, "A3"))132+ }133+ press(t, m, "gg", "P")134+ if input(m, "A1") != "A2" || input(m, "A3") != "A1" {135+ t.Fatalf("P: A1 %q A3 %q", input(m, "A1"), input(m, "A3"))136+ }137+ press(t, m, "u", "u")138+ if input(m, "A2") != "A2" || m.sheet.Len() != 18 {139+ t.Fatalf("uu: A2 %q, %d cells", input(m, "A2"), m.sheet.Len())140+ }141+ press(t, m, "<ctrl+r>")142+ if input(m, "A2") != "A4" {143+ t.Fatalf("Ctrl+R: A2 %q", input(m, "A2"))144+ }145+ press(t, m, "u")146+ // yy copies without deleting; 2p pastes twice.147+ press(t, m, "G", "yy", "2p")148+ if input(m, "B7") != "B6" || input(m, "B8") != "B6" || m.sheet.Len() != 24 {149+ t.Fatalf("yy 2p: B7 %q B8 %q, %d cells", input(m, "B7"), input(m, "B8"), m.sheet.Len())150+ }151+ // x clears cells to the right, keeping rows.152+ press(t, m, "gg", "2x")153+ if input(m, "A1") != "" || input(m, "B1") != "" || input(m, "C1") != "C1" || m.hasRange() {154+ t.Fatalf("2x: %q %q %q", input(m, "A1"), input(m, "B1"), input(m, "C1"))155+ }156+ // o and O open a row and start typing in it.157+ press(t, m, "o", "new", "<enter>")158+ if input(m, "A2") != "new" || input(m, "A3") != "A2" {159+ t.Fatalf("o: A2 %q A3 %q", input(m, "A2"), input(m, "A3"))160+ }161+ press(t, m, "gg", "O")162+ if m.mode != modeEnter || m.cur != addr("A1") || input(m, "C2") != "C1" {163+ t.Fatalf("O: mode %v at %v, C2 %q", m.mode, m.cur, input(m, "C2"))164+ }165+ press(t, m, "<esc>")166+ if indicator(m) != "NORMAL" {167+ t.Errorf("after Esc: %q", indicator(m))168+ }169+}170+171+func TestVimEditKeys(t *testing.T) {172+ m := vimModel(t)173+ press(t, m, "i", "x", "<enter>")174+ if input(m, "A1") != "xA1" || m.cur != addr("A2") {175+ t.Fatalf("i: A1 %q at %v", input(m, "A1"), m.cur)176+ }177+ press(t, m, "a", "y", "<enter>")178+ if input(m, "A2") != "A2y" {179+ t.Fatalf("a: A2 %q", input(m, "A2"))180+ }181+ press(t, m, "=", "1+1", "<enter>")182+ if m.sheet.Value(addr("A3")).Num != 2 {183+ t.Fatalf("=: A3 %q", input(m, "A3"))184+ }185+ // Sheets keys vim doesn't take still work.186+ press(t, m, "<enter>")187+ if m.mode != modeEdit {188+ t.Errorf("Enter: mode %v", m.mode)189+ }190+ press(t, m, "<esc>", "<delete>")191+ if input(m, "A4") != "" {192+ t.Errorf("Del: A4 %q", input(m, "A4"))193+ }194+}195+196+func TestVimVisual(t *testing.T) {197+ m := vimModel(t)198+ press(t, m, "v", "j", "l")199+ if indicator(m) != "VISUAL" || m.selection().String() != "A1:B2" {200+ t.Fatalf("v: %q %v", indicator(m), m.selection())201+ }202+ if !strings.Contains(line(m, contextLine), "other corner") {203+ t.Errorf("context: %q", line(m, contextLine))204+ }205+ press(t, m, "o")206+ if m.cur != addr("B2") || m.selection().String() != "A1:B2" {207+ t.Errorf("o: at %v, %v", m.cur, m.selection())208+ }209+ press(t, m, "y")210+ if indicator(m) != "NORMAL" || m.hasRange() || m.copied.clip == nil || m.copied.clip.Src.String() != "A1:B2" {211+ t.Fatalf("y: %q range %v", indicator(m), m.hasRange())212+ }213+ press(t, m, "G", "p")214+ if input(m, "B6") != "A1" || input(m, "C7") != "B2" {215+ t.Fatalf("p: B6 %q C7 %q", input(m, "B6"), input(m, "C7"))216+ }217+ press(t, m, "gg", "0", "v", "d")218+ if input(m, "A1") != "" || indicator(m) != "NORMAL" {219+ t.Fatalf("v d: A1 %q", input(m, "A1"))220+ }221+ press(t, m, "p")222+ if input(m, "A1") != "A1" {223+ t.Fatalf("p after d: A1 %q", input(m, "A1"))224+ }225+ // V selects whole rows; motions stretch them and d deletes them.226+ press(t, m, "j", "V", "2j")227+ if indicator(m) != "VISUAL" || m.whole != wholeRows || m.selection().From.Row != 1 || m.selection().To.Row != 3 {228+ t.Fatalf("V: %v whole %v", m.selection(), m.whole)229+ }230+ press(t, m, "d")231+ if input(m, "A2") != "A5" || !m.rowsCopied() {232+ t.Fatalf("V d: A2 %q", input(m, "A2"))233+ }234+ // Esc leaves VISUAL, keeping the active cell.235+ press(t, m, "v", "l", "<esc>")236+ if indicator(m) != "NORMAL" || m.hasRange() || m.cur != addr("A2") {237+ t.Errorf("Esc: %q %v at %v", indicator(m), m.hasRange(), m.cur)238+ }239+}240+241+func TestVimFind(t *testing.T) {242+ m := vimModel(t)243+ press(t, m, "n")244+ if !strings.Contains(line(m, contextLine), "No search yet") {245+ t.Errorf("n before a search: %q", line(m, contextLine))246+ }247+ press(t, m, "/", "B", "<enter>")248+ if m.overlay != nil || m.cur != addr("B1") || indicator(m) != "NORMAL" {249+ t.Fatalf("/B: overlay %v at %v", m.overlay, m.cur)250+ }251+ press(t, m, "n", "n")252+ if m.cur != addr("B3") || !strings.Contains(line(m, contextLine), "Match 3 of 6") {253+ t.Fatalf("nn: at %v, %q", m.cur, line(m, contextLine))254+ }255+ press(t, m, "N")256+ if m.cur != addr("B2") {257+ t.Errorf("N: at %v", m.cur)258+ }259+ press(t, m, "gg", "N")260+ if m.cur != addr("B6") {261+ t.Errorf("N wraps: at %v", m.cur)262+ }263+}264+265+func TestVimCommandLine(t *testing.T) {266+ m := vimModel(t)267+ press(t, m, ":")268+ if indicator(m) != "COMMAND" || !strings.HasPrefix(line(m, contextLine), ":") {269+ t.Fatalf(": %q %q", indicator(m), line(m, contextLine))270+ }271+ press(t, m, "B12", "<enter>")272+ if m.cur != addr("B12") || m.overlay != nil || indicator(m) != "NORMAL" {273+ t.Fatalf(":B12 at %v", m.cur)274+ }275+ press(t, m, ":40", "<enter>")276+ if m.cur != addr("B40") {277+ t.Fatalf(":40 at %v", m.cur)278+ }279+ press(t, m, ":Sheet1!C2", "<enter>")280+ if m.cur != addr("C2") {281+ t.Fatalf(":Sheet1!C2 at %v", m.cur)282+ }283+ // Completion comes from the command registry.284+ press(t, m, ":fill")285+ s := screen(m)286+ for _, want := range []string{"Commands", "Fill down", "edit.fill_down", "Fill right"} {287+ if !strings.Contains(s, want) {288+ t.Fatalf(":fill completions lack %q:\n%s", want, s)289+ }290+ }291+ if !strings.Contains(line(m, m.height-1), "Copy the top row") {292+ t.Errorf("status: %q", line(m, m.height-1))293+ }294+ press(t, m, "<tab>")295+ if m.line.text() != "edit.fill_down" {296+ t.Errorf("Tab: %q", m.line.text())297+ }298+ press(t, m, "<tab>")299+ if m.line.text() != "edit.fill_right" {300+ t.Errorf("Tab Tab: %q", m.line.text())301+ }302+ press(t, m, "<esc>")303+ // A title runs its command; so does the first completion of a prefix.304+ press(t, m, "v", "j", ":", "fill down", "<enter>")305+ if input(m, "C3") != "C2" {306+ t.Errorf(":fill down: C3 %q", input(m, "C3"))307+ }308+ press(t, m, "<esc>", ":edit.undo", "<enter>")309+ if input(m, "C3") != "C3" {310+ t.Errorf(":edit.undo: C3 %q", input(m, "C3"))311+ }312+ press(t, m, ":keyboard sh", "<enter>")313+ if _, ok := m.overlay.(*shortcuts); !ok {314+ t.Errorf(":keyboard sh opened %T", m.overlay)315+ }316+ press(t, m, "<esc>", ":nonsense", "<enter>")317+ if m.mode != modeError || !strings.Contains(line(m, m.height-1), "Not a command, cell or range: nonsense") {318+ t.Errorf(":nonsense: %q", line(m, m.height-1))319+ }320+ press(t, m, "<esc>", ":", "<backspace>")321+ if m.overlay != nil {322+ t.Errorf("Backspace on an empty line left %T open", m.overlay)323+ }324+}325+326+func TestVimFileCommands(t *testing.T) {327+ dir := t.TempDir()328+ m := vimModel(t)329+ name := filepath.Join(dir, "book")330+ press(t, m, "x", ":w "+name, "<enter>")331+ if _, err := os.Stat(name + ".012"); err != nil || m.filename != name+".012" || m.changed {332+ t.Fatalf(":w: %v, filename %q, changed %v", err, m.filename, m.changed)333+ }334+ press(t, m, ":w "+filepath.Join(dir, "out.csv"), "<enter>")335+ if data, err := os.ReadFile(filepath.Join(dir, "out.csv")); err != nil || !strings.Contains(string(data), "B1,C1") {336+ t.Fatalf(":w out.csv: %v %q", err, data)337+ }338+ press(t, m, "j", "x", ":e other", "<enter>")339+ if m.mode != modeError || !strings.Contains(line(m, m.height-1), "Unsaved changes") {340+ t.Fatalf(":e with changes: %q", line(m, m.height-1))341+ }342+ press(t, m, "<esc>", ":e! "+name, "<enter>")343+ if m.changed || input(m, "A2") != "A2" {344+ t.Fatalf(":e!: changed %v, A2 %q", m.changed, input(m, "A2"))345+ }346+ if _, ok := press(t, m, ":q", "<enter>").(tea.QuitMsg); !ok {347+ t.Error(":q didn't quit a saved sheet")348+ }349+ press(t, m, "x", ":q", "<enter>")350+ if !strings.Contains(line(m, contextLine), "unsaved changes") {351+ t.Errorf(":q with changes: %q", line(m, contextLine))352+ }353+ press(t, m, "<esc>")354+ if _, ok := press(t, m, ":wq", "<enter>").(tea.QuitMsg); !ok || m.changed {355+ t.Errorf(":wq didn't save and quit")356+ }357+}358+359+// Help lists the vim keys first, and the palette and menus show vim keys360+// where Sheets' are taken.361+func TestVimHelpAndShortcuts(t *testing.T) {362+ m := vimModel(t)363+ press(t, m, "<f1>")364+ s := screen(m)365+ if !strings.Contains(s, "Vim keys: NORMAL") || !strings.Contains(s, "Left, down, up, right") {366+ t.Errorf("help:\n%s", s)367+ }368+ found := false369+ for _, r := range helpRows(true) {370+ found = found || r.action == "Cut rows" && strings.Join(r.keys, " ") == "dd"371+ }372+ if !found {373+ t.Error("help lacks dd for Cut rows")374+ }375+ if m.shortcut("row.cut") != "dd" || m.shortcut("edit.fill_down") != "" || m.shortcut("edit.undo") != "Ctrl+Z" {376+ t.Errorf("shortcuts: %q %q %q", m.shortcut("row.cut"), m.shortcut("edit.fill_down"), m.shortcut("edit.undo"))377+ }378+ if shortcut("edit.fill_down") != "Ctrl+D" {379+ t.Errorf("Sheets shortcut: %q", shortcut("edit.fill_down"))380+ }381+ rows := helpRows(true)382+ if rows[0].heading != "Vim keys: NORMAL" {383+ t.Errorf("first help group %q", rows[0].heading)384+ }385+ for _, r := range helpRows(false) {386+ if strings.HasPrefix(r.heading, "Vim") {387+ t.Error("vim keys in help with vim keys off")388+ }389+ }390+}
A internal/ui/vimkeys.go +239 -0
@@ -0,0 +1,239 @@
1+package ui2+3+import (4+ tea "charm.land/bubbletea/v2"5+)6+7+// The vim key tables. Each binding runs a registered command, so vim keys8+// show next to commands in the palette and the shortcuts, and do what the9+// menus do; the few that only change mode (v, V, =) say so in help.10+11+// vimSpan is what a count means to a binding.12+type vimSpan int13+14+const (15+ spanRepeat vimSpan = iota // run the command n times (3u)16+ spanOnce // the count means nothing (:)17+ spanRows // act on n rows from the active cell's (3dd)18+ spanCols // act on n cells from the active cell rightwards (4x)19+)20+21+// vimBinding is what a key sequence does in NORMAL or VISUAL mode.22+type vimBinding struct {23+ id string // the command run, or shown in help for do24+ span vimSpan25+ label string // for help, when not the command's title26+ // do, when set, acts instead of running id: for keys that change27+ // mode or choose between commands.28+ do func(m *Model, n int) tea.Cmd29+}30+31+// run carries out the binding with count n.32+func (b vimBinding) run(m *Model, n int) tea.Cmd {33+ if b.do != nil {34+ return b.do(m, n)35+ }36+ c := commands[b.id]37+ if !c.available(m) {38+ m.note = c.title + " isn't available now"39+ return nil40+ }41+ switch b.span {42+ case spanOnce:43+ return m.runCommand(b.id)44+ case spanRows:45+ m.spanRows(n)46+ case spanCols:47+ m.spanCols(n)48+ default:49+ cmds := make([]tea.Cmd, 0, n)50+ for range n {51+ cmds = append(cmds, m.runCommand(b.id))52+ }53+ return tea.Batch(cmds...)54+ }55+ cmd := m.runCommand(b.id)56+ if m.mode == modeReady {57+ m.clearSelection()58+ }59+ return cmd60+}61+62+// vimNormal binds key sequences in NORMAL mode, in the order help lists63+// them.64+var vimNormal = map[string]vimBinding{65+ "i": {id: "edit", label: "Edit the cell, caret at the start", do: func(m *Model, _ int) tea.Cmd {66+ cmd := m.runCommand("edit")67+ m.line.pos = 068+ return cmd69+ }},70+ "a": {id: "edit", span: spanOnce},71+ "=": {label: "Start a formula", do: func(m *Model, _ int) tea.Cmd { m.startEntry(modeEnter, "="); return nil }},72+ "o": {id: "row.open_below", span: spanOnce},73+ "O": {id: "row.open_above", span: spanOnce},74+ "x": {id: "clear", span: spanCols},75+ "dd": {id: "row.cut", span: spanRows},76+ "yy": {id: "row.yank", span: spanRows},77+ "p": {id: "row.paste_below", label: "Paste rows below, or cells here", do: putAfter},78+ "P": {id: "row.paste_above", label: "Paste rows above, or cells here", do: putBefore},79+ "u": {id: "edit.undo"},80+ "ctrl+r": {id: "edit.redo"},81+ "v": {label: "Select cells (VISUAL)", do: func(m *Model, _ int) tea.Cmd { m.startVisual(visualCells); return nil }},82+ "V": {label: "Select rows (VISUAL)", do: func(m *Model, _ int) tea.Cmd { m.startVisual(visualRows); return nil }},83+ "/": {id: "edit.find", span: spanOnce},84+ "n": {id: "edit.find_next"},85+ "N": {id: "edit.find_prev"},86+ ":": {id: "vim.command", span: spanOnce},87+ "gt": {id: "sheet.next"},88+ "gT": {id: "sheet.prev"},89+}90+91+// vimVisual binds keys in VISUAL mode; motions extend the selection.92+var vimVisual = map[string]vimBinding{93+ "d": {label: "Delete the selection, keeping a copy", do: visualDelete},94+ "x": {label: "Delete the selection, keeping a copy", do: visualDelete},95+ "y": {label: "Copy the selection", do: func(m *Model, _ int) tea.Cmd {96+ id := "edit.copy"97+ if m.vim.visual == visualRows {98+ id = "row.yank"99+ }100+ return m.endVisual(m.runCommand(id))101+ }},102+ "p": {id: "edit.paste", label: "Paste over the selection", do: func(m *Model, _ int) tea.Cmd {103+ m.vim.visual = visualNone104+ return m.runCommand("edit.paste")105+ }},106+ "o": {label: "Go to the other corner", do: func(m *Model, _ int) tea.Cmd {107+ m.cur, m.ext = m.ext, m.cur108+ return nil109+ }},110+ "v": {label: "Select cells, or back to NORMAL", do: func(m *Model, _ int) tea.Cmd { return m.switchVisual(visualCells) }},111+ "V": {label: "Select rows, or back to NORMAL", do: func(m *Model, _ int) tea.Cmd { return m.switchVisual(visualRows) }},112+ ":": {id: "vim.command", span: spanOnce},113+}114+115+// vimHelpOrder lists the NORMAL and VISUAL bindings for help, grouped.116+var vimHelpOrder = []string{"i", "a", "=", "o", "O", "x", "dd", "yy", "p", "P", "u", "ctrl+r", "v", "V", "/", "n", "N", ":", "gt", "gT"}117+118+// visualDelete deletes the selection, keeping a copy to paste: whole119+// rows with V, the cells' contents with v.120+func visualDelete(m *Model, _ int) tea.Cmd {121+ if m.vim.visual == visualRows {122+ return m.endVisual(m.runCommand("row.cut"))123+ }124+ cmd := m.runCommand("edit.copy")125+ m.runCommand("clear")126+ return m.endVisual(cmd)127+}128+129+// endVisual goes back to NORMAL mode after an operator.130+func (m *Model) endVisual(cmd tea.Cmd) tea.Cmd {131+ m.vim.visual = visualNone132+ if m.mode == modeReady {133+ m.clearSelection()134+ }135+ return cmd136+}137+138+// switchVisual changes a visual selection to kind, or ends it when it139+// already is one.140+func (m *Model) switchVisual(kind visualKind) tea.Cmd {141+ if m.vim.visual == kind {142+ return m.endVisual(nil)143+ }144+ m.startVisual(kind)145+ return nil146+}147+148+// putAfter is p: rows copied with yy or dd go in as new rows below the149+// active one; cells copied any other way are pasted at the active cell.150+func putAfter(m *Model, _ int) tea.Cmd {151+ if !m.rowsCopied() {152+ return m.runCommand("edit.paste")153+ }154+ return m.runCommand("row.paste_below") // it reads the count, n155+156+}157+158+// putBefore is P: as p, but rows go in above the active one.159+func putBefore(m *Model, _ int) tea.Cmd {160+ if !m.rowsCopied() {161+ return m.runCommand("edit.paste")162+ }163+ return m.runCommand("row.paste_above")164+}165+166+// vimKeysFor returns the vim keys bound to a command, NORMAL mode first.167+func vimKeysFor(id string) []string {168+ var keys []string169+ for _, k := range vimHelpOrder {170+ if vimNormal[k].id == id {171+ keys = append(keys, k)172+ }173+ }174+ return keys175+}176+177+// vimShadows reports whether vim keys take key from its Sheets binding:178+// Ctrl+D and Ctrl+U scroll, Ctrl+R redoes.179+func vimShadows(key string) bool {180+ _, bound := vimNormal[key]181+ _, moves := vimMotions[key]182+ return bound || moves183+}184+185+// isVimKey reports whether k is one of the vim tables' keys, which are186+// shown as typed (dd, G, $) rather than as a named key.187+func isVimKey(k string) bool {188+ _, bound := vimNormal[k]189+ _, moves := vimMotions[k]190+ _, visual := vimVisual[k]191+ return (bound || moves || visual) && !isNamedKey(k)192+}193+194+// isNamedKey reports whether k names a key rather than a character.195+func isNamedKey(k string) bool {196+ return len(k) > 2 || k == "up"197+}198+199+// vimVisualOrder lists the VISUAL bindings for help.200+var vimVisualOrder = []string{"d", "x", "y", "p", "o", "v", "V"}201+202+// vimHelpRows are the vim keys for the shortcuts view: motions, then the203+// NORMAL and VISUAL bindings, from the tables above.204+func vimHelpRows() []helpRow {205+ rows := []helpRow{206+ {heading: "Vim keys: NORMAL"},207+ {keys: []string{"h", "j", "k", "l"}, action: "Left, down, up, right"},208+ {keys: []string{"w", "b"}, action: "Jump to the edge of the data"},209+ {keys: []string{"gg", "G"}, action: "First row, last row of data (5G: row 5)"},210+ {keys: []string{"0", "$"}, action: "Column A, last cell of the row"},211+ {keys: []string{"H", "M", "L"}, action: "Top, middle, bottom of the screen"},212+ {keys: []string{"Ctrl+D", "Ctrl+U"}, action: "Half a screen down, up"},213+ {keys: []string{"5j", "3dd"}, action: "A count repeats a move or sizes an operator"},214+ }215+ rows = append(rows, bindingRows(vimNormal, vimHelpOrder)...)216+ rows = append(rows, helpRow{heading: "Vim keys: VISUAL"},217+ helpRow{keys: []string{"Motions"}, action: "Extend the selection"})218+ rows = append(rows, bindingRows(vimVisual, vimVisualOrder)...)219+ return append(rows, helpRow{keys: []string{"Esc"}, action: "Back to NORMAL"})220+}221+222+// bindingRows lists bindings in order, joining neighbors that do the223+// same thing (d and x).224+func bindingRows(table map[string]vimBinding, order []string) []helpRow {225+ var rows []helpRow226+ for _, k := range order {227+ b := table[k]228+ action := b.label229+ if action == "" {230+ action = commands[b.id].title231+ }232+ if n := len(rows); n > 0 && rows[n-1].action == action {233+ rows[n-1].keys = append(rows[n-1].keys, keyLabel(k))234+ continue235+ }236+ rows = append(rows, helpRow{keys: []string{keyLabel(k)}, action: action})237+ }238+ return rows239+}