cat / 012
clone

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

Merge macros: recording, Starlark scripts, step limits and trust

Dami committed bdc5259
Resolved against pivots, vim keys, the config file and 012 serve: commands
declare both what they edit and how macros treat them, settings and the
: line are never recorded or run from scripts, the machine id lives in
config.Dir(), and e2e sessions keep XDG_CONFIG_HOME in their directory.
Sheet-list undo helpers move to historysheets.go to keep history.go under
500 lines.
commit
bdc52596b78b33bf8de50f034204c3732688d78f
parent
11b3e2b4f8f7af4c89ed143a48ef3e834913d34d
parent
0c85478a6f041853a9b150fb6a74817bf85a6c6d
author
Dami <dami.etoile@gmail.com>
92 files changed, +5058 -218
MREADME.md5+
MROADMAP.md2+-
Acmd/012/machine.go37+++
Acmd/012/machine_test.go23++
Mcmd/012/main.go2+
Mdocs/README.md1+
Mdocs/architecture.md38+++-
Mdocs/extending.md43++-
Mdocs/files.md5+
Mdocs/keys.md4+-
Mdocs/limits.md22++
Adocs/macros.md241+++++++++++++++++
Mdocs/observability.md1+
Mdocs/testing.md5+
Me2e/harness_test.go2+-
Ae2e/macros_test.go100+++++++
Ae2e/screenmacros_test.go114++++++++
Me2e/testdata/screens/help-light.html2+-
Me2e/testdata/screens/help-narrow.html2+-
Me2e/testdata/screens/help-wide.html12+-
Me2e/testdata/screens/help.html2+-
Ae2e/testdata/screens/macro-error-light.html30++
Ae2e/testdata/screens/macro-error.html30++
Ae2e/testdata/screens/macro-manager-light.html30++
Ae2e/testdata/screens/macro-manager.html30++
Ae2e/testdata/screens/macro-menu-light.html30++
Ae2e/testdata/screens/macro-menu.html30++
Ae2e/testdata/screens/macro-recording-light.html30++
Ae2e/testdata/screens/macro-recording-narrow.html16+
Ae2e/testdata/screens/macro-recording.html30++
Ae2e/testdata/screens/macro-shortcut.html30++
Ae2e/testdata/screens/macro-trust-light.html30++
Ae2e/testdata/screens/macro-trust.html30++
Me2e/testdata/screens/palette-narrow.html22+-
Me2e/testdata/screens/palette-search-light.html32+-
Me2e/testdata/screens/palette-search.html32+-
Me2e/testdata/screens/palette-wide.html32+-
Me2e/testdata/screens/palette.html32+-
Mgo.mod3+-
Mgo.sum6+-
Ainternal/macro/builtins.go420++++++++++++++++++++++++++++++
Ainternal/macro/macro.go171++++++++++++
Ainternal/macro/macro_test.go213+++++++++++++++
Ainternal/macro/run.go169++++++++++++
Minternal/sheet/file.go20+-
Minternal/sheet/history.go115++++-----
Minternal/sheet/history_test.go2+-
Ainternal/sheet/historysheets.go50++++
Ainternal/sheet/macros.go237+++++++++++++++++
Ainternal/sheet/macros_test.go145++++++++++
Minternal/sheet/refs.go18+
Minternal/sheet/workbook.go3+
Minternal/ui/charts.go2+-
Minternal/ui/clipboard.go2+
Minternal/ui/cmdline.go2+-
Minternal/ui/columns.go14+-
Minternal/ui/commands.go47+++-
Minternal/ui/context.go2+-
Minternal/ui/dialog.go9+-
Minternal/ui/download.go1+
Minternal/ui/edit.go7+-
Minternal/ui/entry.go4+
Minternal/ui/file.go3+-
Minternal/ui/fill.go21++
Minternal/ui/find.go4+-
Minternal/ui/help.go10+-
Minternal/ui/jev.go2+-
Ainternal/ui/macro.go277++++++++++++++++++++
Ainternal/ui/macro_doc_test.go70+++++
Ainternal/ui/macro_stress_test.go100+++++++
Ainternal/ui/macro_test.go382+++++++++++++++++++++++++++
Ainternal/ui/macrohost.go247++++++++++++++++++
Ainternal/ui/macrohostnav.go311++++++++++++++++++++++
Ainternal/ui/macromanage.go266+++++++++++++++++++
Ainternal/ui/macrorec.go259+++++++++++++++++++
Ainternal/ui/macrorun.go238+++++++++++++++++
Ainternal/ui/macroscript_test.go181+++++++++++++
Minternal/ui/menubar.go3+-
Minternal/ui/model.go27++-
Minternal/ui/model_test.go2+-
Minternal/ui/mouse.go2+
Minternal/ui/names.go2+-
Minternal/ui/palette.go3+-
Minternal/ui/panel.go10+
Minternal/ui/prefs.go8+-
Minternal/ui/prompt.go9+-
Minternal/ui/tabs.go6+-
Minternal/ui/tabstrip.go2+
Minternal/ui/theme/theme.go2+
Minternal/ui/trace.go4+-
Minternal/ui/transfer.go2+-
Minternal/ui/vimpref.go2+-
M README.md +5 -0
M ROADMAP.md +1 -1
A cmd/012/machine.go +37 -0
A cmd/012/machine_test.go +23 -0
M cmd/012/main.go +2 -0
M docs/README.md +1 -0
M docs/architecture.md +36 -2
@@ -13,6 +13,7 @@ internal/fileio import and export: CSV, TSV, XLSX, SQLite, Parquet, Lotus .wk1
1313 internal/chart chart layout, text rendering and kitty image encoding1414 internal/jev the API key's resolution, answer cache and TypeSafe client1515 internal/keyring the OS credential store the API key lives in16+internal/macro macros: the Starlark scripting API, recorded actions as scripts, step limits1617 internal/telemetry opt-in JSON log and OTLP export of spans, events and frame stats1718 internal/serve the SSH server: auth, host key, a Model per session (charm.land/wish/v2)1819 internal/confine resolving typed file names, confined to a directory when served
@@ -142,8 +143,8 @@ description (`commands.go`), reached from key bindings, the menu bar,
142143 context menus, the command palette, the help overlay and mouse gestures143144 that stand for one (double-clicking a tab renames it through144145 `sheet.rename`), so they can't disagree. `runCommand` is the one place a145-command runs: telemetry times it there, and a command log or macro146-recorder would attach there. A command that writes cells declares the146+command runs: telemetry times it there, and the macro recorder listens147+there (see [Macros](#macros)). A command that writes cells declares the147148 range it writes (`edits`), and `runCommand` refuses it over a pivot148149 table's results, so a new editing command is guarded by saying what it149150 edits.
@@ -164,6 +165,7 @@ draw. The components:
164165 | sheet tabs | `tabStrip` | where each sheet was left, the tab strip's scroll, layout and clicks (`tabstrip.go`) |165166 | mouse | `mouseState` | drags, hover, double clicks, the fill handle (`mouse.go`, `fill.go`) |166167 | import | `transfer` | the import in progress, its progress display and cancelling (`transfer.go`) |168+| macros | `recorder`, `macroState` | a recording in progress (`macrorec.go`); a macro running, trust in the file's macros (`macrorun.go`); what scripts act on (`macrohost.go`, `macrohostnav.go`); Data > Macros and the manager (`macro.go`, `macromanage.go`) |167169 | others | `clipboard`, `trace`, `chartState`, `jevRunner`, `terminal` | what Ctrl+V pastes, a trace being shown, chart commands' target, JEV questions in flight, what the terminal supports and the chart images sent to it |168170 169171 Overlays implement the `overlay` interface (`overlay.go`): an indicator
@@ -213,6 +215,38 @@ this much (the builder, number formats, serial dates and Excel formula
213215 translation); a format that grew its own dependencies would move to a214216 subpackage behind the same table row.215217 218+## Macros219+220+Macros are Starlark scripts (`go.starlark.net`, pure Go) kept in the221+workbook (`sheet.Macro`, saved in the file). `internal/macro` knows222+neither the engine nor the terminal: it defines the functions scripts call223+over a `Host` interface, turns a recording (a list of `Action`s, the224+command log) into a script, and runs scripts with a step limit, a cancel225+switch and errors placed at their line and column. Scripts have no226+`load`, and Starlark itself has no file, network, clock or random access,227+so the Host is the only way out.228+229+The UI implements the Host on the model (`scriptHost`), through the same230+paths keys take: entries go through `Sheet.Set`, the selection through231+the grid, commands through `runCommand`, with a question answered as if232+typed. A run's script executes on its own goroutine, and each call it233+makes comes back to the UI goroutine as a message (`macroCallMsg`), is234+run on the model, and releases the script; the UI keeps taking calls235+within one update for a few milliseconds, so a 1000-action replay costs236+about 2 ms, and gives the screen back when the script pauses, so Esc can237+stop a runaway. A script whose program is gone (it quit, or its SSH238+session dropped) stops after waiting 30 s for a call to be taken. The whole run is one undo step opened with239+`Workbook.Begin`.240+241+Recording (`macrorec.go`) listens where actions happen: `runCommand` for242+commands (with the answer to a prompt or choice bar), the entry commit,243+pastes, the fill handle, column borders and tab drags. The selection is244+recorded lazily, as absolute `select()` or relative `move()`/`extend()`,245+just before something acts on it. Files record the computer their macros246+were made or trusted on (an id from `cmd/012`); a macro from elsewhere247+asks once before it runs. Only the local app lets scripts be edited in248+the user's editor (`AllowEditor`), since that starts a program.249+216250 ## Charts217251 218252 `internal/chart` draws a chart in two layers that share one layout: text
M docs/extending.md +33 -10
@@ -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 (Sheets and vim), menu bar, context menus, palette, shortcuts help, the `:` command line and its completions |18+| `ui.command` | an action: id, title, description, run, enabled, checked, what it edits, and how macros treat it | key bindings (Sheets and vim), menu bar, context menus, palette, shortcuts help, the `:` command line and its completions, macro recording and `run()` in scripts |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 (`theme.Theme`) | a role: dark and light styles on the 16 ANSI colors, with a contrast minimum for schemes (`minContrast`) | every style in the UI, drawn in the terminal's palette or any color scheme (`FromPalette`); `TestEveryThemeReadable` checks each role under all 349 schemes |
@@ -41,8 +41,24 @@ composited over the finished grid, so a new one never shifts the layout.
4141 ### 3. Actions go through commands4242 4343 Every user action is a registered command, whatever reaches it (key, menu,44-mouse, palette). This keeps discoverability automatic, and gives one place45-to add a command log for macros and replay later.44+mouse, palette). This keeps discoverability automatic, and `runCommand`,45+the one place commands run, is where the macro recorder listens: a command46+that changes the workbook is recorded by id (`run("format.bold")`), with47+the answer to the question it asks. Each command says how macros treat it48+(`command.macro`): recorded (the default), a view command recorded as the49+selection it leaves (Go to, Select all, Next sheet), or never recorded nor50+run from scripts (files, menus, help, undo, macros).51+52+The rest of the command log is what isn't a command but matters to a53+replay: entries as they're accepted, pastes of text, the fill handle,54+column borders and dragged tabs, each recorded where it happens with55+`Model.record`, and the selection, recorded as a state (where it is just56+before something acts on it) rather than as the keys or clicks that moved57+it. So movement keys, typing and mouse selection needn't become commands58+to be recorded, and a replay doesn't depend on the window's size. A new59+action that changes the workbook outside a command records itself the same60+way; anything unrecorded that changes the workbook while recording becomes61+a comment in the script, so a gap shows.4662 4763 ### 4. Side effects stay at the edges4864
@@ -61,9 +77,14 @@ the same way.
6177 Every change to a workbook goes through `Batch(Change{...}, fn)` and the6278 history `step`: snapshot what changes, apply, mark dirty, recalculate. Undo,6379 telemetry, recalculation and the file's dirty flag hang off that one path,64-and so will anything later that needs to see every change (macros, a65-command log, live sharing). New mutations use it; they never write cells66-around it.80+and so will anything later that needs to see every change (live sharing).81+New mutations use it; they never write cells around it. A change made over82+several calls, as a macro run makes one call per message, opens its step83+with `Workbook.Begin` and closes it when done, so it undoes as one; inside84+it, `Settle` recalculates what changed so far, so what reads formulas85+(scripts, sorting) sees current values. The macro list itself is changed86+through the same steps, so saving a macro is undoable and marks the file87+modified.6788 6889 ### 6. Storage behind a small API6990
@@ -122,7 +143,9 @@ Where the code doesn't follow the patterns yet:
122143 - Components in `internal/ui` are handed the whole `*Model` rather than a123144 narrower interface, so they stay in package `ui` (pattern 2).124145 - Movement keys, typing, F4 in formulas, Alt+letter menus and direct mouse125- manipulation (resizing columns, the fill handle, dragging charts and tabs,126- filter buttons) act without a registered command. Making them commands127- would add palette and help entries, so they wait for the command log128- (pattern 3).146+ manipulation act without a registered command (pattern 3). The macro147+ recorder covers what matters for replay without them (entries, the148+ selection, pastes, the fill handle, column borders, dragged tabs), but149+ dragging and resizing charts and the choices made in dialogs (the sort150+ bar, the filter picker, find and replace, the chart editor) aren't151+ recorded: a recording notes them as comments.
M docs/files.md +5 -0
M docs/keys.md +3 -1
M docs/limits.md +22 -0
@@ -28,6 +28,7 @@ it lags, and past a second it stalls.
2828 | 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 |31+| Macros | Replaying 1000 recorded actions: 2.2 ms, one undo step; a script's call to the sheet: about 1.4 us | | Scripts past 10 M Starlark steps: stopped, with the line | One message per call to the sheet, served in batches on the UI goroutine |3132 | 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 |3233 | SSH sessions (`012 serve`) | 50 sessions typing at once: frames within one frame interval (p95 16.6 ms), 1.3 MiB per session plus its sheets (upper bound) | | More than `--max-sessions` (8 by default): turned away | Bubble Tea's 60 fps pacing; per session, the terminal's cell buffers |3334 | Pivot tables | A pivot over 8191 rows recomputes after an edit to its source in 1 to 4 ms | | Results past 8192 x 256 (the pivot shows #REF!) | Reading each source cell of its fields: a map lookup each |
@@ -258,6 +259,27 @@ Groups are found by a comparable key struct rather than a string built
258259 per row: that took the 8-category pivot from 8.4 k to 196 allocations259260 per edit, the others from 32 to 39 k to the figures above.260261 262+## Macros263+264+A macro's script runs on its own goroutine; every call it makes to the265+sheet (an entry, a move, a command) is served on the UI goroutine as a266+message, in batches of up to 12 ms per update, so replaying doesn't wait267+a frame per call and the screen stays live for Esc. The run is one undo268+step whatever its size (`BenchmarkMacroReplay`, `BenchmarkMacroScript`,269+`internal/ui`, `-tags stress`, `BENCHTIME=2s`):270+271+| Benchmark | Time | Per action or call | Allocations |272+|---|---|---|---|273+| Replay 1000 recorded actions, absolute references (entries, selects, extends, a command) | 2.16 ms | 2.2 us | 30 k, 1.6 MB |274+| The same with relative references (moves, formulas moved with the active cell) | 2.19 ms | 2.2 us | 31 k, 1.7 MB |275+| A script's loop: 5000 `set` and 5000 `get` calls | 13.7 ms | 1.4 us per call | 220 k, 7.8 MB |276+277+Reading a range walks its cells; a range larger than 4096 cells (whole278+columns) is first trimmed to the used range, which costs a pass over the279+sheet, so reading single cells never does. Every run stops after 10280+million Starlark steps (`macro.DefaultMaxSteps`), about a second of pure281+computation.282+261283 ## Hotspots found and fixed262284 263285 Each with its benchmark before and after, in the commit that fixed it.
A docs/macros.md +241 -0
@@ -0,0 +1,241 @@
1+# Macros2+3+Macros repeat work: record what you do once, then run it again with a4+key. They work like Google Sheets' Extensions > Macros, under5+**Data > Macros**. A macro is a [Starlark](https://github.com/bazelbuild/starlark)6+script (a small, deterministic dialect of Python) saved in the `.012`7+file, as Sheets saves Apps Script with a spreadsheet. Recorded macros are8+scripts too, so you can read them, change them, or write your own.9+10+## Recording11+12+1. **Data > Macros > Record macro** (or search the palette for "record").13+ `REC` appears beside the mode indicator, and the context line says14+ what's being recorded.15+2. Do the work: type entries, move and select, run commands from the16+ menus, keys or palette, paste, drag the fill handle or a column border.17+3. **Data > Macros > Stop and save recording**. Name the macro on the18+ context line, then give it a shortcut digit, or leave it empty. Esc at19+ either question goes back to recording; **Discard recording** stops20+ without saving.21+22+Two ways to record, as in Sheets:23+24+| | Recorded as | Replays |25+|---|---|---|26+| **Record macro** (absolute references) | `select("B3")`: the cells you selected | On the same cells, wherever the active cell is |27+| **Record macro with relative references** | `move(0, 1)`, `extend(2, 0)`: steps from the active cell | From wherever the active cell is when you run it; formulas typed move with it, as in a copy, and Ctrl+arrows, Home, Ctrl+Home and Ctrl+End are recorded as `jump()`, since where they land depends on the data |28+29+What gets recorded:30+31+- entries as they're accepted (`enter("=SUM(B2:B8)")`), including32+ Ctrl+Enter over a selection (`fill=True`);33+- the selection, only when something acts on it: pressing Down five times34+ then typing records one `move(0, 5)` (or one `select()`), so a replay35+ doesn't depend on the size of the window;36+- commands that change the sheet, by id (`run("format.bold")`), with the37+ answer to the question they ask, if any38+ (`run("column.width", answer="15")`, `run("sheet.delete", answer="enter")`);39+- pasted text (`paste_text(...)`), the fill handle (`fill(...)`), column40+ borders dragged or fitted (`set_width(...)`) and tabs dragged41+ (`move_sheet(...)`).42+43+What doesn't: dialogs such as sorting by several columns, the filter44+picker, find and replace, the chart editor, dragging a chart, and undo.45+Anything like that which changes the workbook is noted in the script as a46+comment, `# Not recorded: sort A2:C9`, so the script never silently does47+less than you did. Files, menus, help and other macros are never part of a48+macro.49+50+## Running51+52+- **The shortcut**, Ctrl+Alt+Shift+digit, in READY. Terminals without the53+ kitty keyboard protocol send it as Ctrl+Alt with the shifted symbol54+ (`Ctrl+Alt+!` for 1 on a US layout), which works too.55+- **Data > Macros > Run macro**, a list of the saved macros.56+- **The palette** (Ctrl+K): type the macro's name.57+58+A run is one undo step: Ctrl+Z undoes everything the macro did. While it59+runs, the mode indicator says `CMD` (as 1-2-3 did), keys and the mouse60+wait, and **Esc stops it**; what it did so far stays, and Ctrl+Z undoes it.61+When it ends, the context line says `Ran Totals`, or the last line the62+script printed, or where it failed:63+64+```65+Totals:3:5: get: not a cell or range: ZZ Ctrl+Z undoes what it did66+```67+68+(`Totals` is the macro's name, `3:5` the line and column in its script.)69+70+Every run has a limit of 10 million Starlark steps, so a script that loops71+forever stops within a second or so with72+`stopped after 10000000 steps: does it loop forever?`.73+74+## Managing75+76+**Data > Macros > Manage macros** lists the saved macros with their77+shortcuts:78+79+| Key | Action |80+|---|---|81+| Enter | Run the highlighted macro |82+| F2 | Rename it |83+| F3 | Change its shortcut: a digit, or empty for none |84+| F4 | Edit its script in your editor |85+| Ctrl+D | Delete it (Ctrl+Z brings it back) |86+| Type | Search by name |87+88+The first row, **+ Write a macro** (also **Data > Macros > Write a89+macro**), names a new macro and opens a script for it in your editor.90+91+Editing uses `$VISUAL`, then `$EDITOR`, then `vi`, with the screen handed92+over until the editor exits; the script is saved when it does. If it has93+a mistake, it's saved anyway and the context line says where94+(`Saved Totals, but it has a mistake at Totals:4:1: undefined: sett`), so95+you can fix it with F4. Sessions served over SSH don't start an editor:96+their macros can be recorded, run, renamed and deleted, not edited.97+98+Changes to macros (recording, renaming, deleting, editing) are undo steps99+and mark the file modified, like any edit.100+101+## Macros from other computers102+103+Opening a file never runs its macros. The file remembers which computer104+its macros were made or trusted on (a random id kept in 012's105+config directory, the one [config.md](config.md) describes). Running106+a macro from a file made elsewhere asks once, on the context line:107+108+```109+Trust this file's macros? Enter Run Esc Cancel110+```111+112+Enter trusts all of the file's macros for this session and, once the file113+is saved, on this computer. Sessions served over SSH have no id of their114+own, so they ask once per session.115+116+Scripts can't read or write files, reach the network, start programs, or117+read the clock or random numbers: all they can reach is the functions118+below. What a macro can do is what you could do with the keyboard in that119+workbook, which is why it asks before running someone else's.120+121+## Writing scripts122+123+A script runs from top to bottom. It's Starlark: Python's syntax124+(`for`, `if`, `def`, lists, dicts, string formatting, `while`) without125+imports, classes or exceptions. Statements may be at the top level.126+127+```python128+# Total every numeric column of the selection, in the row below it.129+sel = selection() # e.g. "B2:D9"130+rows = get(sel)131+if type(rows) != "list":132+ rows = [[rows]] # a single cell133+below = offset(sel.split(":")[-1], 0, 1)134+first = offset(sel.split(":")[0], 0, len(rows))135+for c in range(len(rows[0])):136+ col = [r[c] for r in rows]137+ if all([type(v) in ("int", "float") for v in col]):138+ cell = offset(first, c, 0)139+ set_formula(cell, "SUM(%s:%s)" % (offset(sel.split(":")[0], c, 0), offset(cell, 0, -1)))140+select(first)141+run("format.bold")142+```143+144+References are strings in A1 notation: a cell (`"B3"`), a range145+(`"B3:D9"`), whole columns (`"B:D"`) or rows (`"3:5"`), a named range146+(`"Sales"`), any of them after a sheet name (`"Sheet2!A1"`,147+`"'Q3 plan'!A1:B2"`). Values come back as Starlark values: whole numbers148+as `int`, others as `float`, text as `str`, `True` and `False`, `None`149+for a blank cell, and error values as their code (`"#DIV/0!"`).150+151+### Cells152+153+| Function | Does |154+|---|---|155+| `get(ref)` | The value of a cell, or a list of rows of values for a range |156+| `get_formula(ref)` | A cell's formula as typed (`"=SUM(B2:B8)"`), `""` if it holds none; a list of rows for a range |157+| `set(ref, value)` | Enter a value as if typed: numbers, `True`/`False`, `None` (clears), or text, which is read as typed (`"=A1*2"` is a formula, `"$5"` currency, `"'=x"` text). A range gets `value` in every cell, or a list of rows (`[[1, 2], [3, 4]]`) from its first cell |158+| `set_formula(ref, formula)` | Enter a formula (the `=` is optional) in the first cell and fill it over the range, relative references adjusting as in a copy |159+| `clear(ref=None)` | Clear contents, keeping formatting; the selection by default |160+| `number_format(ref, kind, decimals=None, pattern="")` | Format numbers: kind is `auto`, `text`, `number`, `percent`, `scientific`, `accounting`, `financial`, `currency`, `date`, `time`, `datetime`, `duration`, or `custom` with a `pattern` such as `"0.0%"` |161+| `get_number_format(ref)` | The kind of a cell's number format |162+| `offset(ref, cols=0, rows=0)` | `ref` moved, keeping its size and sheet: `offset("A1", 2, 3)` is `"C4"` |163+164+Formulas read inside a script are up to date: a value set a line earlier165+is already in the formulas that use it.166+167+### The selection168+169+| Function | Does |170+|---|---|171+| `selection()` | The selected range, e.g. `"B2:D9"`, `"B:D"` |172+| `active_cell()` | The active cell, e.g. `"B2"` |173+| `select(ref, active=None)` | Select a cell or range, on another sheet if it names one; `active` puts the active cell elsewhere in it |174+| `move(cols=0, rows=0)` | Move the active cell, dropping the selection |175+| `extend(cols=0, rows=0, whole=None)` | Select from the active cell to `cols`, `rows` away; `whole="columns"` or `"rows"` selects whole ones |176+| `jump(to, extend=False)` | Move as keys do: `"up"`, `"down"`, `"left"`, `"right"` (Ctrl+arrows, to the edge of the data), `"home"` (column A), `"start"` (A1), `"end"` (the last used cell) |177+| `enter(text, fill=False, origin=None)` | Type into the active cell and accept, as Enter does without moving; `fill=True` fills every selected cell (Ctrl+Enter). With `origin`, a formula is taken as typed at that cell and its relative references move with the distance to the active cell |178+| `paste_text(text)` | Paste tab-separated text from the active cell, as pasting from another program |179+| `fill(to=None, rows=0, cols=0)` | Drag the fill handle: continue the selection's series or copy it over `to`, or `rows` down (up if negative), or `cols` right (left if negative) |180+181+### Sheets182+183+| Function | Does |184+|---|---|185+| `sheets()` | The sheet names, in tab order |186+| `active_sheet()` | The name of the sheet shown |187+| `activate_sheet(name)` | Show a sheet |188+| `add_sheet(name=None)` | Add a sheet after the one shown and show it; returns its name |189+| `move_sheet(position)` | Move the sheet shown to a position, counting from 1 |190+| `set_width(cols, width)` | Set column widths, 1 to 240: `set_width("B", 14)`, `set_width("B:D", 8)` |191+192+### Commands193+194+`run(id, answer=None)` runs any command from the menus or palette by its195+id, on the selection, as if picked. A command that asks something (a196+width, a name, a confirmation) needs `answer`: the text you'd type, a range197+for a range question, or the key of a choice (`"enter"`, `"d"`) or its198+label. Commands that open a dialog or picker (Sort range, Insert chart,199+Named ranges) can't run in a script; nor can files, menus, help, undo or200+macros. The ids are those in the recorded scripts; a few:201+202+| Id | Command |203+|---|---|204+| `format.bold`, `format.italic`, `format.underline`, `format.strikethrough` | Text styles (toggles, as the keys) |205+| `format.currency`, `format.percent`, `format.date`, `format.decimals_more`, ... | Number formats |206+| `format.align_left`, `format.align_center`, `format.align_right`, `format.clear` | Alignment, clear formatting |207+| `edit.copy`, `edit.cut`, `edit.paste`, `edit.paste_values` | Clipboard (within 012; copying also sets the system clipboard) |208+| `edit.fill_down`, `edit.fill_right`, `clear` | Fill, clear the selection |209+| `insert.row_above`, `insert.row_below`, `insert.col_left`, `insert.col_right`, `delete.row`, `delete.col` | Rows and columns |210+| `column.width` (answer: the width), `column.reset` | Column widths |211+| `data.sort_sheet_az`, `data.sort_range_az`, `data.sort_range_za`, `data.filter`, `data.filter_remove` | Sorting and filters |212+| `data.define_name` (answer: the name) | Name the selection |213+| `sheet.new`, `sheet.duplicate`, `sheet.rename` (answer: the name), `sheet.delete` (answer: `"enter"` when it asks) | Sheets |214+| `view.freeze_rows1`, `view.freeze_cols1`, `view.freeze_rows0`, ... | Frozen panes |215+216+Record a macro that uses a command to see its id.217+218+### Output and errors219+220+`print(...)` shows its last line on the context line when the macro ends.221+A failure stops the macro and shows where it happened; there's no222+`try`. Calling a function wrongly (`move(1, 2, 3)`) or on a reference223+that doesn't exist fails the same way. Only what the functions above do is224+possible: no `load`, no files, no time, no randomness, so a script does225+the same thing every time it runs on the same sheet.226+227+## In the file228+229+Macros are saved in the `.012` file, one line each, after the named230+ranges:231+232+```json233+"macroOrigin": "3f9c0b1e5a7d42c8b61f0e9a2d4c7b18",234+"macros": [235+ {"name": "Totals", "key": "1", "api": 1, "source": "# Recorded with relative references...\nenter(\"Total\")\n"}236+],237+```238+239+`api` is the version of the functions above the script was written for.240+The file's version doesn't change: older builds of 012 open files with241+macros and ignore them (and drop them if they save).
M docs/observability.md +1 -0
M docs/testing.md +5 -0
M e2e/harness_test.go +1 -1
A e2e/macros_test.go +100 -0
@@ -0,0 +1,100 @@
1+package e2e2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+)9+10+// recordTotals records a macro with relative references at A1: a label,11+// a formula to its right, and bold on both. It's saved as Totals with12+// the shortcut Ctrl+Alt+Shift+1.13+func recordTotals(s *session) {14+ s.keys("<ctrl+k>", "relative references")15+ s.waitFor("Record macro with relative references")16+ s.keys("<enter>")17+ s.waitFor(" REC ")18+ s.waitFor("Recording (relative references)")19+ s.keys("Total", "<tab>", "=SUM(2,3)", "<enter>")20+ s.keys("<up>", "<shift+right>", "<ctrl+b>")21+ s.waitFor("Bold on for A1:B1")22+ s.keys("<ctrl+k>", "stop and save", "<enter>")23+ s.waitFor("Save macro as: Macro 1")24+ s.keys("Totals", "<enter>")25+ s.waitFor("Shortcut Ctrl+Alt+Shift+ (a digit, or empty for none): 1")26+ s.keys("<enter>")27+ s.waitFor("Saved macro Totals; Ctrl+Alt+Shift+1 runs it")28+}29+30+func TestMacroRecordAndReplay(t *testing.T) {31+ dir := t.TempDir()32+ s := start(t, dir, "book.012")33+ recordTotals(s)34+ if strings.Contains(s.line(0), "REC") {35+ t.Errorf("still shows REC: %q", s.line(0))36+ }37+38+ // The shortcut replays it from wherever the active cell is.39+ s.keys("<f5>", "D5", "<enter>")40+ s.waitForBar("D5", "")41+ s.keys("<ctrl+alt+shift+1>")42+ s.waitFor("Ran Totals")43+ s.keys("<right>")44+ s.waitForBar("E5", "=SUM(2,3)")45+46+ // One Ctrl+Z undoes the whole run.47+ s.keys("<ctrl+z>")48+ s.waitFor("Undid: run macro Totals")49+ s.waitForBar("D5", "")50+51+ // Saved in the file, as a script.52+ s.keys("<ctrl+s>")53+ s.eventually("saved", func() bool {54+ data, err := os.ReadFile(filepath.Join(dir, "book.012"))55+ return err == nil && strings.Contains(string(data), `"macros": [`) &&56+ strings.Contains(string(data), `{"name": "Totals", "key": "1", "api": 1, "source": "# Recorded with relative references`) &&57+ strings.Contains(string(data), `enter(\"=SUM(2,3)\", origin=\"B1\")`)58+ })59+ s.keys("<ctrl+q>")60+ s.waitExit()61+62+ // Opening the file runs nothing; on this computer the macro runs63+ // without asking.64+ s = start(t, dir, "book.012")65+ s.waitForBar("A1", "Total")66+ s.keys("<f5>", "A3", "<enter>")67+ s.waitForBar("A3", "")68+ s.keys("<ctrl+k>", "totals")69+ s.waitFor("Ctrl+Alt+Shift+1")70+ s.keys("<enter>")71+ s.waitFor("Ran Totals")72+ s.waitForBar("A3:B3", "Total")73+}74+75+func TestMacroFromAnotherComputerAsks(t *testing.T) {76+ dir := t.TempDir()77+ s := start(t, dir, "book.012")78+ recordTotals(s)79+ s.keys("<ctrl+s>")80+ s.eventually("saved", func() bool {81+ data, err := os.ReadFile(filepath.Join(dir, "book.012"))82+ return err == nil && strings.Contains(string(data), "macroOrigin")83+ })84+ s.keys("<ctrl+q>")85+ s.waitExit()86+87+ s = startWith(t, options{dir: dir, configDir: t.TempDir()}, "book.012")88+ s.waitForBar("A1", "Total")89+ s.keys("<f5>", "C3", "<enter>", "<ctrl+alt+shift+1>")90+ s.waitFor("Trust this file's macros?")91+ s.keys("<esc>")92+ s.waitForBar("C3", "")93+ s.keys("<ctrl+alt+shift+1>")94+ s.waitFor("made on another computer and can change the file")95+ s.keys("<enter>")96+ s.waitForBar("C3:D3", "Total") // the macro ends selecting the pair97+ // Trusted now: no second question.98+ s.keys("<down>", "<ctrl+alt+shift+1>")99+ s.waitForBar("C4:D4", "Total")100+}
A e2e/screenmacros_test.go +114 -0
@@ -0,0 +1,114 @@
1+package e2e2+3+import (4+ "os"5+ "path/filepath"6+ "testing"7+)8+9+// Golden screens for macros: the Data > Macros menu, recording, saving,10+// the manager, an error from a script and the question before running11+// macros from another computer.12+13+// recordOnBudget records, with relative references, a total row below14+// the budget: a label, a formula and bold, saved as name with shortcut15+// key (empty for none).16+func recordOnBudget(s *session, name, key string) {17+ s.keys("<ctrl+k>", "relative references", "<enter>")18+ s.waitFor(" REC ")19+ s.keys("<down>", "<left>", "Yearly", "<tab>", "=B7*12", "<enter>")20+ s.keys("<up>", "<shift+right>", "<ctrl+b>")21+ s.waitFor("Bold on")22+ s.keys("<ctrl+k>", "stop and save", "<enter>")23+ s.waitFor("Save macro as:")24+ s.keys(name, "<enter>")25+ s.waitFor("Shortcut Ctrl+Alt+Shift+")26+ s.keys("<backspace>", key, "<enter>")27+ s.waitFor("Saved macro " + name)28+}29+30+// foreignBook is a saved budget with a macro made on another computer.31+const foreignBook = `{32+ "version": 2,33+ "macroOrigin": "0123456789abcdef0123456789abcdef",34+ "macros": [35+ {"name": "Yearly", "key": "1", "api": 1, "source": "enter(\"Yearly\")\nmove(1, 0)\nenter(\"=B7*12\", origin=\"B8\")\n"}36+ ],37+ "cells": {38+ "A1": "Household budget 2026",39+ "A3": "Rent",40+ "B3": "1450",41+ "A7": "Total",42+ "B7": "=SUM(B3:B5)"43+ }44+}45+`46+47+var macroScreens = []screen{48+ {name: "macro-menu", setup: func(s *session) {49+ budget(s)50+ s.keys("<alt+d>", "m")51+ s.waitFor("Record macro with relative references")52+ }},53+ {name: "macro-recording", setup: func(s *session) {54+ budget(s)55+ s.keys("<ctrl+k>", "relative references", "<enter>")56+ s.waitFor(" REC ")57+ s.keys("<down>", "<left>", "Yearly", "<tab>")58+ s.waitForBar("B8", "")59+ }},60+ {name: "macro-shortcut", setup: func(s *session) {61+ budget(s)62+ s.keys("<ctrl+k>", "relative references", "<enter>")63+ s.waitFor(" REC ")64+ s.keys("<down>", "<left>", "Yearly", "<enter>")65+ s.keys("<ctrl+k>", "stop and save", "<enter>")66+ s.waitFor("Save macro as:")67+ s.keys("Yearly total", "<enter>")68+ s.waitFor("Shortcut Ctrl+Alt+Shift+")69+ }},70+ {name: "macro-manager", setup: func(s *session) {71+ budget(s)72+ recordOnBudget(s, "Yearly total", "1")73+ s.keys("<ctrl+home>")74+ recordOnBudget(s, "Header row", "")75+ s.keys("<ctrl+k>", "manage macros", "<enter>")76+ s.waitFor("Write a macro")77+ s.keys("<down>")78+ }},79+ {name: "macro-error", setup: func(s *session) {80+ budget(s)81+ recordOnBudget(s, "Yearly", "1")82+ s.keys("<ctrl+end>", "<down>", "<down>", "<down>", "<home>", "<ctrl+alt+shift+1>")83+ s.waitFor("goes off the sheet")84+ }},85+ {name: "macro-trust", files: func(t *testing.T, dir string) {86+ if err := os.WriteFile(filepath.Join(dir, "budget.012"), []byte(foreignBook), 0o644); err != nil {87+ t.Fatal(err)88+ }89+ }, setup: func(s *session) {90+ s.keys("<ctrl+o>")91+ s.waitFor("Open file:")92+ s.keys("budget", "<enter>")93+ s.waitFor("Household budget 2026")94+ s.keys("<f5>", "A8", "<enter>", "<ctrl+alt+shift+1>")95+ s.waitFor("Trust this file's macros?")96+ }},97+ {name: "macro-recording-narrow", opts: options{cols: 60, rows: 16}, setup: func(s *session) {98+ budget(s)99+ s.keys("<ctrl+k>", "relative references", "<enter>")100+ s.waitFor(" REC ")101+ }},102+}103+104+func init() {105+ light := map[string]bool{"macro-menu": true, "macro-recording": true, "macro-manager": true, "macro-error": true, "macro-trust": true}106+ for _, sc := range macroScreens {107+ screens = append(screens, sc)108+ if light[sc.name] {109+ sc.name += "-light"110+ sc.opts.light = true111+ screens = append(screens, sc)112+ }113+ }114+}
M e2e/testdata/screens/help-light.html +1 -1
@@ -25,6 +25,6 @@
2525 <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> <span style="color:var(--vt-palette-8)">│</span> Deselect <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-8)">│</span>2626 <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> <span style="color:var(--vt-palette-8)">│</span> Select all <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+A</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>2727 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select columns <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Space</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">1-25</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">84</span> <span style="color:var(--vt-palette-8)">─┘</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> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">1-25</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">88</span> <span style="color:var(--vt-palette-8)">─┘</span>2929 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>3030 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Up/Down</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">scroll</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)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/help-narrow.html +1 -1
@@ -11,6 +11,6 @@
1111 <span style="color:var(--vt-palette-8)">│</span> Last used cell <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+End</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>1212 <span style="color:var(--vt-palette-8)">│</span> Go to <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+G</span><span style="background: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)">F5</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>1313 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">│</span>14-<span style="color:var(--vt-palette-8)">└────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-11</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">84</span> <span style="color:var(--vt-palette-8)">─┘</span>14+<span style="color:var(--vt-palette-8)">└────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-11</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">88</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>1616 <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)">scroll</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/help-wide.html +6 -6
@@ -34,12 +34,12 @@
3434 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">30</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Cancel the entry <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> Italic <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+I</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>3535 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">31</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Edit cell <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F2</span><span style="background: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)">Enter</span><span style="background:var(--vt-palette-8)"> </span> Underline <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+U</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>3636 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">32</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Strikethrough <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Shift+5</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>37-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">33</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Menus</span> <span style="font-weight:bold">and</span> <span style="font-weight:bold">search</span> Left <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+L</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>38-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">34</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Open a menu by its underlined letter <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+letter</span><span style="background:var(--vt-palette-8)"> </span> Center <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+E</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>39-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">35</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Search the menus <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="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+P</span><span style="background: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)">Alt+/</span><span style="background:var(--vt-palette-8)"> </span> Right <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+R</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>40-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">36</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Menu <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> Clear formatting <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)">│</span>41-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">37</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Cell menu <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>42-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">38</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Keyboard shortcuts <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="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F1</span><span style="background:var(--vt-palette-8)"> </span> <span style="font-weight:bold">Data</span> <span style="color:var(--vt-palette-8)">│</span>37+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">33</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Macros</span> Left <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+L</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>38+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">34</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Run the macro with that shortcut <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+Shift+0-9</span><span style="background:var(--vt-palette-8)"> </span> Center <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+E</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>39+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">35</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Stop a macro while it runs <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> Right <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+R</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>40+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">36</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Clear formatting <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)">│</span>41+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">37</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Menus</span> <span style="font-weight:bold">and</span> <span style="font-weight:bold">search</span> <span style="color:var(--vt-palette-8)">│</span>42+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">38</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Open a menu by its underlined letter <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+letter</span><span style="background:var(--vt-palette-8)"> </span> <span style="font-weight:bold">Data</span> <span style="color:var(--vt-palette-8)">│</span>4343 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">39</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-40</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">44</span> <span style="color:var(--vt-palette-8)">─┘</span>4444 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">40</span><span style="background:var(--vt-palette-8)"> </span>4545 <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)">scroll</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/help.html +1 -1
@@ -25,6 +25,6 @@
2525 <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> <span style="color:var(--vt-palette-8)">│</span> Deselect <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)">│</span>2626 <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> <span style="color:var(--vt-palette-8)">│</span> Select all <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>2727 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select columns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Space</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-25</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">84</span> <span style="color: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> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-25</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">88</span> <span style="color:var(--vt-palette-8)">─┘</span>2929 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>3030 <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)">scroll</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
A e2e/testdata/screens/macro-error-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">READY</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)">A11</span><span style="background:var(--vt-palette-7)"> </span>3+<span style="color:var(--vt-palette-3)">Yearly:3:5:</span> <span style="color:var(--vt-palette-3)">move:</span> <span style="color:var(--vt-palette-3)">moving</span> <span style="color:var(--vt-palette-3)">-1,</span> <span style="color:var(--vt-palette-3)">1</span> <span style="color:var(--vt-palette-3)">from</span> <span style="color:var(--vt-palette-3)">A11</span> <span style="color:var(--vt-palette-3)">goes</span> <span style="color:var(--vt-palette-3)">off</span> <span style="color:var(--vt-palette-3)">the</span> <span style="color:var(--vt-palette-3)">sheet</span>4+<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">A</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)">B</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-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> <span style="font-weight:bold">Yearly</span> <span style="font-weight:bold">25900.8</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-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">11</span><span style="background:var(--vt-palette-6)"> </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)">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/macro-error.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">READY</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)">A11</span><span style="background:var(--vt-palette-8)"> </span>3+<span style="color:var(--vt-palette-3)">Yearly:3:5:</span> <span style="color:var(--vt-palette-3)">move:</span> <span style="color:var(--vt-palette-3)">moving</span> <span style="color:var(--vt-palette-3)">-1,</span> <span style="color:var(--vt-palette-3)">1</span> <span style="color:var(--vt-palette-3)">from</span> <span style="color:var(--vt-palette-3)">A11</span> <span style="color:var(--vt-palette-3)">goes</span> <span style="color:var(--vt-palette-3)">off</span> <span style="color:var(--vt-palette-3)">the</span> <span style="color:var(--vt-palette-3)">sheet</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>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-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> <span style="font-weight:bold">Yearly</span> <span style="font-weight:bold">25900.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-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">11</span><span style="background:var(--vt-palette-6)"> </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)">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/macro-manager-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">MENU</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)">A2:B2</span><span style="background:var(--vt-palette-7)"> </span> Yearly <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Macros</span> <span style="color:var(--vt-palette-8)">───────────────────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> <span style="color:var(--vt-palette-0)">Type</span> <span style="color:var(--vt-palette-0)">a</span> <span style="color:var(--vt-palette-0)">macro&#39;s</span> <span style="color:var(--vt-palette-0)">name</span> <span style="color:var(--vt-palette-8)">│</span>4+<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">A</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)">B</span><span style="background:var(--vt-palette-4)"> </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)">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 2<span style="color:var(--vt-palette-8)">│</span> + Write a macro <span style="color:var(--vt-palette-8)">│</span>6+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">2</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Yearly</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);font-weight:bold">25900.8</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)">Yearly</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">total</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">9</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">lines</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Ctrl+Alt+Shift+1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</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 1450<span style="color:var(--vt-palette-8)">│</span> Header row <span style="color:var(--vt-palette-0)">9</span> <span style="color:var(--vt-palette-0)">lines</span> <span style="color:var(--vt-palette-8)">│</span>8+<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.4<span style="color:var(--vt-palette-8)">└───────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">3</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">3</span> <span style="color:var(--vt-palette-8)">─┘</span>9+<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> <span style="font-weight:bold">Yearly</span> <span style="font-weight:bold">25900.8</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+Run Yearly total <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)">F2</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">rename</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F3</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">shortcut</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F4</span><span style="background:var(--vt-palette-7)"> </span> <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+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)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">close</span></pre>\ No newline at end of file
A e2e/testdata/screens/macro-manager.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">MENU</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)">A2:B2</span><span style="background:var(--vt-palette-8)"> </span> Yearly <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Macros</span> <span style="color:var(--vt-palette-8)">───────────────────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> <span style="color:var(--vt-palette-8)">Type</span> <span style="color:var(--vt-palette-8)">a</span> <span style="color:var(--vt-palette-8)">macro&#39;s</span> <span style="color:var(--vt-palette-8)">name</span> <span style="color:var(--vt-palette-8)">│</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-4)">B</span><span style="background:var(--vt-palette-4)"> </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)">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 2<span style="color:var(--vt-palette-8)">│</span> + Write a macro <span style="color:var(--vt-palette-8)">│</span>6+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">2</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Yearly</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);font-weight:bold">25900.8</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)">Yearly</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">total</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">9</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">lines</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Ctrl+Alt+Shift+1</span><span style="background:var(--vt-palette-6)"> </span><span style="color: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 1450<span style="color:var(--vt-palette-8)">│</span> Header row <span style="color:var(--vt-palette-8)">9</span> <span style="color:var(--vt-palette-8)">lines</span> <span style="color:var(--vt-palette-8)">│</span>8+<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.4<span style="color:var(--vt-palette-8)">└───────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">3</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">3</span> <span style="color:var(--vt-palette-8)">─┘</span>9+<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> <span style="font-weight:bold">Yearly</span> <span style="font-weight:bold">25900.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+Run Yearly total <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)">F2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">rename</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F3</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcut</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F4</span><span style="background:var(--vt-palette-8)"> </span> <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+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)">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
A e2e/testdata/screens/macro-menu-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="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);text-decoration:underline">D</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">ata</span><span style="background:var(--vt-palette-6)"> </span> <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">MENU</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) <span style="color:var(--vt-palette-8)">┌────────────────────────────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> Sort sheet › <span style="color:var(--vt-palette-8)">│</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-8)">│</span> Sort range › <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)">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 2026 <span style="color:var(--vt-palette-8)">├────────────────────────────────────────────────┤</span>6+<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> <span style="color:var(--vt-palette-8)">│</span> Create a filter <span style="color:var(--vt-palette-8)">│</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 1450 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Filter</span> <span style="color:var(--vt-palette-8)">by</span> <span style="color:var(--vt-palette-8)">column</span> <span style="color:var(--vt-palette-8)">Alt+Down</span> <span style="color:var(--vt-palette-8)">│</span>8+<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.4 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Remove</span> <span style="color:var(--vt-palette-8)">filter</span> <span style="color:var(--vt-palette-8)">│</span>9+<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 96 <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> <span style="color:var(--vt-palette-8)">│</span> Pivot table <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Edit</span> <span style="color:var(--vt-palette-8)">pivot</span> <span style="color:var(--vt-palette-8)">table</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> Frequency table (column stats) <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Alt+Shift+F</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">├────────────────────────────────────────────────┤</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> <span style="color:var(--vt-palette-8)">│</span> Named ranges <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> Define named range <span style="color:var(--vt-palette-8)">│</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> <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> Trace precedents <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Alt+,</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> Trace dependents <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Alt+.</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">├─────────────────────┌──────────────────────────────────────────┐</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> <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)">Macros</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)">Record</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">macro</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">├─────────────────────│</span> Record macro with relative references <span style="color:var(--vt-palette-8)">│</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> <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)">│</span> <span style="color:var(--vt-palette-8)">Stop</span> <span style="color:var(--vt-palette-8)">and</span> <span style="color:var(--vt-palette-8)">save</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">└─────────────────────│</span> <span style="color:var(--vt-palette-8)">Discard</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">├──────────────────────────────────────────┤</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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Run</span> <span style="color:var(--vt-palette-8)">macro</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> Manage macros <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> Write a macro <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────┘</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+Record what you do as a macro that selects the same ce… <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Arrows</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">move</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)">choose</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)">close</span></pre>\ No newline at end of file
A e2e/testdata/screens/macro-menu.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="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);text-decoration:underline">D</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">ata</span><span style="background:var(--vt-palette-6)"> </span> <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">MENU</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) <span style="color:var(--vt-palette-8)">┌────────────────────────────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> Sort sheet › <span style="color:var(--vt-palette-8)">│</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-8)">│</span> Sort range › <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)">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 2026 <span style="color:var(--vt-palette-8)">├────────────────────────────────────────────────┤</span>6+<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> <span style="color:var(--vt-palette-8)">│</span> Create a filter <span style="color: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 1450 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Filter</span> <span style="color:var(--vt-palette-8)">by</span> <span style="color:var(--vt-palette-8)">column</span> <span style="color:var(--vt-palette-8)">Alt+Down</span> <span style="color:var(--vt-palette-8)">│</span>8+<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.4 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Remove</span> <span style="color:var(--vt-palette-8)">filter</span> <span style="color:var(--vt-palette-8)">│</span>9+<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 96 <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> <span style="color:var(--vt-palette-8)">│</span> Pivot table <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Edit</span> <span style="color:var(--vt-palette-8)">pivot</span> <span style="color:var(--vt-palette-8)">table</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> Frequency table (column stats) <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Shift+F</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Named ranges <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> Define named range <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>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> Trace precedents <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+,</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Trace dependents <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+.</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> <span style="color: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> <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)">Macros</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)">Record</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">macro</span><span style="background:var(--vt-palette-6)"> </span><span style="color: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> <span style="color:var(--vt-palette-8)">├─────────────────────│</span> Record macro with relative references <span style="color: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> <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)">│</span> <span style="color:var(--vt-palette-8)">Stop</span> <span style="color:var(--vt-palette-8)">and</span> <span style="color:var(--vt-palette-8)">save</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color: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> <span style="color:var(--vt-palette-8)">└─────────────────────│</span> <span style="color:var(--vt-palette-8)">Discard</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color: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> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Run</span> <span style="color:var(--vt-palette-8)">macro</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Manage macros <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Write a macro <span style="color: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> <span style="color: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+Record what you do as a macro that selects the same ce… <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Arrows</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)">choose</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
A e2e/testdata/screens/macro-recording-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-1)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-1);font-weight:bold">REC</span><span style="background:var(--vt-palette-1)"> </span> <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</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)">B8</span><span style="background:var(--vt-palette-7)"> </span>3+<span style="color:var(--vt-palette-0)">Recording</span> <span style="color:var(--vt-palette-0)">(relative</span> <span style="color:var(--vt-palette-0)">references).</span> <span style="color:var(--vt-palette-0)">Stop</span> <span style="color:var(--vt-palette-0)">it</span> <span style="color:var(--vt-palette-0)">in</span> <span style="color:var(--vt-palette-0)">Data</span> <span style="color:var(--vt-palette-0)">&gt;</span> <span style="color:var(--vt-palette-0)">Macros.</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-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-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-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">8</span><span style="background:var(--vt-palette-6)"> </span> Yearly <span style="background:var(--vt-palette-6)"> </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)">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/macro-recording-narrow.html +16 -0
@@ -0,0 +1,16 @@
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-1)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-1);font-weight:bold">REC</span><span style="background:var(--vt-palette-1)"> </span> <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</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="color:var(--vt-palette-8)">Recording</span> <span style="color:var(--vt-palette-8)">(relative</span> <span style="color:var(--vt-palette-8)">references).</span> <span style="color:var(--vt-palette-8)">Stop</span> <span style="color:var(--vt-palette-8)">it</span> <span style="color:var(--vt-palette-8)">in</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">&gt;</span> <span style="color:var(--vt-palette-8)">Macros.</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>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-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-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-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)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span></pre>\ No newline at end of file
A e2e/testdata/screens/macro-recording.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-1)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-1);font-weight:bold">REC</span><span style="background:var(--vt-palette-1)"> </span> <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</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)">B8</span><span style="background:var(--vt-palette-8)"> </span>3+<span style="color:var(--vt-palette-8)">Recording</span> <span style="color:var(--vt-palette-8)">(relative</span> <span style="color:var(--vt-palette-8)">references).</span> <span style="color:var(--vt-palette-8)">Stop</span> <span style="color:var(--vt-palette-8)">it</span> <span style="color:var(--vt-palette-8)">in</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">&gt;</span> <span style="color:var(--vt-palette-8)">Macros.</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-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-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-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">8</span><span style="background:var(--vt-palette-6)"> </span> Yearly <span style="background:var(--vt-palette-6)"> </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)">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/macro-shortcut.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-1)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-1);font-weight:bold">REC</span><span style="background:var(--vt-palette-1)"> </span> <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">KEY</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)">A9</span><span style="background:var(--vt-palette-8)"> </span>3+Shortcut Ctrl+Alt+Shift+ (a digit, or empty for none): 1 <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)">apply</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="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>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-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> Yearly13+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">9</span><span style="background:var(--vt-palette-6)"> </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)">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/macro-trust-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">MENU</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)">A8</span><span style="background:var(--vt-palette-7)"> </span>3+<span style="color:var(--vt-palette-3)">Trust</span> <span style="color:var(--vt-palette-3)">this</span> <span style="color:var(--vt-palette-3)">file&#39;s</span> <span style="color:var(--vt-palette-3)">macros?</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> Run <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> Cancel4+<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">A</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)">B</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-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>9+<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>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>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 145012+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">8</span><span style="background:var(--vt-palette-6)"> </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+They were made on another computer and can change the file. Trusting covers all of them from now…</pre>\ No newline at end of file
A e2e/testdata/screens/macro-trust.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">MENU</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)">A8</span><span style="background:var(--vt-palette-8)"> </span>3+<span style="color:var(--vt-palette-3)">Trust</span> <span style="color:var(--vt-palette-3)">this</span> <span style="color:var(--vt-palette-3)">file&#39;s</span> <span style="color:var(--vt-palette-3)">macros?</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> Run <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> Cancel4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>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-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>9+<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>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>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 145012+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">8</span><span style="background:var(--vt-palette-6)"> </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+They were made on another computer and can change the file. Trusting covers all of them from now…</pre>\ No newline at end of file
M e2e/testdata/screens/palette-narrow.html +11 -11
@@ -2,15 +2,15 @@
22 <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Search</span> <span style="font-weight:bold">the</span> <span style="font-weight:bold">menus</span> <span style="color:var(--vt-palette-8)">───────────────────────────────────────┐</span>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>5-<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>7-<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>8-<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)">…</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">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)">…</span> <span style="color:var(--vt-palette-8)">│</span>10-<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)">…</span> <span style="color:var(--vt-palette-8)">│</span>11-<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)">…</span> <span style="color:var(--vt-palette-8)">│</span>12-<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>13-<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)">…</span> <span style="color:var(--vt-palette-8)">│</span>14-<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)">26</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">117</span> <span style="color:var(--vt-palette-8)">─┘</span>5+<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-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="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>7+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Stop</span> <span style="color:var(--vt-palette-8)">and</span> <span style="color:var(--vt-palette-8)">save</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color:var(--vt-palette-8)">│</span>8+<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="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>9+<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)">│</span>10+<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)">│</span>11+<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)">│</span>12+<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)">│</span>13+<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)">│</span>14+<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)">│</span>15+<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">30</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">124</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 +16 -16
@@ -1,20 +1,20 @@
11 <pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</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> =S<span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Search</span> <span style="font-weight:bold">the</span> <span style="font-weight:bold">menus</span> <span style="color:var(--vt-palette-8)">──────────────────────────────────────────────────┐</span>3- <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> col <span style="color:var(--vt-palette-8)">│</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="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)">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> Househo<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">Col</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">umn</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">width</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Format</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6-<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> <span style="color:var(--vt-palette-8)">│</span> Delete <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">Edit</span> <span style="color:var(--vt-palette-8)">│</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 <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-4);font-weight:bold">co</span>lumns <span style="color:var(--vt-palette-4);font-weight:bold">l</span>eft <span style="color:var(--vt-palette-0)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>8-<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> Groceri<span style="color:var(--vt-palette-8)">│</span> Select <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Space</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>9-<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<span style="color:var(--vt-palette-8)">│</span> Freeze 1 <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umn <span style="color:var(--vt-palette-0)">View</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Freeze</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<span style="color:var(--vt-palette-8)">│</span> Unfreeze <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">View</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Freeze</span> <span style="color:var(--vt-palette-8)">│</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="color:var(--vt-palette-8)">│</span> Freeze 2 <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">View</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Freeze</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Filter</span> <span style="color:var(--vt-palette-8)">by</span> <span style="color:var(--vt-palette-8)">column</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">Alt+Down</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> Reset <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umn width <span style="color:var(--vt-palette-0)">Format</span> <span style="color:var(--vt-palette-8)">│</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> <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>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> <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>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> <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)">19</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">117</span> <span style="color:var(--vt-palette-8)">─┘</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><span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Search</span> <span style="font-weight:bold">the</span> <span style="font-weight:bold">menus</span> <span style="color:var(--vt-palette-8)">─────────────────────────────────────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> col <span style="color:var(--vt-palette-8)">│</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="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)">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> Hous<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">Col</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">umn</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">width</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Format</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6+<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> <span style="color:var(--vt-palette-8)">│</span> Delete <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">Edit</span> <span style="color:var(--vt-palette-8)">│</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<span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-4);font-weight:bold">co</span>lumns <span style="color:var(--vt-palette-4);font-weight:bold">l</span>eft <span style="color:var(--vt-palette-0)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>8+<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> Groc<span style="color:var(--vt-palette-8)">│</span> Select <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Space</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>9+<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> Tran<span style="color:var(--vt-palette-8)">│</span> Freeze 1 <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umn <span style="color:var(--vt-palette-0)">View</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Freeze</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> Savi<span style="color:var(--vt-palette-8)">│</span> Unfreeze <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">View</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Freeze</span> <span style="color:var(--vt-palette-8)">│</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> Tota<span style="color:var(--vt-palette-8)">│</span> Freeze 2 <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">View</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Freeze</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Filter</span> <span style="color:var(--vt-palette-8)">by</span> <span style="color:var(--vt-palette-8)">column</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">Alt+Down</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> Reset <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umn width <span style="color:var(--vt-palette-0)">Format</span> <span style="color:var(--vt-palette-8)">│</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> <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>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> <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>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> <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)">20</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">124</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 +16 -16
@@ -1,20 +1,20 @@
11 <pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</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> =S<span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Search</span> <span style="font-weight:bold">the</span> <span style="font-weight:bold">menus</span> <span style="color:var(--vt-palette-8)">──────────────────────────────────────────────────┐</span>3- <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> col <span style="color:var(--vt-palette-8)">│</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="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)">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> Househo<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">Col</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">umn</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">width</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Format</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6-<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> <span style="color:var(--vt-palette-8)">│</span> Delete <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">Edit</span> <span style="color: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 <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-3);font-weight:bold">co</span>lumns <span style="color:var(--vt-palette-3);font-weight:bold">l</span>eft <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>8-<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> Groceri<span style="color:var(--vt-palette-8)">│</span> Select <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Space</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>9-<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<span style="color:var(--vt-palette-8)">│</span> Freeze 1 <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umn <span style="color:var(--vt-palette-8)">View</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Freeze</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<span style="color:var(--vt-palette-8)">│</span> Unfreeze <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">View</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Freeze</span> <span style="color:var(--vt-palette-8)">│</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="color:var(--vt-palette-8)">│</span> Freeze 2 <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">View</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Freeze</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Filter</span> <span style="color:var(--vt-palette-8)">by</span> <span style="color:var(--vt-palette-8)">column</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">Alt+Down</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Reset <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umn width <span style="color:var(--vt-palette-8)">Format</span> <span style="color: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> <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>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> 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>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> 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)">19</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">117</span> <span style="color:var(--vt-palette-8)">─┘</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><span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Search</span> <span style="font-weight:bold">the</span> <span style="font-weight:bold">menus</span> <span style="color:var(--vt-palette-8)">─────────────────────────────────────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> col <span style="color:var(--vt-palette-8)">│</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="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)">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> Hous<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">Col</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">umn</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">width</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Format</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6+<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> <span style="color:var(--vt-palette-8)">│</span> Delete <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">Edit</span> <span style="color: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<span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-3);font-weight:bold">co</span>lumns <span style="color:var(--vt-palette-3);font-weight:bold">l</span>eft <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>8+<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> Groc<span style="color:var(--vt-palette-8)">│</span> Select <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Space</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>9+<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> Tran<span style="color:var(--vt-palette-8)">│</span> Freeze 1 <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umn <span style="color:var(--vt-palette-8)">View</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Freeze</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> Savi<span style="color:var(--vt-palette-8)">│</span> Unfreeze <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">View</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Freeze</span> <span style="color:var(--vt-palette-8)">│</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> Tota<span style="color:var(--vt-palette-8)">│</span> Freeze 2 <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">View</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Freeze</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Filter</span> <span style="color:var(--vt-palette-8)">by</span> <span style="color:var(--vt-palette-8)">column</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">Alt+Down</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Reset <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umn width <span style="color:var(--vt-palette-8)">Format</span> <span style="color: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> <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>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> 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>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> 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)">20</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">124</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 +16 -16
@@ -1,20 +1,20 @@
11 <pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</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) <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Search</span> <span style="font-weight:bold">the</span> <span style="font-weight:bold">menus</span> <span style="color:var(--vt-palette-8)">──────────────────────────────────────────────────┐</span>3- <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> sel <span style="color:var(--vt-palette-8)">│</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-8)">├─────────────────────────────────────────────────────────────────────┤</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-15);background:var(--vt-palette-8)">O</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-15);background:var(--vt-palette-8)">Q</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">R</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">S</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 2026 <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">Sel</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">ect</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">all</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Edit</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Ctrl+A</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6-<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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">Sel</span>ect rows <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+Space</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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 1450 <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>8-<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.4 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">Sel</span>ect columns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Space</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>9-<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 96 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Move</span> <span style="color:var(--vt-palette-8)">sheet</span> <span style="color:var(--vt-palette-8)">left</span> <span style="color:var(--vt-palette-8)">Edit</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Sheet</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> <span style="color:var(--vt-palette-8)">│</span> De<span style="color:var(--vt-palette-3);font-weight:bold">sel</span>ect <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)">│</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> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt columns <span style="color:var(--vt-palette-3);font-weight:bold">l</span>eft <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>av<span style="color:var(--vt-palette-3);font-weight:bold">e</span> <span style="color:var(--vt-palette-8)">Fi</span><span style="color:var(--vt-palette-3);font-weight:bold">l</span><span style="color: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)">Ctrl+S</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>av<span style="color:var(--vt-palette-3);font-weight:bold">e</span> as <span style="color:var(--vt-palette-8)">Fi</span><span style="color:var(--vt-palette-3);font-weight:bold">l</span><span style="color:var(--vt-palette-8)">e</span> <span style="color: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> <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>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> 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>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> 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)">22</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">117</span> <span style="color:var(--vt-palette-8)">─┘</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) <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Search</span> <span style="font-weight:bold">the</span> <span style="font-weight:bold">menus</span> <span style="color:var(--vt-palette-8)">─────────────────────────────────────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> sel <span style="color:var(--vt-palette-8)">│</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="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)">N</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-15);background:var(--vt-palette-8)">P</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Q</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">R</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">S</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 2026 <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">Sel</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">ect</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">all</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Edit</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Ctrl+A</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6+<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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">Sel</span>ect rows <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+Space</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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 1450 <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>8+<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.4 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">Sel</span>ect columns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Space</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>9+<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 96 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Move</span> <span style="color:var(--vt-palette-8)">sheet</span> <span style="color:var(--vt-palette-8)">left</span> <span style="color:var(--vt-palette-8)">Edit</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Sheet</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> <span style="color:var(--vt-palette-8)">│</span> De<span style="color:var(--vt-palette-3);font-weight:bold">sel</span>ect <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)">│</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> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt columns <span style="color:var(--vt-palette-3);font-weight:bold">l</span>eft <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>av<span style="color:var(--vt-palette-3);font-weight:bold">e</span> <span style="color:var(--vt-palette-8)">Fi</span><span style="color:var(--vt-palette-3);font-weight:bold">l</span><span style="color: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)">Ctrl+S</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>av<span style="color:var(--vt-palette-3);font-weight:bold">e</span> as <span style="color:var(--vt-palette-8)">Fi</span><span style="color:var(--vt-palette-3);font-weight:bold">l</span><span style="color:var(--vt-palette-8)">e</span> <span style="color: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> <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>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> 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>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> 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)">22</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">124</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 +16 -16
@@ -1,20 +1,20 @@
11 <pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</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> =S<span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Search</span> <span style="font-weight:bold">the</span> <span style="font-weight:bold">menus</span> <span style="color:var(--vt-palette-8)">──────────────────────────────────────────────────┐</span>3- <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> <span style="color:var(--vt-palette-8)">Type</span> <span style="color:var(--vt-palette-8)">a</span> <span style="color:var(--vt-palette-8)">command,</span> <span style="color:var(--vt-palette-8)">e.g.</span> <span style="color:var(--vt-palette-8)">save</span> <span style="color:var(--vt-palette-8)">or</span> <span style="color:var(--vt-palette-8)">width</span> <span style="color:var(--vt-palette-8)">│</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="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)">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> Househo<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)">New</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="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> <span style="color:var(--vt-palette-8)">│</span> Open <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+O</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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 <span style="color:var(--vt-palette-8)">│</span> Import <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">│</span>8-<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> Groceri<span style="color:var(--vt-palette-8)">│</span> Save <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>9-<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<span style="color:var(--vt-palette-8)">│</span> Save as <span style="color:var(--vt-palette-8)">File</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<span style="color:var(--vt-palette-8)">│</span> Download as CSV <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>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="color:var(--vt-palette-8)">│</span> Download as TSV <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>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> <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>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> <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>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> <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> 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> Theme <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>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)">117</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">117</span> <span style="color:var(--vt-palette-8)">─┘</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><span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Search</span> <span style="font-weight:bold">the</span> <span style="font-weight:bold">menus</span> <span style="color:var(--vt-palette-8)">─────────────────────────────────────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> <span style="color:var(--vt-palette-8)">Type</span> <span style="color:var(--vt-palette-8)">a</span> <span style="color:var(--vt-palette-8)">command,</span> <span style="color:var(--vt-palette-8)">e.g.</span> <span style="color:var(--vt-palette-8)">save</span> <span style="color:var(--vt-palette-8)">or</span> <span style="color:var(--vt-palette-8)">width</span> <span style="color:var(--vt-palette-8)">│</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="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)">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> Hous<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)">New</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="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> <span style="color:var(--vt-palette-8)">│</span> Open <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+O</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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<span style="color:var(--vt-palette-8)">│</span> Import <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">│</span>8+<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> Groc<span style="color:var(--vt-palette-8)">│</span> Save <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>9+<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> Tran<span style="color:var(--vt-palette-8)">│</span> Save as <span style="color:var(--vt-palette-8)">File</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> Savi<span style="color:var(--vt-palette-8)">│</span> Download as CSV <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>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> Tota<span style="color:var(--vt-palette-8)">│</span> Download as TSV <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>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> <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>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> <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>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> <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> 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> Theme <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>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)">124</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">124</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 go.mod +2 -1
M go.sum +4 -2
@@ -120,6 +120,8 @@ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBL
120120 github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=121121 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=122122 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=123+go.starlark.net v0.0.0-20260908191801-89a6a09411d5 h1:X8HyonnLxrmAbdeMIEGEJVZ/yg6WykLZyAZmpCLSfMA=124+go.starlark.net v0.0.0-20260908191801-89a6a09411d5/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo=123125 golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=124126 golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=125127 golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU=
@@ -140,8 +142,8 @@ golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
140142 golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=141143 golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=142144 golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=143-google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=144-google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=145+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=146+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=145147 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=146148 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=147149 modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
A internal/macro/builtins.go +420 -0
@@ -0,0 +1,420 @@
1+package macro2+3+import (4+ "fmt"5+ "math"6+ "slices"7+ "strconv"8+ "strings"9+10+ "go.starlark.net/starlark"11+12+ "github.com/FelineStateMachine/012/internal/formula"13+)14+15+// The functions scripts call. Each checks its arguments, then makes one16+// call to the Host through Env.Do. docs/macros.md documents them; keep17+// the two in step.18+19+type builtinFn func(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error)20+21+func (r *Run) builtins() starlark.StringDict {22+ fns := map[string]builtinFn{23+ "get": r.get, "get_formula": r.getFormula, "set": r.set, "set_formula": r.setFormula,24+ "clear": r.clear, "number_format": r.numberFormat, "get_number_format": r.getNumberFormat,25+ "selection": r.selection, "active_cell": r.activeCell, "select": r.selectRange,26+ "move": r.move, "extend": r.extend, "jump": r.jump, "enter": r.enter, "paste_text": r.pasteText,27+ "sheets": r.sheets, "active_sheet": r.activeSheet, "activate_sheet": r.activateSheet,28+ "add_sheet": r.addSheet, "move_sheet": r.moveSheet,29+ "run": r.run, "set_width": r.setWidth, "fill": r.fill, "offset": offset,30+ }31+ out := make(starlark.StringDict, len(fns))32+ for name, fn := range fns {33+ out[name] = starlark.NewBuiltin(name, func(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {34+ return fn(args, kwargs)35+ })36+ }37+ return out38+}39+40+// host makes one call to the Host where it may be used.41+func (r *Run) host(fn func(h Host) error) error {42+ r.calls++43+ var err error44+ r.env.Do(func() { err = fn(r.env.Host) })45+ return err46+}47+48+// unpack reads arguments for the function whose name leads pairs'49+// error messages; see starlark.UnpackArgs.50+func unpack(fn string, args starlark.Tuple, kwargs []starlark.Tuple, pairs ...any) error {51+ return starlark.UnpackArgs(fn, args, kwargs, pairs...)52+}53+54+func (r *Run) get(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {55+ var ref string56+ if err := unpack("get", args, kwargs, "ref", &ref); err != nil {57+ return nil, err58+ }59+ var rows [][]any60+ if err := r.host(func(h Host) (err error) { rows, err = h.Values(ref); return }); err != nil {61+ return nil, err62+ }63+ return shape(ref, rows, toValue), nil64+}65+66+func (r *Run) getFormula(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {67+ var ref string68+ if err := unpack("get_formula", args, kwargs, "ref", &ref); err != nil {69+ return nil, err70+ }71+ var rows [][]string72+ if err := r.host(func(h Host) (err error) { rows, err = h.Formulas(ref); return }); err != nil {73+ return nil, err74+ }75+ return shape(ref, rows, func(s string) starlark.Value { return starlark.String(s) }), nil76+}77+78+// shape returns one value for a single cell, or a list of rows.79+func shape[T any](ref string, rows [][]T, conv func(T) starlark.Value) starlark.Value {80+ if len(rows) == 1 && len(rows[0]) == 1 && !strings.Contains(ref, ":") {81+ return conv(rows[0][0])82+ }83+ out := make([]starlark.Value, len(rows))84+ for i, row := range rows {85+ vals := make([]starlark.Value, len(row))86+ for j, v := range row {87+ vals[j] = conv(v)88+ }89+ out[i] = starlark.NewList(vals)90+ }91+ return starlark.NewList(out)92+}93+94+// toValue converts a cell's value: whole numbers become ints, as they95+// read in a sheet.96+func toValue(v any) starlark.Value {97+ switch v := v.(type) {98+ case float64:99+ if v == math.Trunc(v) && math.Abs(v) < 1<<53 {100+ return starlark.MakeInt64(int64(v))101+ }102+ return starlark.Float(v)103+ case string:104+ return starlark.String(v)105+ case bool:106+ return starlark.Bool(v)107+ }108+ return starlark.None109+}110+111+// input is a value as it would be typed into a cell.112+func input(fn string, v starlark.Value) (string, error) {113+ switch v := v.(type) {114+ case starlark.NoneType:115+ return "", nil116+ case starlark.String:117+ return string(v), nil118+ case starlark.Bool:119+ if v {120+ return "TRUE", nil121+ }122+ return "FALSE", nil123+ case starlark.Int:124+ return v.String(), nil125+ case starlark.Float:126+ f := float64(v)127+ if math.IsInf(f, 0) || math.IsNaN(f) {128+ return "", fmt.Errorf("%s: %v can't go in a cell", fn, v)129+ }130+ return strconv.FormatFloat(f, 'g', -1, 64), nil131+ }132+ return "", fmt.Errorf("%s: a cell takes a number, text, True, False or None, not %s", fn, v.Type())133+}134+135+func (r *Run) set(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {136+ var ref string137+ var v starlark.Value138+ if err := unpack("set", args, kwargs, "ref", &ref, "value", &v); err != nil {139+ return nil, err140+ }141+ if list, ok := v.(*starlark.List); ok {142+ rows, err := inputRows(list)143+ if err != nil {144+ return nil, err145+ }146+ return starlark.None, r.host(func(h Host) error { return h.SetInputs(ref, rows) })147+ }148+ in, err := input("set", v)149+ if err != nil {150+ return nil, err151+ }152+ return starlark.None, r.host(func(h Host) error { return h.SetInput(ref, in) })153+}154+155+// inputRows reads a list of rows, each a list of values.156+func inputRows(list *starlark.List) ([][]string, error) {157+ rows := make([][]string, list.Len())158+ for i := range list.Len() {159+ row, ok := list.Index(i).(*starlark.List)160+ if !ok {161+ return nil, fmt.Errorf("set: a range takes a list of rows, e.g. [[1, 2], [3, 4]]")162+ }163+ for j := range row.Len() {164+ in, err := input("set", row.Index(j))165+ if err != nil {166+ return nil, err167+ }168+ rows[i] = append(rows[i], in)169+ }170+ }171+ return rows, nil172+}173+174+func (r *Run) setFormula(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {175+ var ref, f string176+ if err := unpack("set_formula", args, kwargs, "ref", &ref, "formula", &f); err != nil {177+ return nil, err178+ }179+ if !strings.HasPrefix(f, "=") {180+ f = "=" + f181+ }182+ return starlark.None, r.host(func(h Host) error { return h.SetFormula(ref, f) })183+}184+185+func (r *Run) clear(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {186+ var ref string187+ if err := unpack("clear", args, kwargs, "ref?", &ref); err != nil {188+ return nil, err189+ }190+ return starlark.None, r.host(func(h Host) error { return h.Clear(ref) })191+}192+193+func (r *Run) numberFormat(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {194+ var ref, kind, pattern string195+ var decimals starlark.Value = starlark.None196+ if err := unpack("number_format", args, kwargs, "ref", &ref, "kind", &kind, "decimals?", &decimals, "pattern?", &pattern); err != nil {197+ return nil, err198+ }199+ d := -1200+ if decimals != starlark.None {201+ var err error202+ if d, err = starlark.AsInt32(decimals); err != nil || d < 0 {203+ return nil, fmt.Errorf("number_format: decimals is a whole number from 0, not %s", decimals)204+ }205+ }206+ return starlark.None, r.host(func(h Host) error { return h.SetNumberFormat(ref, kind, d, pattern) })207+}208+209+func (r *Run) getNumberFormat(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {210+ var ref, kind string211+ if err := unpack("get_number_format", args, kwargs, "ref", &ref); err != nil {212+ return nil, err213+ }214+ err := r.host(func(h Host) (err error) { kind, _, err = h.NumberFormat(ref); return })215+ return starlark.String(kind), err216+}217+218+func (r *Run) selection(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {219+ if err := unpack("selection", args, kwargs); err != nil {220+ return nil, err221+ }222+ var sel string223+ r.host(func(h Host) error { sel, _ = h.Selection(); return nil })224+ return starlark.String(sel), nil225+}226+227+func (r *Run) activeCell(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {228+ if err := unpack("active_cell", args, kwargs); err != nil {229+ return nil, err230+ }231+ var active string232+ r.host(func(h Host) error { _, active = h.Selection(); return nil })233+ return starlark.String(active), nil234+}235+236+func (r *Run) selectRange(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {237+ var ref, active string238+ if err := unpack("select", args, kwargs, "ref", &ref, "active?", &active); err != nil {239+ return nil, err240+ }241+ return starlark.None, r.host(func(h Host) error { return h.Select(ref, active) })242+}243+244+func (r *Run) move(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {245+ var cols, rows int246+ if err := unpack("move", args, kwargs, "cols?", &cols, "rows?", &rows); err != nil {247+ return nil, err248+ }249+ return starlark.None, r.host(func(h Host) error { return h.Move(cols, rows) })250+}251+252+func (r *Run) extend(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {253+ var cols, rows int254+ var whole string255+ if err := unpack("extend", args, kwargs, "cols?", &cols, "rows?", &rows, "whole?", &whole); err != nil {256+ return nil, err257+ }258+ if whole != "" && whole != "columns" && whole != "rows" {259+ return nil, fmt.Errorf("extend: whole is \"columns\" or \"rows\", not %q", whole)260+ }261+ return starlark.None, r.host(func(h Host) error { return h.Extend(cols, rows, whole) })262+}263+264+func (r *Run) jump(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {265+ var to string266+ var ext bool267+ if err := unpack("jump", args, kwargs, "to", &to, "extend?", &ext); err != nil {268+ return nil, err269+ }270+ if !slices.Contains(JumpTargets, to) {271+ return nil, fmt.Errorf("jump: to is one of %s, not %q", strings.Join(JumpTargets, ", "), to)272+ }273+ return starlark.None, r.host(func(h Host) error { return h.Jump(to, ext) })274+}275+276+func (r *Run) enter(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {277+ var v starlark.Value278+ var fill bool279+ var origin string280+ if err := unpack("enter", args, kwargs, "text", &v, "fill?", &fill, "origin?", &origin); err != nil {281+ return nil, err282+ }283+ text, err := input("enter", v)284+ if err != nil {285+ return nil, err286+ }287+ return starlark.None, r.host(func(h Host) error { return h.Enter(text, fill, origin) })288+}289+290+func (r *Run) pasteText(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {291+ var text string292+ if err := unpack("paste_text", args, kwargs, "text", &text); err != nil {293+ return nil, err294+ }295+ return starlark.None, r.host(func(h Host) error { return h.PasteText(text) })296+}297+298+func (r *Run) sheets(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {299+ if err := unpack("sheets", args, kwargs); err != nil {300+ return nil, err301+ }302+ var names []string303+ r.host(func(h Host) error { names = h.Sheets(); return nil })304+ vals := make([]starlark.Value, len(names))305+ for i, n := range names {306+ vals[i] = starlark.String(n)307+ }308+ return starlark.NewList(vals), nil309+}310+311+func (r *Run) activeSheet(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {312+ if err := unpack("active_sheet", args, kwargs); err != nil {313+ return nil, err314+ }315+ var name string316+ r.host(func(h Host) error { name = h.ActiveSheet(); return nil })317+ return starlark.String(name), nil318+}319+320+func (r *Run) activateSheet(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {321+ var name string322+ if err := unpack("activate_sheet", args, kwargs, "name", &name); err != nil {323+ return nil, err324+ }325+ return starlark.None, r.host(func(h Host) error { return h.ActivateSheet(name) })326+}327+328+func (r *Run) addSheet(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {329+ var name string330+ if err := unpack("add_sheet", args, kwargs, "name?", &name); err != nil {331+ return nil, err332+ }333+ var got string334+ err := r.host(func(h Host) (err error) { got, err = h.AddSheet(name); return })335+ return starlark.String(got), err336+}337+338+func (r *Run) moveSheet(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {339+ var pos int340+ if err := unpack("move_sheet", args, kwargs, "position", &pos); err != nil {341+ return nil, err342+ }343+ return starlark.None, r.host(func(h Host) error { return h.MoveSheet(pos) })344+}345+346+func (r *Run) run(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {347+ var id string348+ var answer starlark.Value = starlark.None349+ if err := unpack("run", args, kwargs, "id", &id, "answer?", &answer); err != nil {350+ return nil, err351+ }352+ var a *string353+ if answer != starlark.None {354+ text, err := input("run", answer)355+ if err != nil {356+ return nil, err357+ }358+ a = &text359+ }360+ return starlark.None, r.host(func(h Host) error { return h.Run(id, a) })361+}362+363+func (r *Run) setWidth(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {364+ var cols string365+ var width int366+ if err := unpack("set_width", args, kwargs, "cols", &cols, "width", &width); err != nil {367+ return nil, err368+ }369+ return starlark.None, r.host(func(h Host) error { return h.SetWidth(cols, width) })370+}371+372+func (r *Run) fill(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {373+ var to string374+ var rows, cols int375+ if err := unpack("fill", args, kwargs, "to?", &to, "rows?", &rows, "cols?", &cols); err != nil {376+ return nil, err377+ }378+ if (to != "") == (rows != 0 || cols != 0) || rows != 0 && cols != 0 {379+ return nil, fmt.Errorf("fill: give either to, or one of rows and cols")380+ }381+ return starlark.None, r.host(func(h Host) error { return h.Fill(to, rows, cols) })382+}383+384+// offset is ref moved by cols and rows, keeping its sheet and size; it385+// doesn't need the spreadsheet.386+func offset(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {387+ var ref string388+ var cols, rows int389+ if err := unpack("offset", args, kwargs, "ref", &ref, "cols?", &cols, "rows?", &rows); err != nil {390+ return nil, err391+ }392+ name, rest := formula.SplitSheet(ref)393+ rng, ok := formula.ParseRange(rest)394+ if !ok {395+ return nil, fmt.Errorf("offset: not a cell or range: %s", ref)396+ }397+ rng.From.Col, rng.To.Col = rng.From.Col+cols, rng.To.Col+cols398+ rng.From.Row, rng.To.Row = rng.From.Row+rows, rng.To.Row+rows399+ if !rng.From.Valid() || !rng.To.Valid() {400+ return nil, fmt.Errorf("offset: %s moved by %d, %d is off the sheet", ref, cols, rows)401+ }402+ out := rng.String()403+ if rng.From == rng.To && !strings.Contains(rest, ":") {404+ out = rng.From.String()405+ }406+ if name != "" {407+ out = formula.QuoteSheet(name) + "!" + out408+ }409+ return starlark.String(out), nil410+}411+412+// Functions lists the names of the functions scripts can call, sorted.413+func Functions() []string {414+ names := make([]string, 0, len(predeclared))415+ for n := range predeclared {416+ names = append(names, n)417+ }418+ slices.Sort(names)419+ return names420+}
A internal/macro/macro.go +171 -0
@@ -0,0 +1,171 @@
1+// Package macro runs macros: Starlark scripts that act on a spreadsheet2+// through a small API, as Google Sheets macros are Apps Script. A3+// recorded macro is a list of Actions written out as a script (Source),4+// so recorded and hand-written macros run the same way.5+//6+// Scripts can't reach the file system, the network, the clock or random7+// numbers: the only way out is the Host. Every run has a step limit and8+// can be cancelled from another goroutine. The package knows nothing of9+// the terminal or the engine; internal/ui implements Host.10+package macro11+12+import (13+ "fmt"14+ "strconv"15+ "strings"16+17+ "go.starlark.net/syntax"18+)19+20+// Host is the spreadsheet a script acts on. References are A1 strings,21+// optionally with a sheet ("Sheet2!B3:C9"); a range is a cell or a22+// rectangle. Its methods are only called inside Env.Do.23+type Host interface {24+ // Values returns the computed values of ref, row by row: nil for a25+ // blank, float64, string, bool, or an error value's code as a string26+ // ("#DIV/0!").27+ Values(ref string) ([][]any, error)28+ // Formulas returns what was typed in each cell of ref, row by row,29+ // when it's a formula, and "" otherwise.30+ Formulas(ref string) ([][]string, error)31+ // SetInput enters input, as if typed, in every cell of ref.32+ SetInput(ref, input string) error33+ // SetInputs enters a block of inputs, as if typed, from ref's first34+ // cell.35+ SetInputs(ref string, rows [][]string) error36+ // SetFormula enters formula in ref's first cell and fills it over the37+ // rest, adjusting relative references as a copy does.38+ SetFormula(ref, formula string) error39+ // Clear erases the contents of ref, or of the selection when ref is "".40+ Clear(ref string) error41+ // NumberFormat is the number format of ref's first cell: its kind42+ // ("auto", "number", "currency"...) and decimals.43+ NumberFormat(ref string) (kind string, decimals int, err error)44+ // SetNumberFormat formats ref; decimals < 0 keeps the kind's default,45+ // and pattern is for the "custom" kind.46+ SetNumberFormat(ref, kind string, decimals int, pattern string) error47+48+ // Selection is the selected range and the active cell in it.49+ Selection() (sel, active string)50+ // Select selects ref, switching sheets if it names one, with active51+ // as the active cell ("" for ref's first cell).52+ Select(ref, active string) error53+ // Move moves the active cell by cols and rows and drops the54+ // selection.55+ Move(cols, rows int) error56+ // Extend selects from the active cell to cols and rows away from it;57+ // whole is "", "columns" or "rows".58+ Extend(cols, rows int, whole string) error59+ // Jump moves as Ctrl+arrows, Home, Ctrl+Home or Ctrl+End do (see60+ // JumpTargets), extending the selection when extend is set.61+ Jump(to string, extend bool) error62+ // Enter stores text in the active cell as if typed and accepted, or63+ // in every selected cell, references adjusted, when fill is set. With64+ // an origin, a formula is taken as typed there and moved to the65+ // active cell, relative references following, as in a copy.66+ Enter(text string, fill bool, origin string) error67+ // PasteText pastes tab-separated text at the active cell, as pasting68+ // from another program does.69+ PasteText(text string) error70+71+ // Sheets lists the sheet names in tab order.72+ Sheets() []string73+ // ActiveSheet names the sheet shown.74+ ActiveSheet() string75+ // ActivateSheet shows the named sheet.76+ ActivateSheet(name string) error77+ // AddSheet adds a sheet after the one shown and shows it, returning78+ // its name; name "" picks the next free one.79+ AddSheet(name string) (string, error)80+ // MoveSheet moves the sheet shown to position pos, counting from 1.81+ MoveSheet(pos int) error82+83+ // Run runs a registered command by id. A command that asks a84+ // question (a width, a name, a confirmation) gets answer, which is85+ // required then.86+ Run(id string, answer *string) error87+ // SetWidth sets the width of the columns cols, e.g. "B" or "B:D".88+ SetWidth(cols string, width int) error89+ // Fill fills from the selection as dragging the fill handle does: to90+ // the range to, or by rows (down, or up when negative) or cols.91+ Fill(to string, rows, cols int) error92+}93+94+// JumpTargets are the places Jump goes: the edge of the data in a95+// direction (Ctrl+arrows), the row's first column (Home), A1 (Ctrl+Home)96+// and the last used cell (Ctrl+End).97+var JumpTargets = []string{"up", "down", "left", "right", "home", "start", "end"}98+99+// Action is one step of a recording: a call of one of the script's100+// functions, or a comment when Func is "". It is plain data, so a log101+// can be kept, compared or saved before it becomes a script.102+type Action struct {103+ Func string `json:"func,omitempty"`104+ Args []any `json:"args,omitempty"` // string, int or bool105+ Named []Arg `json:"named,omitempty"`106+ Comment string `json:"comment,omitempty"`107+}108+109+// Arg is a named argument, e.g. fill=True.110+type Arg struct {111+ Name string `json:"name"`112+ Value any `json:"value"`113+}114+115+// Call is a shorthand for an Action calling fn with args.116+func Call(fn string, args ...any) Action { return Action{Func: fn, Args: args} }117+118+// Note is a comment in a recording.119+func Note(text string) Action { return Action{Comment: text} }120+121+// With adds a named argument.122+func (a Action) With(name string, v any) Action {123+ a.Named = append(a.Named, Arg{name, v})124+ return a125+}126+127+// String is the action as a line of Starlark.128+func (a Action) String() string {129+ if a.Func == "" {130+ return "# " + strings.ReplaceAll(a.Comment, "\n", " ")131+ }132+ parts := make([]string, 0, len(a.Args)+len(a.Named))133+ for _, v := range a.Args {134+ parts = append(parts, literal(v))135+ }136+ for _, n := range a.Named {137+ parts = append(parts, n.Name+"="+literal(n.Value))138+ }139+ return a.Func + "(" + strings.Join(parts, ", ") + ")"140+}141+142+// literal writes a Go value as a Starlark literal.143+func literal(v any) string {144+ switch v := v.(type) {145+ case string:146+ return syntax.Quote(v, false)147+ case int:148+ return strconv.Itoa(v)149+ case bool:150+ if v {151+ return "True"152+ }153+ return "False"154+ case nil:155+ return "None"156+ }157+ panic(fmt.Sprintf("macro: no literal for %T", v))158+}159+160+// Source writes a recording as a script, one action per line, under a161+// header comment.162+func Source(header string, actions []Action) string {163+ var b strings.Builder164+ for _, l := range strings.Split(header, "\n") {165+ b.WriteString(strings.TrimRight("# "+l, " ") + "\n")166+ }167+ for _, a := range actions {168+ b.WriteString(a.String() + "\n")169+ }170+ return b.String()171+}
A internal/macro/macro_test.go +213 -0
@@ -0,0 +1,213 @@
1+package macro2+3+import (4+ "errors"5+ "fmt"6+ "strings"7+ "testing"8+ "time"9+)10+11+// fakeHost records the calls it gets and answers from a few cells.12+type fakeHost struct {13+ calls []string14+ cells map[string]any15+}16+17+func (f *fakeHost) log(format string, args ...any) error {18+ f.calls = append(f.calls, fmt.Sprintf(format, args...))19+ return nil20+}21+22+func (f *fakeHost) Values(ref string) ([][]any, error) {23+ if ref == "bad" {24+ return nil, errors.New("not a cell or range: bad")25+ }26+ if strings.Contains(ref, ":") {27+ return [][]any{{f.cells["A1"], f.cells["B1"]}}, nil28+ }29+ return [][]any{{f.cells[ref]}}, nil30+}31+func (f *fakeHost) Formulas(ref string) ([][]string, error) { return [][]string{{"=1+1"}}, nil }32+func (f *fakeHost) SetInput(ref, in string) error {33+ f.cells[ref] = in34+ return f.log("set %s %q", ref, in)35+}36+func (f *fakeHost) SetInputs(ref string, rows [][]string) error { return f.log("set %s %q", ref, rows) }37+func (f *fakeHost) SetFormula(ref, fm string) error { return f.log("formula %s %s", ref, fm) }38+func (f *fakeHost) Clear(ref string) error { return f.log("clear %q", ref) }39+func (f *fakeHost) NumberFormat(string) (string, int, error) { return "currency", 2, nil }40+func (f *fakeHost) SetNumberFormat(ref, kind string, d int, p string) error {41+ return f.log("format %s %s %d %q", ref, kind, d, p)42+}43+func (f *fakeHost) Selection() (string, string) { return "B2:C3", "B2" }44+func (f *fakeHost) Select(ref, active string) error { return f.log("select %s %q", ref, active) }45+func (f *fakeHost) Move(c, r int) error { return f.log("move %d %d", c, r) }46+func (f *fakeHost) Extend(c, r int, whole string) error { return f.log("extend %d %d %q", c, r, whole) }47+func (f *fakeHost) Jump(to string, ext bool) error { return f.log("jump %s %v", to, ext) }48+func (f *fakeHost) Enter(text string, fill bool, origin string) error {49+ return f.log("enter %q %v %q", text, fill, origin)50+}51+func (f *fakeHost) PasteText(text string) error { return f.log("paste %q", text) }52+func (f *fakeHost) Sheets() []string { return []string{"Sheet1", "Two"} }53+func (f *fakeHost) ActiveSheet() string { return "Sheet1" }54+func (f *fakeHost) ActivateSheet(name string) error { return f.log("activate %s", name) }55+func (f *fakeHost) AddSheet(name string) (string, error) { return "Sheet3", f.log("add %q", name) }56+func (f *fakeHost) MoveSheet(pos int) error { return f.log("move sheet %d", pos) }57+func (f *fakeHost) Run(id string, answer *string) error {58+ if answer == nil {59+ return f.log("run %s", id)60+ }61+ return f.log("run %s %q", id, *answer)62+}63+func (f *fakeHost) SetWidth(cols string, w int) error { return f.log("width %s %d", cols, w) }64+func (f *fakeHost) Fill(to string, rows, cols int) error {65+ return f.log("fill %q %d %d", to, rows, cols)66+}67+68+func newFake() *fakeHost { return &fakeHost{cells: map[string]any{"A1": 2.0, "B1": "x", "C1": 2.5}} }69+70+func TestSourceRunsAsRecorded(t *testing.T) {71+ actions := []Action{72+ Call("select", "B2"),73+ Call("enter", "=SUM(A1:A3)"),74+ Call("move", 0, 1),75+ Call("extend", 2, 0).With("whole", "columns"),76+ Call("jump", "down").With("extend", true),77+ Call("enter", "total").With("fill", true),78+ Call("enter", "=A1").With("origin", "B2"),79+ Call("paste_text", "a\tb\n\"c\""),80+ Note("not recorded: sort A1:C9"),81+ Call("run", "column.width").With("answer", "12"),82+ Call("run", "format.bold"),83+ Call("activate_sheet", "Q3 plan"),84+ Call("set_width", "B:C", 14),85+ Call("fill").With("rows", 3),86+ Call("move_sheet", 2),87+ }88+ src := Source("Recorded with absolute references.\n\nSecond line", actions)89+ if !strings.HasPrefix(src, "# Recorded with absolute references.\n#\n# Second line\nselect(\"B2\")\n") {90+ t.Errorf("source:\n%s", src)91+ }92+ f := newFake()93+ if _, err := New("m", src, Env{Host: f}).Exec(); err != nil {94+ t.Fatalf("%v\n%s", err, src)95+ }96+ want := []string{97+ `select B2 ""`, `enter "=SUM(A1:A3)" false ""`, "move 0 1", `extend 2 0 "columns"`, "jump down true",98+ `enter "total" true ""`, `enter "=A1" false "B2"`, `paste "a\tb\n\"c\""`, `run column.width "12"`, "run format.bold",99+ "activate Q3 plan", "width B:C 14", `fill "" 3 0`, "move sheet 2",100+ }101+ if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {102+ t.Errorf("calls:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n"))103+ }104+}105+106+func TestScriptAPI(t *testing.T) {107+ src := `108+a = get("A1")109+b = get("B1")110+row = get("A1:B1")111+set("D1", a * 3)112+set("D2", get("C1") * 2)113+set("D3", True)114+set("D4", None)115+set("E1:F2", [[1, "two"], [3.5, False]])116+set_formula("G1:G9", "A1*2")117+set("H1", "%s %s %s" % (type(a), b, row))118+set("H2", get_formula("G1") + get_number_format("A1"))119+set("H3", selection() + " " + active_cell() + " " + active_sheet() + " " + ",".join(sheets()))120+set("H4", offset("A1", 2, 3) + " " + offset("'Q3 plan'!A1:B2", 1, 0))121+number_format("A1:A3", "percent", decimals=1)122+clear()123+x = add_sheet()124+print("done", x)125+`126+ f := newFake()127+ var printed []string128+ st, err := New("api", src, Env{Host: f, Print: func(s string) { printed = append(printed, s) }}).Exec()129+ if err != nil {130+ t.Fatal(err)131+ }132+ for ref, want := range map[string]string{133+ "D1": "6", "D2": "5", "D3": "TRUE", "D4": "", "H1": `int x [[2, "x"]]`, "H2": "=1+1currency",134+ "H3": "B2:C3 B2 Sheet1 Sheet1,Two", "H4": "C4 'Q3 plan'!B1:C2",135+ } {136+ if got := f.cells[ref]; got != want {137+ t.Errorf("%s = %q, want %q", ref, got, want)138+ }139+ }140+ for _, want := range []string{`set E1:F2 [["1" "two"] ["3.5" "FALSE"]]`, "formula G1:G9 =A1*2", `format A1:A3 percent 1 ""`, `clear ""`, `add ""`} {141+ if !strings.Contains(strings.Join(f.calls, "\n"), want) {142+ t.Errorf("no call %q in\n%s", want, strings.Join(f.calls, "\n"))143+ }144+ }145+ if len(printed) != 1 || printed[0] != "done Sheet3" {146+ t.Errorf("printed %q", printed)147+ }148+ if st.Calls < 15 || st.Steps == 0 {149+ t.Errorf("stats %+v", st)150+ }151+}152+153+func TestErrorsHavePositions(t *testing.T) {154+ for _, tc := range []struct {155+ src, want string156+ compiles bool157+ }{158+ {"move(0, 1)\nset(\"A1\"", "m:2:9: got end of file, want ')'", false},159+ {"x = 1\nnope(2)", "m:2:1: undefined: nope", false},160+ {"move(0, 1)\n get(\"bad\")", "m:2:3: got indent, want primary expression", false},161+ {"move()\nget(\"bad\")", "m:2:4: get: not a cell or range: bad", true},162+ {"def f():\n jump(\"sideways\")\nf()", "m:2:9: jump: to is one of up, down, left, right, home, start, end, not \"sideways\"", true},163+ {"set(\"A1\", {})", "m:1:4: set: a cell takes a number, text, True, False or None, not dict", true},164+ {"move(1, 2, 3)", "m:1:5: move: got 3 arguments, want at most 2", true},165+ } {166+ _, err := New("m", tc.src, Env{Host: newFake()}).Exec()167+ if err == nil || err.Error() != tc.want {168+ t.Errorf("%q: %v, want %q", tc.src, err, tc.want)169+ }170+ if err := Check("m", tc.src); (err == nil) != tc.compiles {171+ t.Errorf("Check(%q) = %v", tc.src, err)172+ }173+ }174+}175+176+func TestStepLimitStopsALoop(t *testing.T) {177+ _, err := New("loop", "while True:\n pass\n", Env{Host: newFake(), MaxSteps: 10_000}).Exec()178+ var me *Error179+ if !errors.As(err, &me) || !me.Limit || !strings.HasPrefix(err.Error(), "loop:1:1: stopped after 1000") {180+ t.Fatalf("err %v", err)181+ }182+}183+184+func TestCancelStopsARun(t *testing.T) {185+ r := New("spin", "while True:\n move(0, 0)\n", Env{Host: newFake(), MaxSteps: 1 << 62})186+ done := make(chan error)187+ go func() { _, err := r.Exec(); done <- err }()188+ time.Sleep(10 * time.Millisecond)189+ r.Cancel()190+ select {191+ case err := <-done:192+ var me *Error193+ if !errors.As(err, &me) || !me.Cancel || !strings.Contains(err.Error(), "stopped with Esc") {194+ t.Fatalf("err %v", err)195+ }196+ case <-time.After(5 * time.Second):197+ t.Fatal("cancel didn't stop the run")198+ }199+}200+201+func TestNoWayOut(t *testing.T) {202+ // Starlark has no file, network, time or random access of its own,203+ // and without a loader nothing else can be brought in.204+ for _, name := range []string{"open", "time", "random", "exec", "eval", "load_module", "__import__"} {205+ if err := Check("m", name+"()"); err == nil {206+ t.Errorf("%s is reachable", name)207+ }208+ }209+ _, err := New("m", `load("time.star", "now")`, Env{Host: newFake()}).Exec()210+ if err == nil {211+ t.Error("load works")212+ }213+}
A internal/macro/run.go +169 -0
@@ -0,0 +1,169 @@
1+package macro2+3+import (4+ "errors"5+ "fmt"6+ "strings"7+ "sync/atomic"8+9+ "go.starlark.net/resolve"10+ "go.starlark.net/starlark"11+ "go.starlark.net/syntax"12+)13+14+// DefaultMaxSteps bounds a run: enough for loops over tens of thousands15+// of cells, few enough that a script that never ends stops within a16+// second or so.17+const DefaultMaxSteps = 10_000_00018+19+// Env is where a run happens.20+type Env struct {21+ Host Host22+ // Do runs fn where Host may be used and returns once it has; nil23+ // runs it directly. The UI hands calls to its own goroutine here, so24+ // the script can run on another.25+ Do func(fn func())26+ // Print shows a line printed by the script; called inside Do.27+ Print func(msg string)28+ // MaxSteps bounds the run; 0 means DefaultMaxSteps.29+ MaxSteps uint6430+}31+32+// Stats describe a finished run.33+type Stats struct {34+ Steps uint64 // Starlark computation steps35+ Calls int // calls to the spreadsheet36+}37+38+// ErrCancelled is the reason a cancelled run gives.39+var ErrCancelled = errors.New("stopped")40+41+// Error is a script's failure at a position in it, e.g.42+// "Totals:3:5: set: not a cell or range: ZZ".43+type Error struct {44+ Pos string // name:line:column, or "" when not at a place in the script45+ Msg string46+ Cancel bool // the run was cancelled47+ Limit bool // the run hit its step limit48+}49+50+func (e *Error) Error() string {51+ if e.Pos == "" {52+ return e.Msg53+ }54+ return e.Pos + ": " + e.Msg55+}56+57+// options are the language options: top-level if, for and while, so a58+// recording can be a plain list of statements and scripts don't need a59+// main function, and reassigning globals, as in Python.60+var options = &syntax.FileOptions{Set: true, While: true, TopLevelControl: true, GlobalReassign: true}61+62+// Run is one run of a script.63+type Run struct {64+ name, src string65+ env Env66+ thread *starlark.Thread67+ calls int68+ cancelled atomic.Bool69+}70+71+// New prepares a run of src, named name in error positions.72+func New(name, src string, env Env) *Run {73+ if env.Do == nil {74+ env.Do = func(fn func()) { fn() }75+ }76+ if env.Print == nil {77+ env.Print = func(string) {}78+ }79+ r := &Run{name: name, src: src, env: env}80+ r.thread = &starlark.Thread{Name: name}81+ r.thread.Print = func(_ *starlark.Thread, msg string) { r.env.Do(func() { r.env.Print(msg) }) }82+ // No Load: scripts can't load modules, so nothing but the builtins83+ // below is reachable.84+ max := env.MaxSteps85+ if max == 0 {86+ max = DefaultMaxSteps87+ }88+ r.thread.SetMaxExecutionSteps(max)89+ return r90+}91+92+// Cancel stops the run as soon as the script next takes a step. It is93+// safe to call from any goroutine.94+func (r *Run) Cancel() {95+ r.cancelled.Store(true)96+ r.thread.Cancel(ErrCancelled.Error())97+}98+99+// Exec runs the script to its end on the calling goroutine. Host calls100+// go through Env.Do.101+func (r *Run) Exec() (Stats, error) {102+ _, err := starlark.ExecFileOptions(options, r.thread, r.name, r.src, r.builtins())103+ st := Stats{Steps: r.thread.ExecutionSteps(), Calls: r.calls}104+ return st, r.explain(err)105+}106+107+// Check parses and resolves src without running it, reporting the first108+// problem with its position.109+func Check(name, src string) error {110+ _, _, err := starlark.SourceProgramOptions(options, name, src, func(n string) bool { return predeclared[n] })111+ return (&Run{name: name}).explain(err)112+}113+114+// predeclared are the names scripts see besides Starlark's own.115+var predeclared = func() map[string]bool {116+ out := map[string]bool{}117+ for n := range (&Run{}).builtins() {118+ out[n] = true119+ }120+ return out121+}()122+123+// explain turns an error from Starlark into an Error with the script124+// position it happened at.125+func (r *Run) explain(err error) error {126+ if err == nil {127+ return nil128+ }129+ var se syntax.Error130+ var re resolve.ErrorList131+ var ee *starlark.EvalError132+ switch {133+ case errors.As(err, &se):134+ return &Error{Pos: position(se.Pos), Msg: se.Msg}135+ case errors.As(err, &re):136+ return &Error{Pos: position(re[0].Pos), Msg: re[0].Msg}137+ case errors.As(err, &ee):138+ return r.evalError(ee)139+ }140+ return &Error{Msg: err.Error()}141+}142+143+// evalError places an evaluation error at the innermost line of the144+// script, naming the function that failed when it's one of ours.145+func (r *Run) evalError(ee *starlark.EvalError) *Error {146+ e := &Error{Msg: ee.Msg}147+ stack := ee.CallStack148+ if n := len(stack); n > 0 && stack[n-1].Pos.Filename() == "<builtin>" {149+ if name := stack[n-1].Name; !strings.HasPrefix(e.Msg, name+":") {150+ e.Msg = name + ": " + e.Msg151+ }152+ stack = stack[:n-1]153+ }154+ if n := len(stack); n > 0 {155+ e.Pos = position(stack[n-1].Pos)156+ }157+ switch {158+ case r.cancelled.Load() && strings.Contains(ee.Msg, ErrCancelled.Error()):159+ e.Msg, e.Cancel = "stopped with Esc", true160+ case strings.Contains(ee.Msg, "too many steps"):161+ e.Msg = fmt.Sprintf("stopped after %d steps: does it loop forever?", r.thread.ExecutionSteps())162+ e.Limit = true163+ }164+ return e165+}166+167+func position(p syntax.Position) string {168+ return fmt.Sprintf("%s:%d:%d", p.Filename(), p.Line, p.Col)169+}
M internal/sheet/file.go +17 -3
@@ -48,9 +48,13 @@ type fileFormat struct {
4848 // Arithmetic is "decimal" for decimal arithmetic, which needs no4949 // version bump: earlier builds ignore it and compute in binary, as5050 // Sheets would. It is for the whole workbook, so it stays at the top.51- Arithmetic string `json:"arithmetic,omitempty"`52- fileSheet // versions 1 to 3: the only sheet53- Sheets []fileSheet `json:"sheets,omitempty"` // version 451+ Arithmetic string `json:"arithmetic,omitempty"`52+ // Macros and where they were made need no version bump either:53+ // earlier builds ignore them, and the sheets read the same.54+ MacroOrigin string `json:"macroOrigin,omitempty"`55+ Macros []fileMacro `json:"macros,omitempty"`56+ fileSheet // versions 1 to 3: the only sheet57+ Sheets []fileSheet `json:"sheets,omitempty"` // version 45458 }5559 5660 // fileSheet is one sheet of a file.
@@ -240,6 +244,12 @@ func (w *Workbook) headLines() string {
240244 if w.decimal {241245 lines = append(lines, `"arithmetic": "decimal"`)242246 }247+ if len(w.macros) > 0 {248+ if w.macroOrigin != "" {249+ lines = append(lines, `"macroOrigin": `+jsonString(w.macroOrigin))250+ }251+ lines = append(lines, w.macrosLines())252+ }243253 return strings.Join(lines, ",\n ")244254 }245255
@@ -385,6 +395,10 @@ func ReadBook(r io.Reader) (*Workbook, error) {
385395 // Anything but "decimal" (say, a mode from a later build) computes in386396 // binary, as the file would in a build without the setting.387397 w.decimal = f.Arithmetic == "decimal"398+ if err := w.readMacros(f.Macros); err != nil {399+ return nil, err400+ }401+ w.macroOrigin = f.MacroOrigin388402 w.RecalcAll()389403 return w, nil390404 }
M internal/sheet/history.go +52 -63
@@ -1,11 +1,6 @@
11 package sheet22 3-import (4- "maps"5- "slices"6-7- "github.com/FelineStateMachine/012/internal/formula"8-)3+import "slices"94 105 // MaxUndo is how many steps of undo history a workbook keeps, at most;116 // MaxUndoBytes also bounds it.
@@ -56,6 +51,8 @@ type step struct {
5651 sheets *sheetList5752 // decimal is the arithmetic setting before the step, when it changed.5853 decimal *bool54+ // macros is the macro list before the step, when it changed.55+ macros *[]Macro5956 }6057 6158 // colKey is a column of a sheet.
@@ -64,12 +61,6 @@ type colKey struct {
6461 col int6562 }6663 67-// sheetList is the order and names of the sheets.68-type sheetList struct {69- order []*Sheet70- names map[*Sheet]string71-}72-7364 func newStep(label string, s *Sheet, focus Rect) *step {7465 return &step{label: label, sheet: s, focus: focus, cells: map[loc]*Cell{}, widths: map[colKey]int{},7566 names: map[string]*Name{}, views: map[*Sheet]*viewState{}, charts: map[*Sheet][]Chart{}, pivots: map[*Sheet]*Pivot{}}
@@ -81,7 +72,8 @@ func (st *step) empty() bool {
8172 8273 // widthOnly reports whether the step changed nothing but column widths.8374 func (st *step) widthOnly() bool {84- return len(st.cells) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && st.sheets == nil && st.decimal == nil75+ return len(st.cells) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && st.sheets == nil && st.decimal == nil &&76+ st.macros == nil8577 }8678 8779 func (c *Cell) clone() *Cell {
@@ -102,6 +94,9 @@ type Change struct {
10294 // Tabs is set when the step added, deleted, renamed or moved sheets;10395 // Focus means nothing then.10496 Tabs bool97+ // Macros is set when the step changed nothing but the macros; Focus98+ // means nothing then either.99+ Macros bool105100 }106101 107102 // Batch runs fn as a single undo step: everything it changes is undone
@@ -121,28 +116,38 @@ func (s *Sheet) change(label string, focus Rect, fn func()) {
121116 // change opens a step (unless one is open), runs fn, and on the way out122117 // of the outermost call recalculates what changed and pushes the step.123118 func (w *Workbook) change(s *Sheet, label string, focus Rect, fn func()) {119+ w.begin(s, label, focus)120+ defer w.finish()121+ fn()122+}123+124+// begin opens a step unless one is open, and nests into it.125+func (w *Workbook) begin(s *Sheet, label string, focus Rect) {124126 h := &w.hist125127 if h.depth == 0 {126128 h.open = newStep(label, s, focus)127129 w.structural = false128130 }129131 h.depth++130- defer func() {131- h.depth--132- if h.depth > 0 {133- return134- }135- st, dirty := h.open, h.dirty136- h.open, h.dirty = nil, nil137- if w.structural {138- w.structural = false139- w.recalcAll()140- } else {141- w.recalc(dirty)142- }143- w.push(st)144- }()145- fn()132+}133+134+// finish leaves a nesting level; the outermost recalculates what changed135+// and pushes the step.136+func (w *Workbook) finish() {137+ h := &w.hist138+ h.depth--139+ if h.depth > 0 {140+ return141+ }142+ st, dirty := h.open, h.dirty143+ h.open, h.dirty = nil, nil144+ if w.structural {145+ w.structural = false146+ w.recalcAll()147+ } else {148+ w.recalc(dirty)149+ }150+ w.push(st)146151 }147152 148153 // record saves the cell at a before its first change in the open step.
@@ -211,39 +216,6 @@ func (w *Workbook) recordDecimal() {
211216 }212217 }213218 214-func (w *Workbook) sheetList() *sheetList {215- l := &sheetList{order: slices.Clone(w.sheets), names: map[*Sheet]string{}}216- for _, s := range w.sheets {217- l.names[s] = s.name218- }219- return l220-}221-222-// setSheets restores a sheet list: sheets not in it are detached, the223-// rest renamed and attached in its order.224-func (w *Workbook) setSheets(l *sheetList) {225- for _, s := range w.sheets {226- if !slices.Contains(l.order, s) {227- w.detach(s)228- }229- }230- w.byKey = map[string]*Sheet{}231- w.sheets = slices.Clone(l.order)232- for _, s := range w.sheets {233- s.name = l.names[s]234- if s.live {235- w.byKey[formula.SheetKey(s.name)] = s236- } else {237- w.attach(s)238- }239- }240- w.structural = true241-}242-243-func (l *sheetList) equal(m *sheetList) bool {244- return slices.Equal(l.order, m.order) && maps.Equal(l.names, m.names)245-}246-247219 // push adds a finished step to the undo stack and clears redo, dropping248220 // no-op changes.249221 func (w *Workbook) push(st *step) {
@@ -303,6 +275,9 @@ func (w *Workbook) dropUnchanged(st *step) {
303275 if st.decimal != nil && *st.decimal == w.decimal {304276 st.decimal = nil305277 }278+ if st.macros != nil && slices.Equal(*st.macros, w.macros) {279+ st.macros = nil280+ }306281 }307282 308283 // sameName reports whether two named ranges (nil for undefined) are the
@@ -354,6 +329,15 @@ func (w *Workbook) Undo() (Change, bool) { return w.swap(true) }
354329 // Redo reapplies the last undone step and describes it.355330 func (w *Workbook) Redo() (Change, bool) { return w.swap(false) }356331 332+// UndoLabel describes the step Undo would revert, e.g. "clear B3:B5",333+// or "" when there is none.334+func (w *Workbook) UndoLabel() string {335+ if top := w.hist.top(); top != nil {336+ return top.label337+ }338+ return ""339+}340+357341 // CanUndo and CanRedo report whether there is a step to undo or redo.358342 func (w *Workbook) CanUndo() bool { return len(w.hist.undo) > 0 }359343 func (w *Workbook) CanRedo() bool { return len(w.hist.redo) > 0 }
@@ -436,6 +420,10 @@ func (w *Workbook) swap(undo bool) (Change, bool) {
436420 inv.decimal, w.decimal = &cur, *st.decimal437421 w.structural = true // every formula computes differently438422 }423+ if st.macros != nil {424+ cur := w.macros425+ inv.macros, w.macros = &cur, slices.Clone(*st.macros)426+ }439427 for s, v := range st.views {440428 cur := s.view441429 inv.views[s] = &cur
@@ -454,7 +442,8 @@ func (w *Workbook) swap(undo bool) (Change, bool) {
454442 w.pushUndo(inv)455443 }456444 h.mergeWidths = false457- return Change{st.label, st.focus, st.sheet, st.sheets != nil}, true445+ macrosOnly := st.macros != nil && len(st.cells) == 0 && len(st.names) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && st.sheets == nil446+ return Change{Label: st.label, Focus: st.focus, Sheet: st.sheet, Tabs: st.sheets != nil, Macros: macrosOnly}, true458447 }459448 460449 // colRect is the range covering whole columns from..to.
M internal/sheet/history_test.go +1 -1
A internal/sheet/historysheets.go +50 -0
A internal/sheet/macros.go +237 -0
@@ -0,0 +1,237 @@
1+package sheet2+3+import (4+ "bytes"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "slices"9+ "strings"10+ "unicode/utf8"11+)12+13+// Macros are kept with the workbook, as Google Sheets keeps a14+// spreadsheet's Apps Script: each is a named Starlark script with an15+// optional Ctrl+Alt+Shift+digit shortcut. The engine only stores them;16+// recording and running them is the UI's (internal/ui, internal/macro).17+// Changing the list is an undo step like any other edit, so the file's18+// modified flag follows it.19+20+// MacroAPI is the version of the scripting API macros are written for.21+// A file records it with each macro, so a later build that changes the22+// API can tell old scripts from new ones.23+const MacroAPI = 124+25+// Macro is a saved macro.26+type Macro struct {27+ Name string28+ Key string // the shortcut's digit, "0" to "9", or "" for none29+ Source string // the Starlark script30+ API int // the scripting API it was written for, MacroAPI or older31+}32+33+// maxMacroName keeps names short enough for menus and the palette.34+const maxMacroName = 6035+36+// ValidMacroName reports why name can't name a macro, or nil.37+func ValidMacroName(name string) error {38+ switch {39+ case strings.TrimSpace(name) == "":40+ return errors.New("a macro needs a name")41+ case utf8.RuneCountInString(name) > maxMacroName:42+ return fmt.Errorf("macro names are at most %d characters", maxMacroName)43+ case strings.ContainsAny(name, "\n\r\t"):44+ return errors.New("macro names are one line")45+ }46+ return nil47+}48+49+// Macros returns the saved macros in the order they were added.50+func (w *Workbook) Macros() []Macro { return slices.Clone(w.macros) }51+52+// Macro finds a macro by name, ignoring case.53+func (w *Workbook) Macro(name string) (Macro, bool) {54+ if i := w.macroIndex(name); i >= 0 {55+ return w.macros[i], true56+ }57+ return Macro{}, false58+}59+60+// MacroForKey finds the macro a shortcut digit runs.61+func (w *Workbook) MacroForKey(key string) (Macro, bool) {62+ for _, mc := range w.macros {63+ if key != "" && mc.Key == key {64+ return mc, true65+ }66+ }67+ return Macro{}, false68+}69+70+func (w *Workbook) macroIndex(name string) int {71+ return slices.IndexFunc(w.macros, func(mc Macro) bool { return strings.EqualFold(mc.Name, name) })72+}73+74+// SaveMacro stores mc in place of the macro named old, or adds it when75+// old is "", as one undo step labelled label. Names are unique ignoring76+// case, and so are shortcuts.77+func (w *Workbook) SaveMacro(old string, mc Macro, label string) error {78+ if err := ValidMacroName(mc.Name); err != nil {79+ return err80+ }81+ mc.Name = strings.TrimSpace(mc.Name)82+ if mc.API == 0 {83+ mc.API = MacroAPI84+ }85+ at := len(w.macros)86+ if old != "" {87+ if at = w.macroIndex(old); at < 0 {88+ return fmt.Errorf("there's no macro named %s", old)89+ }90+ }91+ if i := w.macroIndex(mc.Name); i >= 0 && i != at {92+ return fmt.Errorf("there's already a macro named %s", w.macros[i].Name)93+ }94+ if other, ok := w.MacroForKey(mc.Key); ok && !strings.EqualFold(other.Name, old) {95+ return fmt.Errorf("Ctrl+Alt+Shift+%s already runs %s", mc.Key, other.Name)96+ }97+ if mc.Key != "" && (len(mc.Key) != 1 || mc.Key[0] < '0' || mc.Key[0] > '9') {98+ return fmt.Errorf("a macro's shortcut is a digit, not %q", mc.Key)99+ }100+ w.changeMacros(label, func() {101+ if at == len(w.macros) {102+ w.macros = append(w.macros, mc)103+ } else {104+ w.macros[at] = mc105+ }106+ })107+ return nil108+}109+110+// DeleteMacro removes the named macro as an undo step, reporting whether111+// there was one.112+func (w *Workbook) DeleteMacro(name string) bool {113+ i := w.macroIndex(name)114+ if i < 0 {115+ return false116+ }117+ w.changeMacros("delete macro "+w.macros[i].Name, func() {118+ w.macros = slices.Delete(w.macros, i, i+1)119+ })120+ return true121+}122+123+// changeMacros runs fn, which changes the macro list, as an undo step.124+func (w *Workbook) changeMacros(label string, fn func()) {125+ s := w.sheets[w.Active()]126+ w.change(s, label, Rect{}, func() {127+ if st := w.hist.open; st != nil && st.macros == nil {128+ before := slices.Clone(w.macros)129+ st.macros = &before130+ }131+ w.macros = slices.Clone(w.macros) // steps keep the old slice132+ fn()133+ })134+}135+136+// MacroOrigin identifies the computer the workbook's macros were made or137+// trusted on, as saved in the file; "" when unknown. The UI compares it138+// with its own to decide whether to ask before running a macro from a139+// file made elsewhere.140+func (w *Workbook) MacroOrigin() string { return w.macroOrigin }141+142+// SetMacroOrigin records where the macros were made or trusted. It isn't143+// an edit: it changes nothing a user sees, and is saved with the next144+// save.145+func (w *Workbook) SetMacroOrigin(origin string) { w.macroOrigin = origin }146+147+// Begin opens an undo step that stays open until the returned function148+// is called, for changes made over several calls that undo together,149+// such as a macro run. Changes in between join it as in a Batch, and150+// Undo and Redo do nothing while it is open. Call Settle to see the151+// values of formulas changed so far.152+func (w *Workbook) Begin(c Change) (end func()) {153+ w.begin(c.Sheet, c.Label, c.Focus)154+ done := false155+ return func() {156+ if !done {157+ done = true158+ w.finish()159+ }160+ }161+}162+163+// Settle recalculates what the open step has changed so far, so formulas164+// read inside it (by a macro, or a command it runs) see current values.165+// Outside a step there is nothing pending.166+func (w *Workbook) Settle() {167+ h := &w.hist168+ if h.open == nil || len(h.dirty) == 0 && !w.structural {169+ return170+ }171+ dirty := h.dirty172+ h.dirty = nil173+ if w.structural {174+ w.structural = false175+ w.recalcAll()176+ } else {177+ w.recalc(dirty)178+ }179+}180+181+// fileMacro is a macro, one per line, its script a JSON string:182+//183+// {"name": "Totals", "key": "1", "api": 1, "source": "select(\"B9\")\nenter(\"=SUM(B2:B8)\")\n"}184+type fileMacro struct {185+ Name string `json:"name"`186+ Key string `json:"key,omitempty"`187+ API int `json:"api"`188+ Source string `json:"source"`189+}190+191+// macrosLines is the "macros" field: a list with one macro per line.192+func (w *Workbook) macrosLines() string {193+ var b strings.Builder194+ b.WriteString(`"macros": [`)195+ for i, mc := range w.macros {196+ if i > 0 {197+ b.WriteByte(',')198+ }199+ fmt.Fprintf(&b, "\n {\"name\": %s, ", plainJSON(mc.Name))200+ if mc.Key != "" {201+ fmt.Fprintf(&b, "\"key\": %s, ", plainJSON(mc.Key))202+ }203+ fmt.Fprintf(&b, "\"api\": %d, \"source\": %s}", mc.API, plainJSON(mc.Source))204+ }205+ b.WriteString("\n ]")206+ return b.String()207+}208+209+// plainJSON is s as a JSON string, leaving <, > and & as they are so210+// formulas in scripts stay readable.211+func plainJSON(s string) string {212+ var b bytes.Buffer213+ enc := json.NewEncoder(&b)214+ enc.SetEscapeHTML(false)215+ enc.Encode(s)216+ return strings.TrimSuffix(b.String(), "\n")217+}218+219+// readMacros keeps a file's macros. A macro whose name clashes or is220+// invalid is an error, as for named ranges; its script isn't checked221+// until it runs.222+func (w *Workbook) readMacros(macros []fileMacro) error {223+ for _, fm := range macros {224+ if err := ValidMacroName(fm.Name); err != nil {225+ return fmt.Errorf("macro %q: %w", fm.Name, err)226+ }227+ if w.macroIndex(fm.Name) >= 0 {228+ return fmt.Errorf("macro %q is defined twice", fm.Name)229+ }230+ key := fm.Key231+ if _, taken := w.MacroForKey(key); taken || len(key) != 1 || key[0] < '0' || key[0] > '9' {232+ key = "" // a clash or a key this build can't bind: keep the macro, drop the shortcut233+ }234+ w.macros = append(w.macros, Macro{Name: fm.Name, Key: key, Source: fm.Source, API: max(fm.API, 1)})235+ }236+ return nil237+}
A internal/sheet/macros_test.go +145 -0
@@ -0,0 +1,145 @@
1+package sheet2+3+import (4+ "bytes"5+ "strings"6+ "testing"7+)8+9+func TestSaveMacroIsUndoable(t *testing.T) {10+ w := NewBook()11+ if err := w.SaveMacro("", Macro{Name: "Totals", Key: "1", Source: "enter(\"x\")\n"}, "record macro Totals"); err != nil {12+ t.Fatal(err)13+ }14+ if err := w.SaveMacro("totals", Macro{Name: "Sums", Key: "1", Source: "enter(\"y\")\n"}, "edit macro Sums"); err != nil {15+ t.Fatal(err)16+ }17+ if ms := w.Macros(); len(ms) != 1 || ms[0].Name != "Sums" || ms[0].API != MacroAPI {18+ t.Fatalf("macros %+v", ms)19+ }20+ c, ok := w.Undo()21+ if !ok || c.Label != "edit macro Sums" || !c.Macros {22+ t.Fatalf("undo %+v %v", c, ok)23+ }24+ if mc, ok := w.Macro("TOTALS"); !ok || mc.Source != "enter(\"x\")\n" {25+ t.Fatalf("after undo %+v %v", mc, ok)26+ }27+ w.Undo()28+ if len(w.Macros()) != 0 {29+ t.Fatalf("macros after undoing the add: %+v", w.Macros())30+ }31+ w.Redo()32+ if _, ok := w.MacroForKey("1"); !ok {33+ t.Fatal("redo lost the shortcut")34+ }35+ if !w.DeleteMacro("Totals") || len(w.Macros()) != 0 {36+ t.Fatal("delete")37+ }38+}39+40+func TestSaveMacroChecksNamesAndKeys(t *testing.T) {41+ w := NewBook()42+ w.SaveMacro("", Macro{Name: "A", Key: "1"}, "add")43+ for _, tc := range []struct {44+ old string45+ mc Macro46+ want string47+ }{48+ {"", Macro{Name: " "}, "needs a name"},49+ {"", Macro{Name: "a"}, "already a macro named A"},50+ {"", Macro{Name: "B", Key: "1"}, "Ctrl+Alt+Shift+1 already runs A"},51+ {"", Macro{Name: "B", Key: "x"}, "is a digit"},52+ {"C", Macro{Name: "C"}, "no macro named C"},53+ } {54+ if err := w.SaveMacro(tc.old, tc.mc, "x"); err == nil || !strings.Contains(err.Error(), tc.want) {55+ t.Errorf("SaveMacro(%q, %+v) = %v, want %q", tc.old, tc.mc, err, tc.want)56+ }57+ }58+}59+60+func TestMacrosRoundTrip(t *testing.T) {61+ for _, sheets := range []int{1, 2} {62+ w := NewBook()63+ if sheets == 2 {64+ w.AddSheet("Two", 1)65+ }66+ src := "# a <script> & more\nselect(\"B2\")\nenter(\"=SUM(A1:A3)\")\n"67+ w.SaveMacro("", Macro{Name: "Totals", Key: "3", Source: src}, "add")68+ w.SaveMacro("", Macro{Name: "Plain", Source: "move(0, 1)\n"}, "add")69+ w.SetMacroOrigin("abc123")70+ var b bytes.Buffer71+ if err := w.Write(&b); err != nil {72+ t.Fatal(err)73+ }74+ if !strings.Contains(b.String(), `{"name": "Totals", "key": "3", "api": 1, "source": "# a <script> & more\nselect(\"B2\")`) {75+ t.Errorf("file:\n%s", b.String())76+ }77+ got, err := ReadBook(&b)78+ if err != nil {79+ t.Fatal(err)80+ }81+ ms := got.Macros()82+ if len(ms) != 2 || ms[0] != (Macro{Name: "Totals", Key: "3", Source: src, API: 1}) || ms[1].Name != "Plain" || ms[1].Key != "" {83+ t.Errorf("%d sheets: macros %+v", sheets, ms)84+ }85+ if got.MacroOrigin() != "abc123" {86+ t.Errorf("origin %q", got.MacroOrigin())87+ }88+ if got.CanUndo() {89+ t.Error("loading is undoable")90+ }91+ }92+}93+94+func TestFileWithoutMacrosHasNoMacroFields(t *testing.T) {95+ w := NewBook()96+ w.SetMacroOrigin("abc")97+ var b bytes.Buffer98+ w.Write(&b)99+ if strings.Contains(b.String(), "macro") {100+ t.Errorf("file:\n%s", b.String())101+ }102+}103+104+func TestReadMacrosDropsClashingKeys(t *testing.T) {105+ src := `{"version": 2, "macros": [{"name": "A", "key": "1", "api": 1, "source": ""}, {"name": "B", "key": "1", "api": 2, "source": ""}], "cells": {}}`106+ w, err := ReadBook(strings.NewReader(src))107+ if err != nil {108+ t.Fatal(err)109+ }110+ if ms := w.Macros(); ms[1].Key != "" || ms[1].API != 2 {111+ t.Errorf("macros %+v", ms)112+ }113+ dup := `{"version": 2, "macros": [{"name": "A", "api": 1, "source": ""}, {"name": "a", "api": 1, "source": ""}], "cells": {}}`114+ if _, err := ReadBook(strings.NewReader(dup)); err == nil {115+ t.Error("a name defined twice loads")116+ }117+}118+119+func TestBeginKeepsOneStepOpen(t *testing.T) {120+ s := New()121+ w := s.Book()122+ end := w.Begin(Change{Label: "run macro M", Sheet: s})123+ s.Set(Addr{}, "2")124+ s.Set(Addr{Row: 1}, "=A1*10")125+ w.Settle()126+ if v := s.Value(Addr{Row: 1}); v.Num != 20 {127+ t.Fatalf("A2 inside the step = %v", v)128+ }129+ s.Set(Addr{}, "3")130+ if _, ok := w.Undo(); ok {131+ t.Fatal("undo ran inside an open step")132+ }133+ end()134+ end() // a second call does nothing135+ if v := s.Value(Addr{Row: 1}); v.Num != 30 {136+ t.Fatalf("A2 after the step = %v", v)137+ }138+ c, ok := w.Undo()139+ if !ok || c.Label != "run macro M" || s.Cell(Addr{}) != nil || s.Cell(Addr{Row: 1}) != nil {140+ t.Fatalf("undo %+v %v: A1 %v", c, ok, s.Cell(Addr{}))141+ }142+ if w.CanUndo() {143+ t.Error("the step was split")144+ }145+}
M internal/sheet/refs.go +18 -0
M internal/sheet/workbook.go +3 -0
M internal/ui/charts.go +1 -1
M internal/ui/clipboard.go +2 -0
M internal/ui/cmdline.go +1 -1
M internal/ui/columns.go +13 -1
M internal/ui/commands.go +36 -11
@@ -34,8 +34,30 @@ type command struct {
3434 // changes. A command that would change a pivot table's results is3535 // refused, as Sheets refuses (see pivot.go).3636 edits func(m *Model) sheet.Rect37+38+ // macro says how recording and scripts treat the command; see39+ // macroUse.40+ macro macroUse3741 }3842 43+// macroUse is how macros treat a command.44+type macroUse int45+46+const (47+ // macroRecord commands change the workbook: a recording calls them48+ // by id, run("format.bold"), and scripts may run them. The default.49+ macroRecord macroUse = iota50+ // macroView commands change only what's shown or selected, or open51+ // something to look through: a recording keeps the selection they52+ // leave, not the command, so replaying doesn't depend on the screen.53+ // Scripts may run them.54+ macroView55+ // macroNever commands are about the session (files, menus, help,56+ // undo, macros themselves): never recorded, and scripts can't run57+ // them.58+ macroNever59+)60+3961 // available reports whether the command can run in m's current state.4062 func (c *command) available(m *Model) bool {4163 return c.enabled == nil || c.enabled(m)
@@ -149,13 +171,16 @@ func (m *Model) runCommand(id string) tea.Cmd {
149171 if c.edits != nil && m.refusePivot(c.edits(m)) {150172 return nil151173 }174+ if m.rec != nil {175+ return m.recordingCommand(c) // macrorec.go176+ }152177 return c.run(m)153178 }154179 155180 func init() {156181 register(157- &command{id: "edit", title: "Edit cell", desc: "Edit the active cell's contents", run: (*Model).startEdit, edits: cellTarget},158- &command{id: "goto", title: "Go to", desc: "Move to a cell address", run: func(m *Model) tea.Cmd {182+ &command{id: "edit", macro: macroView, title: "Edit cell", desc: "Edit the active cell's contents", run: (*Model).startEdit, edits: cellTarget},183+ &command{id: "goto", macro: macroView, title: "Go to", desc: "Move to a cell address", run: func(m *Model) tea.Cmd {159184 m.openGoto()160185 return nil161186 }},
@@ -164,14 +189,14 @@ func init() {
164189 m.changed = true165190 return nil166191 }},167- &command{id: "select.none", title: "Deselect", desc: "Collapse the selection and clear the copy marker", run: func(m *Model) tea.Cmd {192+ &command{id: "select.none", macro: macroView, title: "Deselect", desc: "Collapse the selection and clear the copy marker", run: func(m *Model) tea.Cmd {168193 m.clearSelection()169194 m.copied.clearMark()170195 return nil171196 }},172- &command{id: "select.all", title: "Select all", desc: "Select the data, then the whole sheet", run: (*Model).selectAll},173- &command{id: "select.columns", title: "Select columns", desc: "Select the whole columns of the selection", run: (*Model).selectColumns},174- &command{id: "select.rows", title: "Select rows", desc: "Select the whole rows of the selection", run: (*Model).selectRows},197+ &command{id: "select.all", macro: macroView, title: "Select all", desc: "Select the data, then the whole sheet", run: (*Model).selectAll},198+ &command{id: "select.columns", macro: macroView, title: "Select columns", desc: "Select the whole columns of the selection", run: (*Model).selectColumns},199+ &command{id: "select.rows", macro: macroView, title: "Select rows", desc: "Select the whole rows of the selection", run: (*Model).selectRows},175200 &command{id: "column.width", title: "Column width", desc: "Set the width of the selected columns", run: (*Model).openWidth},176201 &command{id: "column.reset", title: "Reset column width", desc: "Return the selected columns to the default width", run: func(m *Model) tea.Cmd {177202 r := m.selection()
@@ -181,13 +206,13 @@ func init() {
181206 m.changed = true182207 return nil183208 }},184- &command{id: "file.new", title: "New", desc: "Start a new, empty sheet", run: func(m *Model) tea.Cmd {209+ &command{id: "file.new", macro: macroNever, title: "New", desc: "Start a new, empty sheet", run: func(m *Model) tea.Cmd {185210 m.reset(sheet.New(), "")186211 return nil187212 }},188- &command{id: "file.save", title: "Save", desc: "Save the sheet", run: (*Model).save},189- &command{id: "file.saveas", title: "Save as", desc: "Save the sheet under a new name", run: (*Model).openSave},190- &command{id: "file.open", title: "Open", desc: "Open a sheet, replacing this one", run: (*Model).openRetrieve},191- &command{id: "quit", title: "Quit", desc: "Close 012", run: (*Model).quit},213+ &command{id: "file.save", macro: macroNever, title: "Save", desc: "Save the sheet", run: (*Model).save},214+ &command{id: "file.saveas", macro: macroNever, title: "Save as", desc: "Save the sheet under a new name", run: (*Model).openSave},215+ &command{id: "file.open", macro: macroNever, title: "Open", desc: "Open a sheet, replacing this one", run: (*Model).openRetrieve},216+ &command{id: "quit", macro: macroNever, title: "Quit", desc: "Close 012", run: (*Model).quit},192217 )193218 }
M internal/ui/context.go +1 -1
M internal/ui/dialog.go +6 -3
M internal/ui/download.go +1 -0
M internal/ui/edit.go +4 -3
M internal/ui/entry.go +4 -0
M internal/ui/file.go +2 -1
M internal/ui/fill.go +21 -0
M internal/ui/find.go +2 -2
M internal/ui/help.go +7 -3
@@ -16,17 +16,17 @@ import (
1616 1717 func init() {1818 register(19- &command{id: "help", title: "Keyboard shortcuts", desc: "Show every key, grouped by what it does", run: func(m *Model) tea.Cmd {19+ &command{id: "help", macro: macroNever, title: "Keyboard shortcuts", desc: "Show every key, grouped by what it does", run: func(m *Model) tea.Cmd {2020 m.openOverlay(&shortcuts{})2121 return nil2222 }},23- &command{id: "help.functions", title: "Function list", desc: "Search the functions formulas can use", run: func(m *Model) tea.Cmd {23+ &command{id: "help.functions", macro: macroNever, title: "Function list", desc: "Search the functions formulas can use", run: func(m *Model) tea.Cmd {2424 p := newPicker(m, "Functions", "Type a function name, e.g. sum or if", 100, functionItems())2525 p.action = "insert"2626 m.openOverlay(p)2727 return nil2828 }},29- &command{id: "help.about", title: "About 012", desc: "Show the version", run: func(m *Model) tea.Cmd {29+ &command{id: "help.about", macro: macroNever, title: "About 012", desc: "Show the version", run: func(m *Model) tea.Cmd {3030 m.openOverlay(&choiceBar{3131 msg: "012 " + version() + ": Lotus 1-2-3 looks, Google Sheets keys.",3232 choices: []choice{
@@ -143,6 +143,10 @@ func keyHelpRows(vim bool) []helpRow {
143143 {keys: []string{"Arrows"}, action: "Pick cells for a formula"},144144 {keys: []string{"Esc"}, action: "Cancel the entry"},145145 }, "edit")146+ group("Macros", []helpRow{147+ {keys: []string{"Ctrl+Alt+Shift+0-9"}, action: "Run the macro with that shortcut"},148+ {keys: []string{"Esc"}, action: "Stop a macro while it runs"},149+ })146150 group("Menus and search", []helpRow{147151 {keys: []string{"Alt+letter"}, action: "Open a menu by its underlined letter"},148152 }, "palette", "menu", "menu.context", "help")
M internal/ui/jev.go +1 -1
A internal/ui/macro.go +277 -0
@@ -0,0 +1,277 @@
1+package ui2+3+import (4+ "errors"5+ "strconv"6+ "strings"7+8+ tea "charm.land/bubbletea/v2"9+10+ "github.com/FelineStateMachine/012/internal/macro"11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// Macros, as Google Sheets' Extensions > Macros, under Data > Macros:15+// record what you do (with absolute or relative references), save it16+// with a name and an optional Ctrl+Alt+Shift+digit shortcut, run it from17+// the menu, the palette or its shortcut, and manage saved macros. Macros18+// are Starlark scripts kept in the .012 file (see docs/macros.md);19+// recorded ones are scripts too, readable and editable. Recording is in20+// macrorec.go, running in macrorun.go, what scripts act on in21+// macrohost.go, and the manager in macromanage.go.22+23+// macroItems is Data > Macros.24+var macroItems = []menuItem{25+ {cmd: "macro.record"}, {cmd: "macro.record_relative"}, {cmd: "macro.stop"}, {cmd: "macro.discard"}, sep,26+ {cmd: "macro.run"}, {cmd: "macro.manage"}, {cmd: "macro.new"},27+}28+29+func init() {30+ idle := func(m *Model) bool { return m.rec == nil && m.macros.run == nil }31+ recording := func(m *Model) bool { return m.rec != nil }32+ register(33+ &command{id: "macro.record", title: "Record macro", macro: macroNever, enabled: idle,34+ desc: "Record what you do as a macro that selects the same cells (absolute references)",35+ run: func(m *Model) tea.Cmd { m.startRecording(false); return nil }},36+ &command{id: "macro.record_relative", title: "Record macro with relative references", macro: macroNever, enabled: idle,37+ desc: "Record what you do as a macro that moves from wherever the active cell is",38+ run: func(m *Model) tea.Cmd { m.startRecording(true); return nil }},39+ &command{id: "macro.stop", title: "Stop and save recording", macro: macroNever, enabled: recording,40+ desc: "Stop recording and save the macro with a name and an optional shortcut",41+ run: (*Model).stopRecording},42+ &command{id: "macro.discard", title: "Discard recording", macro: macroNever, enabled: recording,43+ desc: "Stop recording without saving; what you did stays done",44+ run: func(m *Model) tea.Cmd {45+ m.rec = nil46+ m.note = "Recording discarded"47+ return nil48+ }},49+ &command{id: "macro.run", title: "Run macro", macro: macroNever,50+ enabled: func(m *Model) bool { return idle(m) && len(m.book().Macros()) > 0 },51+ desc: "Pick a saved macro and run it",52+ run: func(m *Model) tea.Cmd {53+ p := newPicker(m, "Run macro", "Type a macro's name", 60, macroRunItems(m))54+ m.openOverlay(p)55+ return nil56+ }},57+ &command{id: "macro.manage", title: "Manage macros", macro: macroNever, enabled: idle,58+ desc: "Rename, delete, give shortcuts to or edit the saved macros",59+ run: func(m *Model) tea.Cmd { m.openMacros(""); return nil }},60+ &command{id: "macro.new", title: "Write a macro", macro: macroNever,61+ enabled: func(m *Model) bool { return idle(m) && m.macros.editor },62+ desc: "Write a macro script in your editor ($VISUAL or $EDITOR)",63+ run: (*Model).newMacro},64+ )65+ // Ctrl+Alt+Shift+digit arrives as Ctrl+Alt with the shifted symbol66+ // from terminals without the kitty keyboard protocol (US layout).67+ for i, sym := range "!@#$%^&*()" {68+ d := string("1234567890"[i])69+ keyAliases["ctrl+alt+"+string(sym)] = "ctrl+alt+shift+" + d70+ keyAliases["ctrl+alt+shift+"+string(sym)] = "ctrl+alt+shift+" + d71+ }72+}73+74+// macroShortcut returns the digit of a macro shortcut key.75+func macroShortcut(key string) (string, bool) {76+ d, ok := strings.CutPrefix(key, "ctrl+alt+shift+")77+ return d, ok && len(d) == 1 && d[0] >= '0' && d[0] <= '9'78+}79+80+// shortcutLabel is how a macro's shortcut is shown, e.g. Ctrl+Alt+Shift+1.81+func shortcutLabel(key string) string {82+ if key == "" {83+ return ""84+ }85+ return "Ctrl+Alt+Shift+" + key86+}87+88+// runShortcut runs the macro a Ctrl+Alt+Shift+digit key is for, and89+// reports whether the key is one.90+func (m *Model) runShortcut(key string) (tea.Cmd, bool) {91+ d, ok := macroShortcut(canonicalKey(key))92+ if !ok {93+ return nil, false94+ }95+ mc, found := m.book().MacroForKey(d)96+ if !found {97+ m.note = shortcutLabel(d) + " runs no macro; give one a shortcut in Data > Macros > Manage macros"98+ return nil, true99+ }100+ return m.runMacro(mc), true101+}102+103+// macroRunItems lists the saved macros for the run picker and the104+// palette; picking one runs it.105+func macroRunItems(m *Model) []pickItem {106+ var items []pickItem107+ for _, mc := range m.book().Macros() {108+ items = append(items, pickItem{109+ title: mc.Name, name: len(mc.Name), detail: "Data › Macros", key: shortcutLabel(mc.Key),110+ desc: "Run the macro " + mc.Name, off: m.rec != nil,111+ pick: func(m *Model) tea.Cmd {112+ m.closeOverlay()113+ return m.runMacro(mc)114+ },115+ })116+ }117+ return items118+}119+120+// runMacro runs mc, asking first when this file's macros came from121+// another computer.122+func (m *Model) runMacro(mc sheet.Macro) tea.Cmd {123+ switch {124+ case m.macros.run != nil:125+ return nil126+ case m.rec != nil:127+ m.note = "Stop recording before running a macro"128+ return nil129+ case m.macroTrusted():130+ return m.startMacro(mc)131+ }132+ m.openOverlay(&choiceBar{133+ msg: "Trust this file's macros?",134+ desc: "They were made on another computer and can change the file. Trusting covers all of them from now on.",135+ warn: true,136+ choices: []choice{137+ {key: "enter", label: "Run", run: func(m *Model) tea.Cmd {138+ m.trustHere()139+ return m.startMacro(mc)140+ }},141+ {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},142+ },143+ })144+ return nil145+}146+147+// macroTrusted reports whether this file's macros may run without148+// asking: they were made or trusted on this computer, or the user said149+// so this session.150+func (m *Model) macroTrusted() bool {151+ o := m.book().MacroOrigin()152+ return m.macros.trusted || o != "" && o == m.macros.machine153+}154+155+// trustHere trusts the file's macros from now on, on this computer: the156+// origin saved with the file becomes this one.157+func (m *Model) trustHere() {158+ m.macros.trusted = true159+ if m.macros.machine != "" {160+ m.book().SetMacroOrigin(m.macros.machine)161+ }162+}163+164+// AllowEditor lets the user edit macro scripts in their own editor165+// ($VISUAL or $EDITOR), which runs as a program of theirs with the166+// screen handed over. Only the local app allows it: a session served167+// over SSH must not start programs on the server.168+func (m *Model) AllowEditor() { m.macros.editor = true }169+170+// SetMachine tells the model which computer it runs on, as an id kept in171+// the user's configuration, so macros saved here run without asking and172+// macros from files made elsewhere ask once.173+func (m *Model) SetMachine(id string) { m.macros.machine = id }174+175+// madeHere is called when the user saves a macro of their own: it trusts176+// the file's macros unless others came from elsewhere untrusted.177+func (m *Model) madeHere(before int) {178+ if before == 0 || m.macroTrusted() {179+ m.trustHere()180+ }181+}182+183+// stopRecording asks for the new macro's name and then its shortcut.184+// Esc at either question goes back to recording.185+func (m *Model) stopRecording() tea.Cmd {186+ r := m.rec187+ r.flush(m)188+ if len(r.actions) == 0 {189+ m.rec = nil190+ m.note = "Nothing was recorded, so there's no macro to save"191+ return nil192+ }193+ m.openText("Save macro as:", nextMacroName(m.book()), func(m *Model, name string) tea.Cmd {194+ if err := checkNewMacroName(m.book(), name); err != nil {195+ m.fail(err.Error() + "; still recording")196+ return nil197+ }198+ m.askShortcut(name)199+ return nil200+ })201+ m.prompt.indicator = "NAME"202+ m.prompt.onCancel = stillRecording203+ return nil204+}205+206+func stillRecording(m *Model) { m.note = "Still recording" }207+208+// checkNewMacroName reports why name can't name a new macro.209+func checkNewMacroName(w *sheet.Workbook, name string) error {210+ if err := sheet.ValidMacroName(name); err != nil {211+ return err212+ }213+ if mc, ok := w.Macro(strings.TrimSpace(name)); ok {214+ return errors.New("There's already a macro named " + mc.Name)215+ }216+ return nil217+}218+219+// askShortcut asks for the digit of the new macro's shortcut, offering220+// the first one free.221+func (m *Model) askShortcut(name string) {222+ m.openText("Shortcut Ctrl+Alt+Shift+ (a digit, or empty for none):", freeShortcut(m.book()), func(m *Model, key string) tea.Cmd {223+ if key != "" && !isShortcutDigit(key) {224+ m.fail("A shortcut is one digit, 0 to 9, or nothing; still recording")225+ return nil226+ }227+ m.saveRecording(name, key)228+ return nil229+ })230+ m.prompt.indicator = "KEY"231+ m.prompt.onCancel = stillRecording232+}233+234+func isShortcutDigit(s string) bool { return len(s) == 1 && s[0] >= '0' && s[0] <= '9' }235+236+// saveRecording saves the recording as a macro and stops recording.237+func (m *Model) saveRecording(name, key string) {238+ r := m.rec239+ before := len(m.book().Macros())240+ mc := sheet.Macro{Name: name, Key: key, Source: macro.Source(recordingHeader(r.relative), r.actions)}241+ if err := m.book().SaveMacro("", mc, "record macro "+strings.TrimSpace(name)); err != nil {242+ m.fail(err.Error() + "; still recording")243+ return244+ }245+ m.rec = nil246+ m.madeHere(before)247+ m.note = "Saved macro " + strings.TrimSpace(name)248+ if key != "" {249+ m.note += "; " + shortcutLabel(key) + " runs it"250+ } else {251+ m.note += "; run it from Data > Macros or the palette"252+ }253+}254+255+// nextMacroName is the first free "Macro N".256+func nextMacroName(w *sheet.Workbook) string {257+ for i := 1; ; i++ {258+ if name := "Macro " + strconv.Itoa(i); !hasMacro(w, name) {259+ return name260+ }261+ }262+}263+264+func hasMacro(w *sheet.Workbook, name string) bool {265+ _, ok := w.Macro(name)266+ return ok267+}268+269+// freeShortcut is the first digit, 1 to 9 then 0, no macro uses.270+func freeShortcut(w *sheet.Workbook) string {271+ for _, d := range "1234567890" {272+ if _, taken := w.MacroForKey(string(d)); !taken {273+ return string(d)274+ }275+ }276+ return ""277+}
A internal/ui/macro_doc_test.go +70 -0
@@ -0,0 +1,70 @@
1+package ui2+3+import (4+ "os"5+ "regexp"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/macro"10+)11+12+// docs/macros.md documents the scripting API and names command ids; these13+// tests keep it honest.14+15+func readMacroDoc(t *testing.T) string {16+ t.Helper()17+ data, err := os.ReadFile("../../docs/macros.md")18+ if err != nil {19+ t.Fatal(err)20+ }21+ return string(data)22+}23+24+// TestMacroDocCommandIDs checks every command id the doc names exists and25+// can run in a script.26+func TestMacroDocCommandIDs(t *testing.T) {27+ doc := readMacroDoc(t)28+ start := strings.Index(doc, "| Id | Command |")29+ end := strings.Index(doc[start:], "\n\n")30+ ids := regexp.MustCompile("`([a-z_.0-9]+)`").FindAllStringSubmatch(doc[start:start+end], -1)31+ if len(ids) < 20 {32+ t.Fatalf("found %d ids", len(ids))33+ }34+ for _, id := range ids {35+ if c, ok := commands[id[1]]; !ok || c.macro == macroNever {36+ t.Errorf("docs/macros.md names %q, which scripts can't run", id[1])37+ }38+ }39+}40+41+// TestMacroDocFunctions checks the doc covers every function scripts have.42+func TestMacroDocFunctions(t *testing.T) {43+ doc := readMacroDoc(t)44+ for _, name := range macro.Functions() {45+ if !strings.Contains(doc, "`"+name+"(") {46+ t.Errorf("docs/macros.md doesn't document %s", name)47+ }48+ }49+}50+51+// TestMacroDocExample runs the example script from the doc.52+func TestMacroDocExample(t *testing.T) {53+ doc := readMacroDoc(t)54+ start := strings.Index(doc, "```python\n")55+ end := strings.Index(doc[start:], "```\n\n")56+ src := doc[start+len("```python\n") : start+end]57+ m := newModel()58+ press(t, m, "<right>", "<down>", "1", "<tab>", "x", "<tab>", "2", "<enter>", "3", "<tab>", "y", "<tab>", "4", "<enter>")59+ press(t, m, "<up>", "<up>", "<shift+down>", "<shift+right>", "<shift+right>")60+ script(t, m, src)61+ if m.warn != "" {62+ t.Fatal(m.warn)63+ }64+ if got := input(m, "B4") + " " + input(m, "C4") + " " + input(m, "D4"); got != "=SUM(B2:B3) =SUM(D2:D3)" {65+ t.Errorf("totals %q", got)66+ }67+ if c := m.sheet.Cell(addr("B4")); c == nil || !c.Style.Bold || m.cur != addr("B4") {68+ t.Errorf("B4 isn't bold and active: %v", m.cur)69+ }70+}
A internal/ui/macro_stress_test.go +100 -0
@@ -0,0 +1,100 @@
1+//go:build stress2+3+package ui4+5+import (6+ "fmt"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/macro"10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// replayActions is a recording of n actions as a user makes them: entries14+// of numbers, text and formulas, moves between them, a selection and a15+// command now and then.16+func replayActions(n int, relative bool) []macro.Action {17+ out := make([]macro.Action, 0, n)18+ for i := 0; len(out) < n; i++ {19+ row := i/4 + 120+ switch i % 8 {21+ case 0:22+ out = append(out, macro.Call("enter", fmt.Sprint(i)))23+ case 1:24+ out = append(out, macro.Call("enter", "item "+fmt.Sprint(i)))25+ case 2:26+ a := macro.Call("enter", fmt.Sprintf("=A%d*2", row))27+ if relative {28+ a = a.With("origin", fmt.Sprintf("C%d", row))29+ }30+ out = append(out, a)31+ case 3:32+ out = append(out, macro.Call("extend", 2, 0))33+ case 4:34+ out = append(out, macro.Call("run", "format.bold"))35+ case 5, 6:36+ if relative {37+ out = append(out, macro.Call("move", 1, 0))38+ } else {39+ out = append(out, macro.Call("select", fmt.Sprintf("%s%d", sheet.ColName(i%8-4), row)))40+ }41+ case 7:42+ if relative {43+ out = append(out, macro.Call("move", -2, 1))44+ } else {45+ out = append(out, macro.Call("select", fmt.Sprintf("A%d", row+1)))46+ }47+ }48+ }49+ return out50+}51+52+// BenchmarkMacroReplay replays a 1000-action recorded macro through the53+// real path: the script on its own goroutine, each call served on the54+// model through Bubble Tea messages, all of it one undo step.55+func BenchmarkMacroReplay(b *testing.B) {56+ for _, relative := range []bool{false, true} {57+ name := "absolute"58+ if relative {59+ name = "relative"60+ }61+ src := macro.Source(recordingHeader(relative), replayActions(1000, relative))62+ b.Run(name+"/actions=1000", func(b *testing.B) {63+ b.ReportAllocs()64+ for b.Loop() {65+ b.StopTimer()66+ m := newModel()67+ m.book().SaveMacro("", sheet.Macro{Name: "Bench", Source: src}, "add")68+ m.trustHere()69+ mc, _ := m.book().Macro("Bench")70+ b.StartTimer()71+ run(m, m.runMacro(mc))72+ if m.warn != "" {73+ b.Fatal(m.warn)74+ }75+ }76+ b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(b.N)/1000, "ns/action")77+ })78+ }79+}80+81+// BenchmarkMacroScript is a script's own loop: 10,000 cells set and read82+// back, to show what a call to the spreadsheet costs next to Starlark83+// computing on its own.84+func BenchmarkMacroScript(b *testing.B) {85+ src := "for i in range(1, 5001):\n set(\"A\" + str(i), i)\n x = get(\"A\" + str(i))\n"86+ b.ReportAllocs()87+ for b.Loop() {88+ b.StopTimer()89+ m := newModel()90+ m.book().SaveMacro("", sheet.Macro{Name: "Loop", Source: src}, "add")91+ m.trustHere()92+ mc, _ := m.book().Macro("Loop")93+ b.StartTimer()94+ run(m, m.runMacro(mc))95+ if m.warn != "" {96+ b.Fatal(m.warn)97+ }98+ }99+ b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(b.N)/10000, "ns/call")100+}
A internal/ui/macro_test.go +382 -0
@@ -0,0 +1,382 @@
1+package ui2+3+import (4+ "bytes"5+ "os"6+ "path/filepath"7+ "strings"8+ "testing"9+10+ tea "charm.land/bubbletea/v2"11+12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// record records keys as a macro named name with the shortcut key, from16+// wherever m is, and returns its script.17+func record(t *testing.T, m *Model, relative bool, name, key string, keys ...string) string {18+ t.Helper()19+ id := "macro.record"20+ if relative {21+ id = "macro.record_relative"22+ }23+ send(m, nil) // an idle update, as between keys24+ run(m, m.runCommand(id))25+ if m.rec == nil {26+ t.Fatal("not recording")27+ }28+ if !strings.Contains(line(m, menuLine), "REC") {29+ t.Errorf("no REC chip: %q", line(m, menuLine))30+ }31+ press(t, m, keys...)32+ run(m, m.runCommand("macro.stop"))33+ if m.mode != modePrompt || !strings.HasPrefix(m.prompt.label, "Save macro as") {34+ t.Fatalf("no name prompt: mode %v", m.mode)35+ }36+ m.line.set(name)37+ press(t, m, "<enter>")38+ m.line.set(key)39+ press(t, m, "<enter>")40+ if m.rec != nil {41+ t.Fatalf("still recording: %q %q", m.note, m.errMsg)42+ }43+ mc, ok := m.book().Macro(name)44+ if !ok {45+ t.Fatalf("no macro %s: %q", name, m.errMsg)46+ }47+ return mc.Source48+}49+50+// body is a script without its header comment.51+func body(src string) string {52+ var out []string53+ for _, l := range strings.Split(strings.TrimSpace(src), "\n") {54+ if !strings.HasPrefix(l, "# Recorded") && !strings.HasPrefix(l, "# Edit it") {55+ out = append(out, l)56+ }57+ }58+ return strings.Join(out, "\n")59+}60+61+// fileOf is the workbook as saved, for comparing two of them.62+func fileOf(t *testing.T, m *Model) string {63+ t.Helper()64+ var b bytes.Buffer65+ if err := m.book().Write(&b); err != nil {66+ t.Fatal(err)67+ }68+ return b.String()69+}70+71+// withMacro gives a fresh model the macro from another, trusted.72+func withMacro(t *testing.T, from *Model, name string) *Model {73+ t.Helper()74+ m := newModel()75+ mc, _ := from.book().Macro(name)76+ if err := m.book().SaveMacro("", mc, "add"); err != nil {77+ t.Fatal(err)78+ }79+ m.trustHere()80+ return m81+}82+83+func TestRecordAbsoluteAndReplay(t *testing.T) {84+ m := newModel()85+ src := record(t, m, false, "Header", "1",86+ "<down>", "Rent", "<tab>", "1450", "<enter>",87+ "<right>", "<right>", "=B2*12", "<enter>",88+ "<up>", "<shift+left>", "<ctrl+b>",89+ "<ctrl+home>")90+ want := `select("A2")91+enter("Rent")92+select("B2")93+enter("1450")94+select("C3")95+enter("=B2*12")96+select("B3:C3", active="C3")97+run("format.bold")98+select("A1")`99+ // The selection is recorded when something acts on it, so Enter's100+ // move to A3 and the arrows to C3 are one select.101+ if got := body(src); got != want {102+ t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)103+ }104+ if !strings.HasPrefix(src, "# Recorded with absolute references") {105+ t.Errorf("header: %q", src)106+ }107+108+ // Replaying on a new sheet from anywhere does the same.109+ r := withMacro(t, m, "Header")110+ press(t, r, "<down>", "<down>", "<right>")111+ press(t, r, "<ctrl+alt+shift+1>")112+ if r.note != "Ran Header" {113+ t.Fatalf("note %q warn %q err %q", r.note, r.warn, r.errMsg)114+ }115+ if fileOf(t, r) != fileOf(t, m) {116+ t.Errorf("replay differs:\n%s\nrecorded:\n%s", fileOf(t, r), fileOf(t, m))117+ }118+ if r.cur != m.cur || r.selection() != m.selection() {119+ t.Errorf("replay left %v, recording %v", r.selection(), m.selection())120+ }121+}122+123+func TestRecordRelativeReplaysFromTheActiveCell(t *testing.T) {124+ m := newModel()125+ press(t, m, "<right>", "<down>") // B2126+ src := record(t, m, true, "Pair", "",127+ "Name", "<tab>", "=LEN(B2)", "<enter>",128+ "<shift+right>", "<ctrl+i>", "<ctrl+down>")129+ want := `enter("Name")130+move(1, 0)131+enter("=LEN(B2)", origin="C2")132+move(-1, 1)133+extend(1, 0)134+run("format.italic")135+jump("down")`136+ if got := body(src); got != want {137+ t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)138+ }139+140+ r := withMacro(t, m, "Pair")141+ press(t, r, "<down>", "<down>", "<down>", "<right>", "<right>", "<right>") // D4142+ run(r, r.runMacro(mustMacro(t, r, "Pair")))143+ if got := input(r, "D4") + "|" + input(r, "E4"); got != "Name|=LEN(D4)" {144+ t.Errorf("replayed at D4: %q", got)145+ }146+ if c := r.sheet.Cell(addr("E5")); c == nil || !c.Style.Italic {147+ t.Error("E5 isn't italic")148+ }149+ if r.cur != addr("D8192") {150+ t.Errorf("jump down ended at %v", r.cur)151+ }152+}153+154+func mustMacro(t *testing.T, m *Model, name string) sheet.Macro {155+ t.Helper()156+ mc, ok := m.book().Macro(name)157+ if !ok {158+ t.Fatalf("no macro %s", name)159+ }160+ return mc161+}162+163+func TestReplayIsOneUndoStep(t *testing.T) {164+ m := newModel()165+ record(t, m, false, "Three", "2", "1", "<enter>", "2", "<enter>", "=A1+A2", "<enter>", "<shift+up>", "<ctrl+i>")166+ r := withMacro(t, m, "Three")167+ before := r.book().UndoLabel()168+ press(t, r, "<ctrl+alt+shift+2>")169+ if input(r, "A3") != "=A1+A2" || r.sheet.Value(addr("A3")).Num != 3 {170+ t.Fatalf("A3 %q = %v", input(r, "A3"), r.sheet.Value(addr("A3")))171+ }172+ if got := r.book().UndoLabel(); got != "run macro Three" {173+ t.Errorf("undo step %q (before %q)", got, before)174+ }175+ press(t, r, "<ctrl+z>")176+ if r.sheet.Len() != 0 || r.note != "Undid: run macro Three" {177+ t.Errorf("after undo: %d cells, note %q", r.sheet.Len(), r.note)178+ }179+ if r.book().UndoLabel() != before {180+ t.Errorf("more than one step: %q", r.book().UndoLabel())181+ }182+ press(t, r, "<ctrl+y>")183+ if input(r, "A2") != "2" {184+ t.Error("redo")185+ }186+}187+188+func TestRecordingAnswersAndSkips(t *testing.T) {189+ m := newModel()190+ press(t, m, "x", "<enter>")191+ src := record(t, m, false, "Widths", "",192+ "<f10>", // a menu opened and closed records nothing193+ "<esc>",194+ "<ctrl+z>", // undo isn't recorded; the macro says so195+ )196+ run(m, nil)197+ if !strings.Contains(src, "# Not recorded: undid: enter x in a1") && !strings.Contains(src, "# Not recorded: undid") {198+ t.Errorf("recorded:\n%s", src)199+ }200+201+ m = newModel()202+ run(m, m.runCommand("macro.record"))203+ run(m, m.runCommand("column.width"))204+ m.line.set("15")205+ press(t, m, "<enter>")206+ run(m, m.runCommand("macro.stop"))207+ press(t, m, "<enter>", "<enter>")208+ if got := body(mustMacro(t, m, "Macro 1").Source); got != `run("column.width", answer="15")` {209+ t.Errorf("recorded %q", got)210+ }211+ r := withMacro(t, m, "Macro 1")212+ run(r, r.runMacro(mustMacro(t, r, "Macro 1")))213+ if r.sheet.ColWidth(0) != 15 || r.mode != modeReady {214+ t.Errorf("width %d mode %v %q", r.sheet.ColWidth(0), r.mode, r.warn)215+ }216+}217+218+func TestRecordingTheMouse(t *testing.T) {219+ for _, relative := range []bool{false, true} {220+ m := newModel()221+ press(t, m, "1", "<enter>", "2", "<enter>", "<up>", "<up>", "<shift+down>")222+ run(m, m.runCommand(map[bool]string{false: "macro.record", true: "macro.record_relative"}[relative]))223+ m.startFill() // drag the fill handle from A1:A2 down to A5224+ m.dragFillTo(addr("A5"))225+ m.finishFill()226+ m.autofit(1) // double-click column B's border227+ run(m, m.runCommand("macro.stop"))228+ press(t, m, "<enter>", "<enter>")229+ want := `fill(to="A1:A5")` + "\n" + `set_width("B", 3)`230+ if relative {231+ want = `fill(rows=3)` + "\n" + `set_width("B", 3)`232+ }233+ if got := body(mustMacro(t, m, "Macro 1").Source); got != want {234+ t.Errorf("relative %v: recorded\n%s\nwant\n%s", relative, got, want)235+ }236+ r := withMacro(t, m, "Macro 1")237+ press(t, r, "1", "<enter>", "2", "<enter>", "<up>", "<up>", "<shift+down>")238+ run(r, r.runMacro(mustMacro(t, r, "Macro 1")))239+ if input(r, "A5") != "5" || r.sheet.ColWidth(1) != 3 {240+ t.Errorf("relative %v: A5 %q, width %d, %q", relative, input(r, "A5"), r.sheet.ColWidth(1), r.warn)241+ }242+ }243+}244+245+func TestStopWithNothingRecorded(t *testing.T) {246+ m := newModel()247+ run(m, m.runCommand("macro.record"))248+ run(m, m.runCommand("macro.stop"))249+ if m.rec != nil || !strings.Contains(m.note, "Nothing was recorded") {250+ t.Errorf("rec %v note %q", m.rec, m.note)251+ }252+ // Esc on the name keeps recording.253+ run(m, m.runCommand("macro.record"))254+ press(t, m, "a", "<enter>")255+ run(m, m.runCommand("macro.stop"))256+ press(t, m, "<esc>")257+ if m.rec == nil || m.note != "Still recording" {258+ t.Errorf("rec %v note %q", m.rec, m.note)259+ }260+}261+262+func TestMacrosFromElsewhereAskOnce(t *testing.T) {263+ w := sheet.NewBook()264+ w.SaveMacro("", sheet.Macro{Name: "Hi", Key: "3", Source: `set("A1", "hi")`}, "add")265+ w.SetMacroOrigin("someone-else")266+ var b bytes.Buffer267+ w.Write(&b)268+ s, err := sheet.Read(&b)269+ if err != nil {270+ t.Fatal(err)271+ }272+ m := New(s, "hi.012")273+ m.SetMachine("this-one")274+ m.Update(tea.WindowSizeMsg{Width: 100, Height: 20})275+ if m.sheet.Len() != 0 {276+ t.Fatal("opening ran a macro")277+ }278+ press(t, m, "<ctrl+alt+#>") // Ctrl+Alt+Shift+3 without the kitty protocol279+ if !strings.Contains(line(m, contextLine), "Trust this file's macros?") || !strings.Contains(line(m, m.height-1), "made on another computer") {280+ t.Fatalf("no trust question: %q", line(m, contextLine))281+ }282+ press(t, m, "<esc>")283+ if m.sheet.Len() != 0 {284+ t.Fatal("ran after cancel")285+ }286+ press(t, m, "<ctrl+alt+shift+3>", "<enter>")287+ if input(m, "A1") != "hi" || m.book().MacroOrigin() != "this-one" {288+ t.Fatalf("A1 %q origin %q", input(m, "A1"), m.book().MacroOrigin())289+ }290+ press(t, m, "<delete>", "<ctrl+alt+shift+3>")291+ if input(m, "A1") != "hi" {292+ t.Error("asked twice")293+ }294+}295+296+func TestPaletteAndPickerRunMacros(t *testing.T) {297+ m := newModel()298+ record(t, m, false, "Stamp", "5", "stamp", "<enter>")299+ press(t, m, "<ctrl+k>", "stamp")300+ if !strings.Contains(screen(m), "Ctrl+Alt+Shift+5") {301+ t.Errorf("palette:\n%s", screen(m))302+ }303+ press(t, m, "<enter>")304+ if m.note != "Ran Stamp" {305+ t.Errorf("note %q", m.note)306+ }307+ run(m, m.runCommand("macro.run"))308+ press(t, m, "<enter>")309+ if m.note != "Ran Stamp" || m.overlay != nil {310+ t.Errorf("picker: note %q", m.note)311+ }312+}313+314+func TestManageMacros(t *testing.T) {315+ m := newModel()316+ m.AllowEditor()317+ record(t, m, false, "One", "1", "1", "<enter>")318+ run(m, m.runCommand("macro.manage"))319+ if !strings.Contains(screen(m), "+ Write a macro") || !strings.Contains(screen(m), "One") {320+ t.Fatalf("manager:\n%s", screen(m))321+ }322+ press(t, m, "<down>", "<f2>")323+ m.line.set("Uno")324+ press(t, m, "<enter>")325+ press(t, m, "<f3>")326+ m.line.set("7")327+ press(t, m, "<enter>")328+ mc := mustMacro(t, m, "Uno")329+ if mc.Key != "7" {330+ t.Errorf("key %q", mc.Key)331+ }332+ press(t, m, "<ctrl+d>")333+ if len(m.book().Macros()) != 0 || !strings.Contains(line(m, m.height-1), "Deleted Uno") {334+ t.Errorf("after delete: %v %q", m.book().Macros(), line(m, m.height-1))335+ }336+ press(t, m, "<esc>", "<ctrl+z>")337+ if _, ok := m.book().Macro("Uno"); !ok {338+ t.Error("undo didn't bring it back")339+ }340+}341+342+func TestWithoutAnEditorScriptsAreReadOnly(t *testing.T) {343+ m := newModel()344+ record(t, m, false, "One", "", "1", "<enter>")345+ if commands["macro.new"].available(m) {346+ t.Error("Write a macro is available without an editor")347+ }348+ run(m, m.runCommand("macro.manage"))349+ if strings.Contains(screen(m), "+ Write a macro") {350+ t.Error("the manager offers to write a macro")351+ }352+ if cmd := m.overlay.key(m, tea.KeyPressMsg{Code: tea.KeyF4}); cmd != nil || !strings.Contains(line(m, m.height-1), "No editor in this session") {353+ t.Errorf("F4: %v %q", cmd, line(m, m.height-1))354+ }355+}356+357+func TestEditedScriptsAreSavedAndChecked(t *testing.T) {358+ m := newModel()359+ m.AllowEditor()360+ record(t, m, false, "Ed", "", "a", "<enter>")361+ path := filepath.Join(t.TempDir(), "ed.star")362+ os.WriteFile(path, []byte("set(\"B1\", 2)\nset(\"B2\",\n"), 0o600)363+ m.Update(macroEditedMsg{name: "Ed", path: path, orig: "x"})364+ if !strings.Contains(m.warn, "Saved Ed, but it has a mistake at Ed:3:1:") {365+ t.Errorf("warn %q", m.warn)366+ }367+ if mustMacro(t, m, "Ed").Source != "set(\"B1\", 2)\nset(\"B2\",\n" {368+ t.Error("not saved")369+ }370+ if _, err := os.Stat(path); !os.IsNotExist(err) {371+ t.Error("temp file left")372+ }373+374+ // A new script is trusted, and runs.375+ path = filepath.Join(t.TempDir(), "new.star")376+ os.WriteFile(path, []byte("set(\"C1\", 3)\n"), 0o600)377+ m.Update(macroEditedMsg{name: "New", path: path, orig: newMacroTemplate, isNew: true})378+ run(m, m.runMacro(mustMacro(t, m, "New")))379+ if input(m, "C1") != "3" {380+ t.Errorf("C1 %q warn %q", input(m, "C1"), m.warn)381+ }382+}
A internal/ui/macrohost.go +247 -0
@@ -0,0 +1,247 @@
1+package ui2+3+import (4+ "errors"5+ "fmt"6+ "strconv"7+ "strings"8+9+ "github.com/FelineStateMachine/012/internal/macro"10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// scriptHost is what a running macro acts on: the model, through the14+// same paths a user's keys take (entries, the selection, commands). Its15+// methods run on the UI goroutine, inside the run's undo step; see16+// macrorun.go.17+type scriptHost struct {18+ m *Model19+}20+21+var _ macro.Host = scriptHost{}22+23+// maxReadCells bounds what get and get_formula return at once.24+const maxReadCells = 1 << 1825+26+// resolve finds the sheet and range a script means: "" for the27+// selection, A1 or A1:B3, whole columns (B:D) or rows (3:5), or a named28+// range, any of them after a sheet name.29+func (h scriptHost) resolve(ref string) (*sheet.Sheet, sheet.Rect, error) {30+ m := h.m31+ if strings.TrimSpace(ref) == "" {32+ return m.sheet, m.selection(), nil33+ }34+ name, rest := sheet.SplitSheet(ref)35+ s := m.sheet36+ if name != "" {37+ if s = m.book().Lookup(name); s == nil {38+ return nil, sheet.Rect{}, fmt.Errorf("there's no sheet named %s", name)39+ }40+ }41+ if r, ok := parseWhole(rest); ok {42+ return s, r, nil43+ }44+ if n, ok := m.sheet.LookupName(rest); ok && name == "" {45+ if n.Gone() {46+ return nil, sheet.Rect{}, fmt.Errorf("the range named %s was deleted", n.Name)47+ }48+ return n.Sheet, n.Range, nil49+ }50+ return nil, sheet.Rect{}, fmt.Errorf("not a cell or range: %s", ref)51+}52+53+// parseWhole parses A1, A1:B3, whole columns B:D and whole rows 3:5.54+func parseWhole(s string) (sheet.Rect, bool) {55+ if r, ok := sheet.ParseRange(s); ok {56+ return r, true57+ }58+ from, to, ok := strings.Cut(strings.TrimSpace(s), ":")59+ if !ok {60+ return sheet.Rect{}, false61+ }62+ if a, ok1 := sheet.ParseCol(strings.ToUpper(from)); ok1 {63+ if b, ok2 := sheet.ParseCol(strings.ToUpper(to)); ok2 {64+ return sheet.NewRect(sheet.Addr{Col: a}, sheet.Addr{Col: b, Row: sheet.MaxRows - 1}), true65+ }66+ }67+ a, err1 := strconv.Atoi(from)68+ b, err2 := strconv.Atoi(to)69+ if err1 != nil || err2 != nil || a < 1 || b < 1 || a > sheet.MaxRows || b > sheet.MaxRows {70+ return sheet.Rect{}, false71+ }72+ return sheet.NewRect(sheet.Addr{Row: a - 1}, sheet.Addr{Col: sheet.MaxCols - 1, Row: b - 1}), true73+}74+75+// wholeRef writes r as a script would: B:D for whole columns, 3:5 for76+// whole rows, otherwise A1 or A1:B3.77+func wholeRef(r sheet.Rect) string {78+ allRows := r.From.Row == 0 && r.To.Row == sheet.MaxRows-179+ allCols := r.From.Col == 0 && r.To.Col == sheet.MaxCols-180+ switch {81+ case allRows && !allCols:82+ return sheet.ColName(r.From.Col) + ":" + sheet.ColName(r.To.Col)83+ case allCols && !allRows:84+ return strconv.Itoa(r.From.Row+1) + ":" + strconv.Itoa(r.To.Row+1)85+ }86+ return r.String()87+}88+89+// readRange is r to read: a large range (whole columns, say) is trimmed90+// to the used range. Small ones aren't, as finding the used range takes91+// a pass over the sheet.92+func readRange(s *sheet.Sheet, r sheet.Rect) (sheet.Rect, error) {93+ if (r.To.Col-r.From.Col+1)*(r.To.Row-r.From.Row+1) <= 1<<12 {94+ return r, nil95+ }96+ if used, ok := s.UsedRange(); ok && (r.To.Col > used.To.Col || r.To.Row > used.To.Row) {97+ r.To = sheet.Addr{Col: max(min(r.To.Col, used.To.Col), r.From.Col), Row: max(min(r.To.Row, used.To.Row), r.From.Row)}98+ }99+ if (r.To.Col-r.From.Col+1)*(r.To.Row-r.From.Row+1) > maxReadCells {100+ return r, fmt.Errorf("%s has more than %d cells to read at once", r, maxReadCells)101+ }102+ return r, nil103+}104+105+// each calls fn for the cells of ref, row by row, to read them.106+func (h scriptHost) each(ref string, fn func(s *sheet.Sheet, a sheet.Addr, row int)) error {107+ s, r, err := h.resolve(ref)108+ if err != nil {109+ return err110+ }111+ if r, err = readRange(s, r); err != nil {112+ return err113+ }114+ for row := r.From.Row; row <= r.To.Row; row++ {115+ for col := r.From.Col; col <= r.To.Col; col++ {116+ fn(s, sheet.Addr{Col: col, Row: row}, row-r.From.Row)117+ }118+ }119+ return nil120+}121+122+func (h scriptHost) Values(ref string) ([][]any, error) {123+ var rows [][]any124+ err := h.each(ref, func(s *sheet.Sheet, a sheet.Addr, row int) {125+ if row == len(rows) {126+ rows = append(rows, nil)127+ }128+ rows[row] = append(rows[row], scriptValue(s.Value(a)))129+ })130+ return rows, err131+}132+133+// scriptValue is a value as scripts see it.134+func scriptValue(v sheet.Value) any {135+ switch v.Kind {136+ case sheet.Number:137+ return v.Num138+ case sheet.Text, sheet.Error:139+ return v.Str140+ case sheet.Bool:141+ return v.Num != 0142+ }143+ return nil144+}145+146+func (h scriptHost) Formulas(ref string) ([][]string, error) {147+ var rows [][]string148+ err := h.each(ref, func(s *sheet.Sheet, a sheet.Addr, row int) {149+ if row == len(rows) {150+ rows = append(rows, nil)151+ }152+ f := ""153+ if c := s.Cell(a); c != nil && c.IsFormula() {154+ f = c.Input155+ }156+ rows[row] = append(rows[row], f)157+ })158+ return rows, err159+}160+161+func (h scriptHost) SetInput(ref, input string) error {162+ s, r, err := h.resolve(ref)163+ if err != nil {164+ return err165+ }166+ if n := (r.To.Col - r.From.Col + 1) * (r.To.Row - r.From.Row + 1); n > maxReadCells {167+ return fmt.Errorf("%s has more than %d cells to set at once", ref, maxReadCells)168+ }169+ return s.Batch(sheet.Change{Label: "set " + r.String(), Focus: r}, func() error {170+ for row := r.From.Row; row <= r.To.Row; row++ {171+ for col := r.From.Col; col <= r.To.Col; col++ {172+ if err := s.Set(sheet.Addr{Col: col, Row: row}, input); err != nil {173+ return fmt.Errorf("%s: %w", sheet.Addr{Col: col, Row: row}, err)174+ }175+ }176+ }177+ return nil178+ })179+}180+181+func (h scriptHost) SetInputs(ref string, rows [][]string) error {182+ s, r, err := h.resolve(ref)183+ if err != nil {184+ return err185+ }186+ return s.Batch(sheet.Change{Label: "set " + r.String(), Focus: r}, func() error {187+ for i, row := range rows {188+ for j, input := range row {189+ a := sheet.Addr{Col: r.From.Col + j, Row: r.From.Row + i}190+ if !a.Valid() {191+ return fmt.Errorf("the values run off the sheet at row %d, column %d", i+1, j+1)192+ }193+ if err := s.Set(a, input); err != nil {194+ return fmt.Errorf("%s: %w", a, err)195+ }196+ }197+ }198+ return nil199+ })200+}201+202+func (h scriptHost) SetFormula(ref, f string) error {203+ s, r, err := h.resolve(ref)204+ if err != nil {205+ return err206+ }207+ return s.FillEntry(r, r.From, f)208+}209+210+func (h scriptHost) Clear(ref string) error {211+ s, r, err := h.resolve(ref)212+ if err != nil {213+ return err214+ }215+ s.EraseRange(r)216+ return nil217+}218+219+func (h scriptHost) NumberFormat(ref string) (string, int, error) {220+ s, r, err := h.resolve(ref)221+ if err != nil {222+ return "", 0, err223+ }224+ f := s.DisplayFormat(r.From)225+ return f.Kind.String(), f.Decimals, nil226+}227+228+func (h scriptHost) SetNumberFormat(ref, kind string, decimals int, pattern string) error {229+ s, r, err := h.resolve(ref)230+ if err != nil {231+ return err232+ }233+ k, ok := sheet.ParseFormatKind(kind)234+ if !ok {235+ return fmt.Errorf("no number format %q; try number, percent, currency, date, time or custom", kind)236+ }237+ f := sheet.Preset(k)238+ if decimals >= 0 {239+ f.Decimals = min(decimals, sheet.MaxDecimals)240+ }241+ if k == sheet.FmtCustom && pattern == "" {242+ return errors.New("a custom format needs a pattern, e.g. pattern=\"0.0%\"")243+ }244+ f.Pattern = pattern245+ s.SetFormat(r, f)246+ return nil247+}
A internal/ui/macrohostnav.go +311 -0
@@ -0,0 +1,311 @@
1+package ui2+3+import (4+ "errors"5+ "fmt"6+ "slices"7+ "strings"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// What scripts do to the selection, the sheets and through commands; the13+// cells are in macrohost.go.14+15+func (h scriptHost) Selection() (string, string) {16+ return wholeRef(h.m.selection()), h.m.cur.String()17+}18+19+func (h scriptHost) Select(ref, active string) error {20+ m := h.m21+ s, r, err := h.resolve(ref)22+ if err != nil {23+ return err24+ }25+ m.showSheet(s)26+ m.selectRect(r)27+ if active == "" {28+ return nil29+ }30+ a, ok := sheet.ParseAddr(active)31+ if !ok || !r.Contains(a) {32+ return fmt.Errorf("the active cell %s isn't in %s", active, ref)33+ }34+ m.setActive(r, a)35+ return nil36+}37+38+// setActive makes a, inside the selected range r, the active cell, with39+// the far corner of r as the other end of the selection.40+func (g *grid) setActive(r sheet.Rect, a sheet.Addr) {41+ far := func(v, lo, hi int) int {42+ if v == lo {43+ return hi44+ }45+ return lo46+ }47+ g.cur = a48+ switch g.whole {49+ case wholeCols:50+ g.ext = sheet.Addr{Col: far(a.Col, r.From.Col, r.To.Col), Row: a.Row}51+ case wholeRows:52+ g.ext = sheet.Addr{Col: a.Col, Row: far(a.Row, r.From.Row, r.To.Row)}53+ case wholeNone:54+ g.ext = sheet.Addr{Col: far(a.Col, r.From.Col, r.To.Col), Row: far(a.Row, r.From.Row, r.To.Row)}55+ }56+}57+58+func (h scriptHost) Move(cols, rows int) error {59+ m := h.m60+ to := sheet.Addr{Col: m.cur.Col + cols, Row: m.cur.Row + rows}61+ if !to.Valid() {62+ return fmt.Errorf("moving %d, %d from %s goes off the sheet", cols, rows, m.cur)63+ }64+ m.cur = to65+ m.clearSelection()66+ return nil67+}68+69+func (h scriptHost) Extend(cols, rows int, whole string) error {70+ m := h.m71+ to := sheet.Addr{Col: m.cur.Col + cols, Row: m.cur.Row + rows}72+ if !to.Valid() {73+ return fmt.Errorf("extending %d, %d from %s goes off the sheet", cols, rows, m.cur)74+ }75+ m.selecting, m.ext, m.whole = true, to, wholeNone76+ switch whole {77+ case "columns":78+ m.whole = wholeCols79+ case "rows":80+ m.whole = wholeRows81+ }82+ return nil83+}84+85+// jumpKeys are the keys Jump's targets stand for.86+var jumpKeys = map[string]string{87+ "up": "ctrl+up", "down": "ctrl+down", "left": "ctrl+left", "right": "ctrl+right",88+ "home": "home", "start": "ctrl+home", "end": "ctrl+end",89+}90+91+func (h scriptHost) Jump(to string, extend bool) error {92+ key, ok := jumpKeys[to]93+ if !ok {94+ return fmt.Errorf("can't jump to %q", to)95+ }96+ if extend {97+ key = strings.Replace(key, "+", "+shift+", 1)98+ if !strings.Contains(key, "+") {99+ key = "shift+" + key100+ }101+ }102+ h.m.moveKey(key)103+ return nil104+}105+106+func (h scriptHost) Enter(text string, fill bool, origin string) error {107+ m := h.m108+ if origin != "" {109+ from, ok := sheet.ParseAddr(origin)110+ if !ok {111+ return fmt.Errorf("origin %q isn't a cell", origin)112+ }113+ var err error114+ if text, err = sheet.ShiftEntry(text, m.cur.Col-from.Col, m.cur.Row-from.Row); err != nil {115+ return err116+ }117+ }118+ if fill {119+ return m.sheet.FillEntry(m.selection(), m.cur, text)120+ }121+ if err := m.sheet.Set(m.cur, text); err != nil {122+ return err123+ }124+ m.clearSelection()125+ return nil126+}127+128+func (h scriptHost) PasteText(text string) error {129+ m := h.m130+ if !m.pasteText(text) {131+ if err := m.sheet.Set(m.cur, strings.TrimSpace(text)); err != nil {132+ return err133+ }134+ }135+ if m.mode == modeError {136+ return h.failure()137+ }138+ return nil139+}140+141+// failure turns an error a command reported to the user into the142+// script's error, leaving the screen as it was.143+func (h scriptHost) failure() error {144+ m := h.m145+ msg := m.errMsg146+ m.errMsg, m.mode = "", modeReady147+ return errors.New(msg)148+}149+150+func (h scriptHost) Sheets() []string {151+ var names []string152+ for _, s := range h.m.book().Sheets() {153+ names = append(names, s.Name())154+ }155+ return names156+}157+158+func (h scriptHost) ActiveSheet() string { return h.m.sheet.Name() }159+160+func (h scriptHost) ActivateSheet(name string) error {161+ s := h.m.book().Lookup(name)162+ if s == nil {163+ return fmt.Errorf("there's no sheet named %s", name)164+ }165+ h.m.showSheet(s)166+ return nil167+}168+169+func (h scriptHost) AddSheet(name string) (string, error) {170+ m := h.m171+ s, err := m.book().AddSheet(name, m.book().Index(m.sheet)+1)172+ if err != nil {173+ return "", err174+ }175+ m.showSheet(s)176+ return s.Name(), nil177+}178+179+func (h scriptHost) MoveSheet(pos int) error {180+ m := h.m181+ if pos < 1 || pos > m.book().Len() {182+ return fmt.Errorf("position %d isn't between 1 and %d", pos, m.book().Len())183+ }184+ m.book().MoveSheet(m.sheet, pos-1)185+ return nil186+}187+188+func (h scriptHost) SetWidth(cols string, width int) error {189+ r, ok := parseWhole(cols)190+ if !ok {191+ if c, isCol := sheet.ParseCol(strings.ToUpper(cols)); isCol {192+ r, ok = sheet.Rect{From: sheet.Addr{Col: c}, To: sheet.Addr{Col: c}}, true193+ }194+ }195+ if !ok {196+ return fmt.Errorf("not a column or columns: %s", cols)197+ }198+ if width < 1 || width > 240 {199+ return errors.New("column width must be between 1 and 240")200+ }201+ for c := r.From.Col; c <= r.To.Col; c++ {202+ h.m.sheet.SetColWidth(c, width)203+ }204+ return nil205+}206+207+func (h scriptHost) Fill(to string, rows, cols int) error {208+ m := h.m209+ src := m.selection()210+ dst := src211+ switch {212+ case to != "":213+ s, r, err := h.resolve(to)214+ if err != nil {215+ return err216+ }217+ if s != m.sheet {218+ return errors.New("fill stays on the sheet shown")219+ }220+ dst = r221+ case rows > 0:222+ dst.To.Row += rows223+ case rows < 0:224+ dst.From.Row += rows225+ case cols > 0:226+ dst.To.Col += cols227+ case cols < 0:228+ dst.From.Col += cols229+ }230+ if !dst.From.Valid() || !dst.To.Valid() {231+ return errors.New("the fill goes off the sheet")232+ }233+ got, err := m.sheet.FillSeries(src, dst)234+ if err != nil {235+ return err236+ }237+ m.selectRect(got)238+ return nil239+}240+241+// Run runs a command as if picked from the palette, answering the242+// question it asks, if any.243+func (h scriptHost) Run(id string, answer *string) error {244+ m := h.m245+ c, ok := commands[id]246+ switch {247+ case !ok:248+ return fmt.Errorf("no command %q; the palette (Ctrl+K) lists them, and docs/macros.md how to find ids", id)249+ case c.macro == macroNever:250+ return fmt.Errorf("%s (%s) can't run in a macro", c.title, id)251+ case !c.available(m):252+ return fmt.Errorf("%s can't run now", c.title)253+ }254+ m.macros.cmds = append(m.macros.cmds, m.runCommand(id))255+ return h.answer(c, answer)256+}257+258+// answer answers the question a command left open, or backs out of it259+// and reports that it needed one.260+func (h scriptHost) answer(c *command, answer *string) error {261+ m := h.m262+ switch {263+ case m.mode == modeError:264+ return h.failure()265+ case m.mode == modePrompt && answer != nil:266+ p := m.prompt267+ m.line.clear()268+ m.line.insert(*answer)269+ p.fresh, p.typing = false, p.kind == promptRange270+ m.macros.cmds = append(m.macros.cmds, p.accept(m))271+ case m.mode == modePrompt:272+ label := m.prompt.label273+ m.prompt.cancel(m)274+ return fmt.Errorf("%s asks %q: give run(%q, answer=...)", c.title, strings.TrimSuffix(label, ":"), c.id)275+ case m.overlay != nil:276+ return h.choose(c, answer)277+ }278+ if m.mode == modeError {279+ return h.failure()280+ }281+ if m.mode != modeReady {282+ m.closeOverlay()283+ return fmt.Errorf("%s asks more than one question; it can't run in a macro", c.title)284+ }285+ return nil286+}287+288+// choose answers a choice bar with the key or label of a choice; other289+// overlays (dialogs, pickers) can't be answered by a script.290+func (h scriptHost) choose(c *command, answer *string) error {291+ m := h.m292+ bar, ok := m.overlay.(*choiceBar)293+ if !ok {294+ m.closeOverlay()295+ return fmt.Errorf("%s opens a dialog; it can't run in a macro", c.title)296+ }297+ if answer != nil {298+ i := slices.IndexFunc(bar.choices, func(ch choice) bool {299+ return strings.EqualFold(ch.key, *answer) || strings.EqualFold(ch.label, *answer)300+ })301+ if i >= 0 {302+ m.macros.cmds = append(m.macros.cmds, bar.choose(m, bar.choices[i]))303+ if m.mode == modeError {304+ return h.failure()305+ }306+ return nil307+ }308+ }309+ m.closeOverlay()310+ return fmt.Errorf("%s asks %q: give run(%q, answer=...) with one of its keys", c.title, bar.msg, c.id)311+}
A internal/ui/macromanage.go +266 -0
@@ -0,0 +1,266 @@
1+package ui2+3+import (4+ "cmp"5+ "fmt"6+ "os"7+ "os/exec"8+ "slices"9+ "strconv"10+ "strings"11+12+ tea "charm.land/bubbletea/v2"13+14+ "github.com/FelineStateMachine/012/internal/macro"15+ "github.com/FelineStateMachine/012/internal/sheet"16+)17+18+// Managing macros, as Sheets' Extensions > Macros > Manage macros: a19+// picker lists them with their shortcuts; Enter runs one, F2 renames it,20+// F3 changes its shortcut, F4 opens its script in the user's editor, and21+// Ctrl+D deletes it. The first row writes a new one. Questions go on the22+// context line; the picker opens again after each.23+24+// macrosPicker is the picker of saved macros with its extra keys.25+type macrosPicker struct {26+ *picker27+ msg string // feedback on the last action, e.g. a deletion28+}29+30+const writeMacroTitle = "+ Write a macro"31+32+// openMacros opens the manager with the macro named sel highlighted.33+func (m *Model) openMacros(sel string) {34+ p := newPicker(m, "Macros", "Type a macro's name", 72, macroManageItems(m))35+ p.action = "run"36+ for i, pm := range p.shown {37+ if strings.EqualFold(pm.item.title, sel) {38+ p.sel = i39+ }40+ }41+ m.openOverlay(&macrosPicker{picker: p})42+}43+44+// macroManageItems lists "Write a macro" and then every macro.45+func macroManageItems(m *Model) []pickItem {46+ var items []pickItem47+ if m.macros.editor {48+ items = append(items, pickItem{49+ title: writeMacroTitle, name: len(writeMacroTitle), desc: "Write a new macro script in your editor",50+ pick: func(m *Model) tea.Cmd {51+ m.closeOverlay()52+ return m.newMacro()53+ },54+ })55+ }56+ for _, mc := range m.book().Macros() {57+ lines := strings.Count(strings.TrimRight(mc.Source, "\n"), "\n") + 158+ items = append(items, pickItem{59+ title: mc.Name, name: len(mc.Name), detail: strconv.Itoa(lines) + " lines", key: shortcutLabel(mc.Key),60+ desc: "Run " + mc.Name,61+ pick: func(m *Model) tea.Cmd {62+ m.closeOverlay()63+ return m.runMacro(mc)64+ },65+ })66+ }67+ return items68+}69+70+// current is the macro highlighted, if any.71+func (p *macrosPicker) current(m *Model) (sheet.Macro, bool) {72+ if p.picker.sel >= len(p.shown) || p.shown[p.picker.sel].item.title == writeMacroTitle {73+ return sheet.Macro{}, false74+ }75+ return m.book().Macro(p.shown[p.picker.sel].item.title)76+}77+78+func (p *macrosPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {79+ p.msg = ""80+ mc, ok := p.current(m)81+ switch k.String() {82+ case "f2", "f3", "f4", "ctrl+d":83+ if !ok {84+ return nil85+ }86+ default:87+ return p.picker.key(m, k)88+ }89+ switch k.String() {90+ case "f2":91+ m.closeOverlay()92+ m.renameMacro(mc)93+ case "f3":94+ m.closeOverlay()95+ m.setMacroShortcut(mc)96+ case "f4":97+ if !m.macros.editor {98+ p.msg = "No editor in this session"99+ return nil100+ }101+ m.closeOverlay()102+ return m.editScript(mc.Name, mc.Source, false)103+ case "ctrl+d":104+ m.book().DeleteMacro(mc.Name)105+ p.msg = "Deleted " + mc.Name + "; Ctrl+Z brings it back"106+ p.items = macroManageItems(m)107+ sel := p.picker.sel108+ p.changed(m)109+ p.picker.sel = max(min(sel, len(p.shown)-1), 0)110+ }111+ return nil112+}113+114+func (p *macrosPicker) status(m *Model) (string, string) {115+ pairs := []string{"Enter", "run", "F2", "rename", "F3", "shortcut", "F4", "edit", "Ctrl+D", "delete", "Esc", "close"}116+ if !m.macros.editor {117+ pairs = slices.Delete(pairs, 6, 8)118+ }119+ if m.width < 100 {120+ pairs = slices.DeleteFunc(pairs[:len(pairs)-2], func(s string) bool { return s == "shortcut" })121+ pairs = slices.Insert(pairs, slices.Index(pairs, "F3")+1, "key")122+ }123+ keys := m.th.KeyHints(pairs...)124+ if _, ok := p.current(m); !ok {125+ keys = m.th.KeyHints("Enter", "write", "Esc", "close")126+ }127+ if p.msg != "" {128+ return p.msg, keys129+ }130+ desc, _ := p.picker.status(m)131+ return desc, keys132+}133+134+// renameMacro asks for a new name on the context line.135+func (m *Model) renameMacro(mc sheet.Macro) {136+ m.openText("Rename macro "+mc.Name+":", mc.Name, func(m *Model, name string) tea.Cmd {137+ old := mc.Name138+ mc.Name = name139+ if err := m.book().SaveMacro(old, mc, "rename macro "+old); err != nil {140+ m.fail(err.Error())141+ return nil142+ }143+ m.openMacros(name)144+ return nil145+ })146+ m.prompt.indicator = "NAME"147+}148+149+// setMacroShortcut asks for the digit of a macro's shortcut.150+func (m *Model) setMacroShortcut(mc sheet.Macro) {151+ m.openText("Shortcut for "+mc.Name+": Ctrl+Alt+Shift+ (a digit, or empty for none):", mc.Key, func(m *Model, key string) tea.Cmd {152+ if key != "" && !isShortcutDigit(key) {153+ m.fail("A shortcut is one digit, 0 to 9, or nothing")154+ return nil155+ }156+ mc.Key = key157+ if err := m.book().SaveMacro(mc.Name, mc, "change the shortcut of macro "+mc.Name); err != nil {158+ m.fail(err.Error())159+ return nil160+ }161+ m.openMacros(mc.Name)162+ return nil163+ })164+ m.prompt.indicator = "KEY"165+}166+167+// newMacro asks for a name, then opens a new script in the editor.168+func (m *Model) newMacro() tea.Cmd {169+ m.openText("Name for the new macro:", nextMacroName(m.book()), func(m *Model, name string) tea.Cmd {170+ if err := checkNewMacroName(m.book(), name); err != nil {171+ m.fail(err.Error())172+ return nil173+ }174+ return m.editScript(strings.TrimSpace(name), newMacroTemplate, true)175+ })176+ m.prompt.indicator = "NAME"177+ return nil178+}179+180+const newMacroTemplate = `# A macro written by hand. It runs from the top as one undo step;181+# docs/macros.md lists what it can call. For example:182+#183+# set("A1", "Total")184+# set_formula("B1", "SUM(B2:B100)")185+# select("B1")186+# run("format.bold")187+`188+189+// macroEditedMsg says the editor closed on a macro's script.190+type macroEditedMsg struct {191+ name, path, orig string192+ isNew bool193+ err error194+}195+196+// editScript opens src in the user's editor ($VISUAL, $EDITOR or vi),197+// through a temporary file, and saves it as the macro name when the198+// editor exits. The screen is handed to the editor meanwhile.199+func (m *Model) editScript(name, src string, isNew bool) tea.Cmd {200+ f, err := os.CreateTemp("", "012-macro-*.star")201+ if err == nil {202+ _, err = f.WriteString(src)203+ if cerr := f.Close(); err == nil {204+ err = cerr205+ }206+ }207+ if err != nil {208+ m.fail("Couldn't write the script for the editor: " + err.Error())209+ return nil210+ }211+ msg := macroEditedMsg{name: name, path: f.Name(), orig: src, isNew: isNew}212+ return tea.ExecProcess(editorCommand(f.Name()), func(err error) tea.Msg {213+ msg.err = err214+ return msg215+ })216+}217+218+// editorCommand runs the user's editor on path.219+func editorCommand(path string) *exec.Cmd {220+ editor := strings.Fields(cmp.Or(os.Getenv("VISUAL"), os.Getenv("EDITOR"), "vi"))221+ return exec.Command(editor[0], append(editor[1:], path)...)222+}223+224+// macroEdited saves the script the editor wrote, and checks it: a225+// mistake is shown with its line and column, and the script is kept so226+// it can be fixed.227+func (m *Model) macroEdited(msg macroEditedMsg) {228+ defer os.Remove(msg.path)229+ if msg.err != nil {230+ m.fail(fmt.Sprintf("The editor failed: %v; the script is unchanged", msg.err))231+ return232+ }233+ data, err := os.ReadFile(msg.path)234+ if err != nil {235+ m.fail("Couldn't read the edited script: " + err.Error())236+ return237+ }238+ src := string(data)239+ if !msg.isNew && src == msg.orig {240+ m.note = "No changes to " + msg.name241+ return242+ }243+ before := len(m.book().Macros())244+ mc, old, label := sheet.Macro{Name: msg.name, Source: src}, "", "write macro "+msg.name245+ if !msg.isNew {246+ cur, ok := m.book().Macro(msg.name)247+ if !ok {248+ m.fail("The macro " + msg.name + " is gone; the edit wasn't saved")249+ return250+ }251+ mc, old, label = cur, cur.Name, "edit macro "+msg.name252+ mc.Source = src253+ }254+ if err := m.book().SaveMacro(old, mc, label); err != nil {255+ m.fail(err.Error())256+ return257+ }258+ if msg.isNew {259+ m.madeHere(before)260+ }261+ if err := macro.Check(msg.name, src); err != nil {262+ m.warn = "Saved " + msg.name + ", but it has a mistake at " + err.Error()263+ return264+ }265+ m.note = "Saved " + msg.name266+}
A internal/ui/macrorec.go +259 -0
@@ -0,0 +1,259 @@
1+package ui2+3+import (4+ "cmp"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/macro"10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// Recording a macro, as Sheets' Extensions > Macros > Record macro: while14+// recording, what the user does becomes a list of actions (the command15+// log), written out as a script when the recording is saved.16+//17+// What's recorded:18+// - commands that change the workbook, by id, where runCommand runs19+// them, with the answer to the question they ask (a width, a name, a20+// confirmation) once it's given;21+// - entries, when they're stored (enter), and pastes of text;22+// - the fill handle, column borders dragged or fitted, and tabs dragged;23+// - the selection, not the keys or clicks that made it: just before24+// something is recorded, the selection it acts on is, as select() with25+// absolute references or as move() and extend() from the last one with26+// relative references. So pressing Down five times is one move(0, 5),27+// and a replay doesn't depend on the window's size.28+//29+// With relative references, Ctrl+arrows, Home, Ctrl+Home and Ctrl+End are30+// recorded as jump(), since where they land depends on the data. Anything31+// else that changes the workbook (a dialog such as sorting by several32+// columns, the filter picker, dragging a chart, undo) becomes a comment33+// saying it wasn't recorded, so the script never silently differs from34+// what was done.35+36+// recorder is a recording in progress.37+type recorder struct {38+ relative bool39+ actions []macro.Action40+ base selState // the selection the recording has reached41+ pending string // a command waiting for the answer to its question42+ acted bool // something was recorded during this input event43+ depth int // commands running inside a recorded one44+}45+46+// selState is the selection as a recording sees it.47+type selState struct {48+ sheet *sheet.Sheet49+ cur, ext sheet.Addr50+ selecting bool51+ whole wholeKind52+}53+54+func (g *grid) selState() selState {55+ return selState{g.sheet, g.cur, g.ext, g.selecting, g.whole}56+}57+58+func (s selState) rect() sheet.Rect {59+ return (&grid{cur: s.cur, ext: s.ext, selecting: s.selecting, whole: s.whole}).selection()60+}61+62+// startRecording starts recording, from the selection as it is.63+func (m *Model) startRecording(relative bool) {64+ m.rec = &recorder{relative: relative, base: m.selState()}65+}66+67+// add records a, taking the selection after it as the new base.68+func (r *recorder) add(m *Model, a macro.Action) {69+ r.actions = append(r.actions, a)70+ r.base = m.selState()71+ r.acted = true72+}73+74+// flush records the selection, if it moved since the last action.75+func (r *recorder) flush(m *Model) {76+ now := m.selState()77+ if now == r.base {78+ return79+ }80+ r.actions = append(r.actions, r.selection(r.base, now)...)81+ r.base = now82+}83+84+// selection records the move from one selection to another.85+func (r *recorder) selection(from, to selState) []macro.Action {86+ if !r.relative || from.sheet != to.sheet || to.whole == wholeAll {87+ return []macro.Action{absoluteSelect(from, to)}88+ }89+ var out []macro.Action90+ dc, dr := to.cur.Col-from.cur.Col, to.cur.Row-from.cur.Row91+ if dc != 0 || dr != 0 || from.selecting && !to.selecting {92+ out = append(out, macro.Call("move", dc, dr))93+ }94+ if to.selecting && (from.cur != to.cur || from.ext != to.ext || from.whole != to.whole || !from.selecting) {95+ ext := macro.Call("extend", to.ext.Col-to.cur.Col, to.ext.Row-to.cur.Row)96+ switch to.whole {97+ case wholeCols:98+ ext = ext.With("whole", "columns")99+ case wholeRows:100+ ext = ext.With("whole", "rows")101+ }102+ out = append(out, ext)103+ }104+ return out105+}106+107+// absoluteSelect is select() of the selection to, naming its sheet when108+// it's another than from's, and the active cell when it isn't where109+// select() puts it.110+func absoluteSelect(from, to selState) macro.Action {111+ r := to.rect()112+ ref := wholeRef(r)113+ if to.sheet != from.sheet {114+ ref = sheet.QuoteSheet(to.sheet.Name()) + "!" + ref115+ }116+ a := macro.Call("select", ref)117+ if to.whole != wholeNone || to.cur != r.From {118+ a = a.With("active", to.cur.String())119+ }120+ return a121+}122+123+// recordingCommand runs c, recording it if it changes the workbook.124+func (m *Model) recordingCommand(c *command) tea.Cmd {125+ r := m.rec126+ if r.depth > 0 || c.macro != macroRecord {127+ return c.run(m)128+ }129+ r.flush(m)130+ r.depth++131+ cmd := c.run(m)132+ r.depth--133+ _, choosing := m.overlay.(*choiceBar)134+ switch {135+ case m.mode == modePrompt || choosing:136+ r.pending = c.id // recorded once answered137+ case m.overlay != nil:138+ // A dialog or picker: what it does isn't recorded (see observe).139+ case m.mode != modeError:140+ r.add(m, macro.Call("run", c.id))141+ }142+ return cmd143+}144+145+// recordAnswer records the command waiting for the answer to its146+// question, now that it has one. Esc on a choice cancels instead.147+func (m *Model) recordAnswer(answer string, cancelled bool) {148+ r := m.rec149+ if r == nil || r.pending == "" {150+ return151+ }152+ id := r.pending153+ r.pending = ""154+ if !cancelled && m.mode != modeError {155+ r.add(m, macro.Call("run", id).With("answer", answer))156+ }157+}158+159+// recordEntry records an entry stored in the active cell, or in every160+// selected cell with fill (Ctrl+Enter).161+func (m *Model) recordEntry(input string, fill bool) {162+ if m.rec == nil {163+ return164+ }165+ m.rec.flush(m)166+ a := macro.Call("enter", input)167+ if fill {168+ a = a.With("fill", true)169+ }170+ if m.rec.relative && sheet.IsFormulaEntry(input) {171+ a = a.With("origin", m.cur.String()) // references move with the active cell172+ }173+ m.rec.add(m, a)174+}175+176+// recordFlush records the selection an action is about to act on.177+func (m *Model) recordFlush() {178+ if m.rec != nil {179+ m.rec.flush(m)180+ }181+}182+183+// record records a after the selection it acted on (see recordFlush).184+func (m *Model) record(a macro.Action) {185+ if m.rec != nil {186+ m.rec.add(m, a)187+ }188+}189+190+// jumpTargets are the keys recorded as jump() with relative references.191+var jumpTargets = map[string]string{192+ "ctrl+up": "up", "ctrl+down": "down", "ctrl+left": "left", "ctrl+right": "right", "end": "right",193+ "home": "home", "ctrl+home": "start", "ctrl+end": "end",194+}195+196+// recordMove handles a movement key in READY, recording it as jump()197+// when it goes to a place that depends on the data.198+func (m *Model) recordMove(key string) bool {199+ r := m.rec200+ base, extend := extendKey(key)201+ if !extend {202+ base = key203+ }204+ to, jump := jumpTargets[base]205+ if r == nil || !r.relative || !jump {206+ return m.moveKey(key)207+ }208+ r.flush(m)209+ if !m.moveKey(key) {210+ return false211+ }212+ a := macro.Call("jump", to)213+ if extend {214+ a = a.With("extend", true)215+ }216+ r.add(m, a)217+ return true218+}219+220+// observe follows an input event while recording: with relative221+// references a new sheet shown is recorded right away (where its222+// selection starts can't be told later), and a change to the workbook223+// nothing recorded is noted as a comment.224+func (m *Model) observeRecording(before int) {225+ r := m.rec226+ if r == nil || m.mode != modeReady && m.mode != modeError || m.overlay != nil || m.prompt != nil {227+ return228+ }229+ if r.relative && m.sheet != r.base.sheet {230+ r.add(m, macro.Call("activate_sheet", m.sheet.Name()))231+ }232+ if m.sheet.StateID() != before && !r.acted {233+ what := cmp.Or(m.note, m.book().UndoLabel(), "a change")234+ r.actions = append(r.actions, macro.Note("Not recorded: "+strings.ToLower(what[:1])+what[1:]))235+ }236+}237+238+// recordingLine is the context line in READY while recording, when239+// there's nothing else to say.240+func (m *Model) recordingLine() string {241+ if m.rec == nil {242+ return ""243+ }244+ refs := "absolute"245+ if m.rec.relative {246+ refs = "relative"247+ }248+ return m.th.Hint.Render("Recording (" + refs + " references). Stop it in Data > Macros.")249+}250+251+// recordingHeader is the comment a recorded script starts with.252+func recordingHeader(relative bool) string {253+ if relative {254+ return "Recorded with relative references: moves count from the active cell.\n" +255+ "Edit it in Data > Macros > Manage macros; docs/macros.md lists what it can call."256+ }257+ return "Recorded with absolute references: cells are named as they were.\n" +258+ "Edit it in Data > Macros > Manage macros; docs/macros.md lists what it can call."259+}
A internal/ui/macrorun.go +238 -0
@@ -0,0 +1,238 @@
1+package ui2+3+import (4+ "errors"5+ "log/slog"6+ "time"7+8+ tea "charm.land/bubbletea/v2"9+10+ "github.com/FelineStateMachine/012/internal/macro"11+ "github.com/FelineStateMachine/012/internal/sheet"12+ "github.com/FelineStateMachine/012/internal/telemetry"13+)14+15+// Running a macro. The script runs on a goroutine of its own, so a long16+// or runaway one never freezes the screen and Esc can stop it; every call17+// it makes to the spreadsheet comes back to the UI goroutine as a message18+// and runs there, on the model, like a key press would. The whole run is19+// one undo step, opened when it starts and closed when it ends. While it20+// runs the mode indicator says CMD, as 1-2-3 did, and only Esc is taken.21+22+// macroState is the macros' part of the model: a run in progress, and23+// whether this file's macros may run without asking.24+type macroState struct {25+ run *macroRun26+ // cmds are commands the run's calls returned (copying sets the27+ // system clipboard), handed to Bubble Tea after the call.28+ cmds []tea.Cmd29+30+ // machine identifies this computer (see SetMachine); trusted is set31+ // once the user has agreed to run macros from a file made elsewhere.32+ machine string33+ trusted bool34+35+ // editor allows editing scripts in the user's editor, a program of36+ // their own; only the local app does (see AllowEditor).37+ editor bool38+}39+40+// macroRun is a script running.41+type macroRun struct {42+ name string43+ run *macro.Run44+ calls chan func() // from the script: a call to run on the UI goroutine45+ ack chan struct{} // to the script: the call has run46+ done chan macroDone47+ end func() // closes the undo step48+ start time.Time49+ span telemetry.Span50+ print string // the last line the script printed51+52+ orphan *time.Timer // see do; used only on the script's goroutine53+}54+55+type macroDone struct {56+ stats macro.Stats57+ err error58+}59+60+// macroCallMsg carries a call from a running script; macroDoneMsg says it61+// finished.62+type (63+ macroCallMsg struct {64+ r *macroRun65+ call func()66+ }67+ macroDoneMsg struct {68+ r *macroRun69+ done macroDone70+ }71+)72+73+// How the UI serves a script's calls: it keeps taking calls within one74+// update for up to sliceBudget, so a busy script isn't held up by a frame75+// per call, and gives the screen back when the script pauses for76+// idleWait (computing on its own) or the slice is used up.77+const (78+ sliceBudget = 12 * time.Millisecond79+ idleWait = 2 * time.Millisecond80+)81+82+// macroMaxSteps bounds every run; tests lower it.83+var macroMaxSteps uint64 = macro.DefaultMaxSteps84+85+// startMacro runs mc's script as one undo step.86+func (m *Model) startMacro(mc sheet.Macro) tea.Cmd {87+ if mc.API > sheet.MacroAPI {88+ m.fail("Macro " + mc.Name + " was written for a newer version of 012")89+ return nil90+ }91+ r := &macroRun{92+ name: mc.Name, calls: make(chan func()), ack: make(chan struct{}), done: make(chan macroDone, 1),93+ start: time.Now(), span: telemetry.Start("macro"),94+ }95+ r.end = m.book().Begin(sheet.Change{Label: "run macro " + mc.Name, Focus: m.selection(), Sheet: m.sheet})96+ r.run = macro.New(mc.Name, mc.Source, macro.Env{97+ Host: scriptHost{m},98+ Do: r.do,99+ Print: func(s string) { r.print = s },100+ MaxSteps: macroMaxSteps,101+ })102+ m.macros.run = r103+ m.note = ""104+ go func() {105+ st, err := r.run.Exec()106+ r.done <- macroDone{st, err}107+ }()108+ return r.wait()109+}110+111+// orphanAfter is how long a script waits for the UI to take a call before112+// deciding the program is gone (it quit, or its SSH session dropped) and113+// stopping, so its goroutine doesn't outlive the program. A live UI114+// always has a wait running, so it takes calls at once.115+var orphanAfter = 30 * time.Second116+117+// do hands fn to the UI goroutine and waits until it has run. It runs on118+// the script's goroutine.119+func (r *macroRun) do(fn func()) {120+ if r.orphan == nil {121+ r.orphan = time.NewTimer(orphanAfter)122+ } else {123+ r.orphan.Reset(orphanAfter)124+ }125+ select {126+ case r.calls <- fn:127+ <-r.ack128+ case <-r.orphan.C:129+ r.run.Cancel() // the call is dropped; the script stops at its next step130+ }131+}132+133+// wait is the command that waits for the script's next call or its end.134+func (r *macroRun) wait() tea.Cmd {135+ return func() tea.Msg {136+ select {137+ case fn := <-r.calls:138+ return macroCallMsg{r, fn}139+ case d := <-r.done:140+ return macroDoneMsg{r, d}141+ }142+ }143+}144+145+// serveMacro runs a script's call, then the calls that follow it for as146+// long as the slice lasts.147+func (m *Model) serveMacro(msg macroCallMsg) tea.Cmd {148+ r := msg.r149+ deadline := time.Now().Add(sliceBudget)150+ m.serveCall(r, msg.call)151+ idle := time.NewTimer(idleWait)152+ defer idle.Stop()153+ for time.Now().Before(deadline) {154+ select {155+ case fn := <-r.calls:156+ m.serveCall(r, fn)157+ idle.Reset(idleWait)158+ case d := <-r.done:159+ return m.finishMacro(r, d)160+ case <-idle.C:161+ return m.macroCmds(r.wait())162+ }163+ }164+ return m.macroCmds(r.wait())165+}166+167+// serveCall runs one call with the values of formulas up to date, and168+// lets the script go on.169+func (m *Model) serveCall(r *macroRun, fn func()) {170+ m.book().Settle()171+ fn()172+ r.ack <- struct{}{}173+}174+175+// macroCmds hands the commands the run's calls returned to Bubble Tea,176+// with next.177+func (m *Model) macroCmds(next tea.Cmd) tea.Cmd {178+ cmds := append(m.macros.cmds, next)179+ m.macros.cmds = nil180+ return tea.Batch(cmds...)181+}182+183+// finishMacro closes the run's undo step and says how it went on the184+// context line: what it printed last, or where it failed.185+func (m *Model) finishMacro(r *macroRun, d macroDone) tea.Cmd {186+ before := m.sheet.StateID()187+ r.end()188+ m.macros.run = nil189+ changed := m.sheet.StateID() != before190+ var me *macro.Error191+ outcome := "ok"192+ switch {193+ case d.err == nil && r.print != "":194+ m.note = r.name + ": " + r.print195+ case d.err == nil:196+ m.note = "Ran " + r.name197+ case errors.As(d.err, &me) && me.Cancel:198+ outcome = "cancelled"199+ m.warn = "Stopped " + r.name + " with Esc"200+ case errors.As(d.err, &me) && me.Limit:201+ outcome = "limit"202+ m.warn = d.err.Error()203+ default:204+ outcome = "error"205+ m.warn = d.err.Error()206+ }207+ if d.err != nil && changed {208+ m.warn += " Ctrl+Z undoes what it did"209+ }210+ r.span.End(slog.Int64("steps", int64(d.stats.Steps)), slog.Int("calls", d.stats.Calls), slog.String("outcome", outcome))211+ return m.macroCmds(nil)212+}213+214+// macroKey handles a key while a macro runs: Esc stops it, nothing else215+// gets through. It reports whether a macro is running.216+func (m *Model) macroKey(k tea.KeyPressMsg) bool {217+ r := m.macros.run218+ if r == nil {219+ return false220+ }221+ if k.String() == "esc" {222+ r.run.Cancel()223+ }224+ return true225+}226+227+// macroRunning reports whether msg is input to hold back while a macro228+// runs, so the script's view of the selection stays its own.229+func (m *Model) macroBusy(msg tea.Msg) bool {230+ if m.macros.run == nil {231+ return false232+ }233+ switch msg.(type) {234+ case tea.MouseMsg, tea.PasteMsg:235+ return true236+ }237+ return false238+}
A internal/ui/macroscript_test.go +181 -0
@@ -0,0 +1,181 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+ "time"7+8+ tea "charm.land/bubbletea/v2"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// Scripts run against the model: the API, errors, limits and Esc.14+15+func TestAScriptOutlivesNoProgram(t *testing.T) {16+ defer func(d time.Duration) { orphanAfter = d }(orphanAfter)17+ orphanAfter = 20 * time.Millisecond18+ m := newModel()19+ m.book().SaveMacro("", sheet.Macro{Name: "Spin", Source: "while True:\n move(0, 0)\n"}, "add")20+ m.trustHere()21+ m.runMacro(mustMacro(t, m, "Spin")) // its wait is never run: the program is gone22+ select {23+ case d := <-m.macros.run.done:24+ if d.err == nil {25+ t.Error("finished without an error")26+ }27+ case <-time.After(5 * time.Second):28+ t.Fatal("the script's goroutine is still waiting")29+ }30+}31+32+// script saves src as a trusted macro and runs it to the end.33+func script(t *testing.T, m *Model, src string) {34+ t.Helper()35+ old := ""36+ if hasMacro(m.book(), "S") {37+ old = "S"38+ }39+ if err := m.book().SaveMacro(old, sheet.Macro{Name: "S", Source: src}, "add"); err != nil {40+ t.Fatal(err)41+ }42+ m.trustHere()43+ run(m, m.runMacro(mustMacro(t, m, "S")))44+}45+46+func TestScriptsReadWhatTheyWrote(t *testing.T) {47+ m := newModel()48+ script(t, m, `49+set("A1:A3", [[1], [2], [3]])50+set_formula("B1:B3", "A1*10")51+total = 052+for row in get("B1:B3"):53+ total += row[0]54+set("C1", total)55+set("C2", get_formula("B2"))56+number_format("C1", "currency", decimals=0)57+set("C3", get_number_format("C1"))58+add_sheet("Two")59+set("A1", "on " + active_sheet())60+activate_sheet("Sheet1")61+select("A:B", active="B5")62+set("D1", selection() + " " + active_cell())63+print("total", total)64+`)65+ if m.warn != "" {66+ t.Fatal(m.warn)67+ }68+ for a, want := range map[string]string{"C1": "60", "C2": "=A2*10", "C3": "currency", "D1": "A:B B5"} {69+ if got := input(m, a); got != want {70+ t.Errorf("%s = %q, want %q", a, got, want)71+ }72+ }73+ if two := m.book().Lookup("Two"); two == nil || two.Cell(addr("A1")).Input != "on Two" {74+ t.Error("sheet Two")75+ }76+ if m.whole != wholeCols || m.cur != addr("B5") {77+ t.Errorf("selection %v whole %v", m.selection(), m.whole)78+ }79+ if m.note != "S: total 60" {80+ t.Errorf("note %q", m.note)81+ }82+}83+84+func TestScriptsAnswerQuestions(t *testing.T) {85+ m := newModel()86+ script(t, m, `87+add_sheet("Two")88+set("A1", 1)89+run("sheet.delete", answer="enter")90+select("A1:A3")91+run("data.define_name", answer="Sales")92+run("sheet.rename", answer="Budget")93+`)94+ if m.warn != "" {95+ t.Fatal(m.warn)96+ }97+ if m.book().Len() != 1 || m.sheet.Name() != "Budget" {98+ t.Errorf("sheets %d, %q", m.book().Len(), m.sheet.Name())99+ }100+ if n, ok := m.sheet.LookupName("Sales"); !ok || n.Range.String() != "A1:A3" {101+ t.Errorf("name %+v %v", n, ok)102+ }103+}104+105+func TestScriptErrorsShowWhere(t *testing.T) {106+ m := newModel()107+ script(t, m, "set(\"A1\", 5)\nrun(\"format.bold\")\nget(\"nowhere\")\n")108+ want := "S:3:4: get: not a cell or range: nowhere Ctrl+Z undoes what it did"109+ if m.warn != want {110+ t.Errorf("warn %q\nwant %q", m.warn, want)111+ }112+ if !strings.Contains(line(m, contextLine), "S:3:4: get: not a cell") {113+ t.Errorf("context line %q", line(m, contextLine))114+ }115+ press(t, m, "<ctrl+z>")116+ if m.sheet.Len() != 0 {117+ t.Errorf("undo left cells: note %q A1 %q undo %q", m.note, input(m, "A1"), m.book().UndoLabel())118+ }119+120+ for src, want := range map[string]string{121+ `run("file.save")`: "S:1:4: run: Save (file.save) can't run in a macro",122+ `run("column.width")`: `S:1:4: run: Column width asks "Column width (1-240)": give run("column.width", answer=...)`,123+ `run("no.such")`: `S:1:4: run: no command "no.such"`,124+ `run("data.sort_range")`: "S:1:4: run: Sort range opens a dialog; it can't run in a macro",125+ "move(0, -1)": "S:1:5: move: moving 0, -1 from A1 goes off the sheet",126+ } {127+ m := newModel()128+ script(t, m, src)129+ if !strings.HasPrefix(m.warn, want) {130+ t.Errorf("%s: %q, want %q", src, m.warn, want)131+ }132+ if m.mode != modeReady || m.overlay != nil || m.prompt != nil {133+ t.Errorf("%s left mode %v", src, m.mode)134+ }135+ }136+}137+138+func TestStepLimitStopsARunaway(t *testing.T) {139+ defer func(n uint64) { macroMaxSteps = n }(macroMaxSteps)140+ macroMaxSteps = 50_000141+ m := newModel()142+ script(t, m, "set(\"A1\", 1)\nwhile True:\n pass\n")143+ if !strings.HasPrefix(m.warn, "S:") || !strings.Contains(m.warn, ": stopped after 50") || !strings.HasSuffix(m.warn, "Ctrl+Z undoes what it did") {144+ t.Errorf("warn %q", m.warn)145+ }146+ if m.macros.run != nil || m.indicator() != "READY" {147+ t.Errorf("still running: %q", m.indicator())148+ }149+}150+151+func TestEscStopsARun(t *testing.T) {152+ m := newModel()153+ m.book().SaveMacro("", sheet.Macro{Name: "Spin", Source: "while True:\n move(0, 0)\n"}, "add")154+ m.trustHere()155+ cmd := m.runMacro(mustMacro(t, m, "Spin"))156+ if m.indicator() != "CMD" || !strings.Contains(line(m, contextLine), "Running Spin") {157+ t.Errorf("indicator %q, context %q", m.indicator(), line(m, contextLine))158+ }159+ msg := cmd()160+ press(t, m, "x") // held back while running161+ m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})162+ for range 1000 {163+ if _, done := msg.(macroDoneMsg); done {164+ break165+ }166+ _, next := m.Update(msg)167+ if next == nil {168+ break169+ }170+ msg = next()171+ if b, ok := msg.(tea.BatchMsg); ok {172+ msg = b[len(b)-1]()173+ }174+ }175+ if m.macros.run != nil {176+ m.Update(msg)177+ }178+ if m.macros.run != nil || m.warn != "Stopped Spin with Esc" || m.sheet.Len() != 0 {179+ t.Errorf("run %v warn %q cells %d", m.macros.run, m.warn, m.sheet.Len())180+ }181+}
M internal/ui/menubar.go +2 -1
M internal/ui/model.go +23 -4
@@ -68,6 +68,7 @@ type Model struct {
6868 6969 mode mode7070 note string // feedback on the last action, e.g. "Undid: clear B3"71+ warn string // like note, for something that went wrong, e.g. a macro's error7172 errMsg string // the message ERROR mode shows7273 7374 // Components. Each owns its state and the handling of the input it
@@ -88,7 +89,9 @@ type Model struct {
8889 term terminal // what the terminal supports: graphics.go8990 prefs prefs // the settings in effect and the theme chosen: prefs.go9091 91- vim vimState // a vim key sequence in progress: vim.go92+ vim vimState // a vim key sequence in progress: vim.go93+ rec *recorder // a macro being recorded: macrorec.go94+ macros macroState // a macro running, and trust in the file's macros: macrorun.go9295 9396 keyAt time.Time // when the key the next frame answers was pressed, for telemetry9497
@@ -119,6 +122,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
119122 if cmd, ok := m.importing(msg); ok {120123 return m, cmd121124 }125+ if m.macroBusy(msg) {126+ return m, nil127+ }128+ if m.rec != nil {129+ m.rec.acted = false130+ }122131 before, beforeMode := *m.focus(), m.mode123132 state := m.beginUpdate(msg)124133 var cmd tea.Cmd
@@ -161,6 +170,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
161170 cmd = m.answerJEV(msg)162171 case importedMsg, importTickMsg, exportedMsg:163172 cmd = m.handleTransfer(msg)173+ case macroCallMsg:174+ cmd = m.serveMacro(msg)175+ case macroDoneMsg:176+ cmd = m.finishMacro(msg.r, msg.done)177+ case macroEditedMsg:178+ m.macroEdited(msg)164179 default:165180 if !m.handlePrefs(msg) {166181 cmd = m.term.handle(msg)
@@ -184,7 +199,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
184199 func (m *Model) beginUpdate(msg tea.Msg) int {185200 switch msg.(type) {186201 case tea.KeyPressMsg, tea.MouseClickMsg, tea.PasteMsg:187- m.note = ""202+ m.note, m.warn = "", ""188203 if _, key := msg.(tea.KeyPressMsg); !key {189204 m.trace = nil // keys end a trace in handleKey190205 }
@@ -199,6 +214,7 @@ func (m *Model) beginUpdate(msg tea.Msg) int {
199214 // flag follows the undo history, and any edit but a paste clears the copy200215 // marker, as in Sheets.201216 func (m *Model) endUpdate(state int) {217+ m.observeRecording(state)202218 if m.sheet.StateID() != state {203219 m.changed = m.sheet.StateID() != m.saved204220 // An edit, a sort or a filter may hide the active cell's row.
@@ -217,7 +233,7 @@ func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {
217233 }218234 return nil // the mouse is busy filling219235 }220- if m.traceKey(k) {236+ if m.macroKey(k) || m.traceKey(k) {221237 return nil222238 }223239 switch m.mode {
@@ -247,7 +263,7 @@ func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {
247263 }248264 }249265 key := k.String()250- if m.moveKey(key) {266+ if m.recordMove(key) {251267 m.entry.tabbing = false252268 return nil253269 }
@@ -258,6 +274,9 @@ func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {
258274 if id, ok := keymap[canonicalKey(key)]; ok {259275 return m.runCommand(id)260276 }277+ if cmd, ok := m.runShortcut(key); ok {278+ return cmd279+ }261280 if text := typed(k); text != "" {262281 m.startEntry(modeEnter, text)263282 }
M internal/ui/model_test.go +1 -1
M internal/ui/mouse.go +2 -0
M internal/ui/names.go +1 -1
M internal/ui/palette.go +2 -1
M internal/ui/panel.go +10 -0
M internal/ui/prefs.go +4 -4
M internal/ui/prompt.go +7 -2
M internal/ui/tabs.go +3 -3
M internal/ui/tabstrip.go +2 -0
M internal/ui/theme/theme.go +2 -0
M internal/ui/trace.go +2 -2
M internal/ui/transfer.go +1 -1
M internal/ui/vimpref.go +1 -1