- commit
- 9dc8ad42b4dd0f31c49d216ce430f8930df7e164
- parent
- 74a712598c4003cd48a56912340aa0cb8d293aa7
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 5 | + |
| M | ROADMAP.md | 2 | +- |
| M | docs/ | 1 | + |
| M | docs/ | 37 | ++++- |
| M | docs/ | 43 | ++++- |
| M | docs/ | 5 | + |
| M | docs/ | 4 | +- |
| M | docs/ | 22 | +++ |
| A | docs/ | 242 | ++++++++++++++++++++++++++++++ |
| M | docs/ | 1 | + |
| M | docs/ | 4 | + |
| M | e2e/ | 8 | +- |
| M | internal/ | 10 | + |
| A | internal/ | 70 | +++++++++ |
| M | internal/ | 130 | ---------------- |
| M | internal/ | 2 | +- |
| A | internal/ | 164 | ++++++++++++++++++++ |
@@ -49,6 +49,10 @@ searches every command.
4949 - **Files.** A diff-friendly JSON format, plus import from CSV, TSV, XLSX,5050 SQLite, Parquet and Lotus 1-2-3 `.wk1`, and export to CSV, TSV, XLSX and5151 SQLite.52+- **Macros.** Record what you do with absolute or relative references and53+ replay it with Ctrl+Alt+Shift+digit, as one undo step; macros are readable54+ Starlark scripts saved in the file, with step limits and no file or55+ network access ([docs](docs/macros.md)).5256 - **JEV functions.** `JEV.TEST`, `JEV.PROB`, `JEV.CLASSIFY` and `JEV.SCORE`5357 ask TypeSafe's hosted JEV model about your data, answered in the background5458 and cached ([docs](docs/jev.md)).@@ -79,6 +83,7 @@ kitty, Ghostty and WezTerm they are real images.
7983 - [Charts, links and the terminal](docs/charts.md)8084 - [Files](docs/files.md)8185 - [JEV functions](docs/jev.md)86+- [Macros](docs/macros.md): recording, running, and the scripting API8287 - [Architecture](docs/architecture.md), [UX bar](docs/UX.md) and [testing](docs/testing.md)8388 - [Limits](docs/limits.md): how big a sheet 012 handles and where it slows down8489 - [Observability](docs/observability.md): event logs, DuckDB, and a local Collector, ClickHouse and Grafana stack@@ -75,7 +75,7 @@ Avoid in the main binary: DuckDB and automerge (both need cgo).
7575 7676 - Multiple sheets with references between them, in Sheets' style (`Sheet2!A1`) rather than 1-2-3 R3's `B:A1`, and sheet tabs. Done.7777 - Pivot tables as derived sheets. L.78-- Macros: replay the command log; Starlark (`go.starlark.net`) for scripts, with step limits.78+- Macros: replay the command log; Starlark (`go.starlark.net`) for scripts, with step limits. Done: Data > Macros records (absolute or relative references), saves, runs (Ctrl+Alt+Shift+digit, the palette) and manages macros, stored as Starlark in the file; see [docs/macros.md](docs/macros.md).7979 - Serve over SSH with `charm.land/wish/v2`.8080 - Decimal mode for currency (`cockroachdb/apd/v3`), opt-in. Done: File > Settings > Decimal arithmetic, boundary in the README.8181 - Demos in CI with VHS tapes; screenshots with freeze. Tapes done (`make demos`, `demos/`); not yet run in CI.@@ -9,6 +9,7 @@
99 - [Charts, links and the terminal](charts.md): charts, hyperlinks, terminal features used1010 - [Files](files.md): the `.012` format, import and export1111 - [JEV functions](jev.md): asking a hosted model from formulas12+- [Macros](macros.md): recording, running and managing macros; the Starlark scripting API1213 1314 **Working on 012**1415 @@ -11,6 +11,7 @@ internal/numfmt number formats, rounding, General, date serials
1111 internal/fileio import and export: CSV, TSV, XLSX, SQLite, Parquet, Lotus .wk11212 internal/chart chart layout, text rendering and kitty image encoding1313 internal/jev JEV configuration, answer cache and TypeSafe client14+internal/macro macros: the Starlark scripting API, recorded actions as scripts, step limits1415 internal/telemetry opt-in JSON log and OTLP export of spans, events and frame stats1516 internal/stress synthetic worst-case sheets for the -tags stress benchmarks1617 internal/ui the Bubble Tea model: modes, menus, overlays, rendering@@ -123,8 +124,8 @@ description (`commands.go`), reached from key bindings, the menu bar,
123124 context menus, the command palette, the help overlay and mouse gestures124125 that stand for one (double-clicking a tab renames it through125126 `sheet.rename`), so they can't disagree. `runCommand` is the one place a126-command runs: telemetry times it there, and a command log or macro127-recorder would attach there.127+command runs: telemetry times it there, and the macro recorder listens128+there (see [Macros](#macros)).128129 129130 **Model and components.** `Model` (`model.go`) is the root: it holds the130131 file, the mode and the note on the context line, owns one component for@@ -142,6 +143,7 @@ draw. The components:
142143 | sheet tabs | `tabStrip` | where each sheet was left, the tab strip's scroll, layout and clicks (`tabstrip.go`) |143144 | mouse | `mouseState` | drags, hover, double clicks, the fill handle (`mouse.go`, `fill.go`) |144145 | import | `transfer` | the import in progress, its progress display and cancelling (`transfer.go`) |146+| 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`) |145147 | 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 |146148 147149 Overlays implement the `overlay` interface (`overlay.go`): an indicator@@ -191,6 +193,37 @@ this much (the builder, number formats, serial dates and Excel formula
191193 translation); a format that grew its own dependencies would move to a192194 subpackage behind the same table row.193195 196+## Macros197+198+Macros are Starlark scripts (`go.starlark.net`, pure Go) kept in the199+workbook (`sheet.Macro`, saved in the file). `internal/macro` knows200+neither the engine nor the terminal: it defines the functions scripts call201+over a `Host` interface, turns a recording (a list of `Action`s, the202+command log) into a script, and runs scripts with a step limit, a cancel203+switch and errors placed at their line and column. Scripts have no204+`load`, and Starlark itself has no file, network, clock or random access,205+so the Host is the only way out.206+207+The UI implements the Host on the model (`scriptHost`), through the same208+paths keys take: entries go through `Sheet.Set`, the selection through209+the grid, commands through `runCommand`, with a question answered as if210+typed. A run's script executes on its own goroutine, and each call it211+makes comes back to the UI goroutine as a message (`macroCallMsg`), is212+run on the model, and releases the script; the UI keeps taking calls213+within one update for a few milliseconds, so a 1000-action replay costs214+about 2 ms, and gives the screen back when the script pauses, so Esc can215+stop a runaway. The whole run is one undo step opened with216+`Workbook.Begin`.217+218+Recording (`macrorec.go`) listens where actions happen: `runCommand` for219+commands (with the answer to a prompt or choice bar), the entry commit,220+pastes, the fill handle, column borders and tab drags. The selection is221+recorded lazily, as absolute `select()` or relative `move()`/`extend()`,222+just before something acts on it. Files record the computer their macros223+were made or trusted on (an id from `cmd/012`); a macro from elsewhere224+asks once before it runs. Only the local app lets scripts be edited in225+the user's editor (`AllowEditor`), since that starts a program.226+194227 ## Charts195228 196229 `internal/chart` draws a chart in two layers that share one layout: text@@ -15,7 +15,7 @@ shows it is derived from the table rather than listing it again.
1515 | Registry | Entry | Derived from it |1616 |---|---|---|1717 | `sheet.FuncDef` | a function: name, signature, description, arity, eval | parsing, autocomplete, argument hints, help, [functions.md](functions.md) |18-| `ui.command` | an action: id, title, description, run, enabled, checked | key bindings, menu bar, context menus, palette, shortcuts help |18+| `ui.command` | an action: id, title, description, run, enabled, checked, and how macros treat it | key bindings, menu bar, context menus, palette, shortcuts help, 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 | a role: dark and light styles on the 16 ANSI colors | every style in the UI |@@ -36,8 +36,24 @@ composited over the finished grid, so a new one never shifts the layout.
3636 ### 3. Actions go through commands3737 3838 Every user action is a registered command, whatever reaches it (key, menu,39-mouse, palette). This keeps discoverability automatic, and gives one place40-to add a command log for macros and replay later.39+mouse, palette). This keeps discoverability automatic, and `runCommand`,40+the one place commands run, is where the macro recorder listens: a command41+that changes the workbook is recorded by id (`run("format.bold")`), with42+the answer to the question it asks. Each command says how macros treat it43+(`command.macro`): recorded (the default), a view command recorded as the44+selection it leaves (Go to, Select all, Next sheet), or never recorded nor45+run from scripts (files, menus, help, undo, macros).46+47+The rest of the command log is what isn't a command but matters to a48+replay: entries as they're accepted, pastes of text, the fill handle,49+column borders and dragged tabs, each recorded where it happens with50+`Model.record`, and the selection, recorded as a state (where it is just51+before something acts on it) rather than as the keys or clicks that moved52+it. So movement keys, typing and mouse selection needn't become commands53+to be recorded, and a replay doesn't depend on the window's size. A new54+action that changes the workbook outside a command records itself the same55+way; anything unrecorded that changes the workbook while recording becomes56+a comment in the script, so a gap shows.4157 4258 ### 4. Side effects stay at the edges4359 @@ -56,9 +72,14 @@ the same way.
5672 Every change to a workbook goes through `Batch(Change{...}, fn)` and the5773 history `step`: snapshot what changes, apply, mark dirty, recalculate. Undo,5874 telemetry, recalculation and the file's dirty flag hang off that one path,59-and so will anything later that needs to see every change (macros, a60-command log, live sharing). New mutations use it; they never write cells61-around it.75+and so will anything later that needs to see every change (live sharing).76+New mutations use it; they never write cells around it. A change made over77+several calls, as a macro run makes one call per message, opens its step78+with `Workbook.Begin` and closes it when done, so it undoes as one; inside79+it, `Settle` recalculates what changed so far, so what reads formulas80+(scripts, sorting) sees current values. The macro list itself is changed81+through the same steps, so saving a macro is undoable and marks the file82+modified.6283 6384 ### 6. Storage behind a small API6485 @@ -118,7 +139,9 @@ Where the code doesn't follow the patterns yet:
118139 - Components in `internal/ui` are handed the whole `*Model` rather than a119140 narrower interface, so they stay in package `ui` (pattern 2).120141 - Movement keys, typing, F4 in formulas, Alt+letter menus and direct mouse121- manipulation (resizing columns, the fill handle, dragging charts and tabs,122- filter buttons) act without a registered command. Making them commands123- would add palette and help entries, so they wait for the command log124- (pattern 3).142+ manipulation act without a registered command (pattern 3). The macro143+ recorder covers what matters for replay without them (entries, the144+ selection, pastes, the fill handle, column borders, dragged tabs), but145+ dragging and resizing charts and the choices made in dialogs (the sort146+ bar, the filter picker, find and replace, the chart editor) aren't147+ recorded: a recording notes them as comments.@@ -38,6 +38,11 @@ only written when a sheet uses one of them, so older builds of 012 can open
3838 everything else. Charts are an optional `charts` field that older builds3939 ignore. Saves are atomic: 012 writes a temporary file and renames it.4040 41+Macros are an optional `macros` list, one macro per line with its42+Starlark script as a string, and `macroOrigin`, the computer they were43+made or trusted on; neither raises the version, and older builds ignore44+them. Opening a file never runs its macros. See [macros.md](macros.md#in-the-file).45+4146 Other formats import as one sheet named after the file (or the SQLite4247 table); CSV and TSV downloads write the sheet shown, as Sheets' do.4348 @@ -50,6 +50,8 @@ Inside the grid, 012 works like Google Sheets.
5050 | Insert > Chart | Chart the selection (or the table around the active cell); the editor bar picks the type with Left/Right, S switches rows and columns, H and L toggle the header row and labels, R changes the range, T the title |5151 | Click a chart, then Arrows / Shift+arrows / Del | Move, resize or delete it; drag the chart or its corner with the mouse; Enter edits it |5252 | Cmd- or Ctrl-click a URL | Open it: cells holding a URL, and `=HYPERLINK(url, [label])`, are terminal hyperlinks |53+| Data > Macros | Record what you do as a macro (absolute or relative references), stop and save it with a name and shortcut, run or manage saved macros; see [macros.md](macros.md) |54+| Ctrl+Alt+Shift+0 ... 9 | Run the macro with that shortcut; Esc stops a macro while it runs (the indicator says CMD) |5355 5456 ## Mouse5557 @@ -75,7 +77,7 @@ restores that on exit.
7577 ## The screen7678 7779 The top three lines are the **menu bar** with the mode indicator on the78-right, the **formula bar** (name box, then the cell's contents or the entry80+right (and `REC` beside it while a macro is recorded), the **formula bar** (name box, then the cell's contents or the entry7981 being typed) and the **context line**, which holds prompts, key hints,8082 formula errors and explanations. The bottom line is the **status line**:8183 the sheet tabs (as tmux lists its windows, the sheet shown highlighted),@@ -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 | 100 steps that each rewrite a whole column: 217 MB held | | Whole-cell before-images, 264 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 ms, one undo step; a script's call to the sheet: about 1.3 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 3334 ## Sheet size@@ -187,6 +188,27 @@ Cell reads now go through one more function call to resolve the sheet,
187188 which is most of the 3 to 5% on read-heavy shapes; undo steps key their188189 before-images by sheet and address, which is BigUndo's extra allocation.189190 191+## Macros192+193+A macro's script runs on its own goroutine; every call it makes to the194+sheet (an entry, a move, a command) is served on the UI goroutine as a195+message, in batches of up to 12 ms per update, so replaying doesn't wait196+a frame per call and the screen stays live for Esc. The run is one undo197+step whatever its size (`BenchmarkMacroReplay`, `BenchmarkMacroScript`,198+`internal/ui`, `-tags stress`, `BENCHTIME=2s`):199+200+| Benchmark | Time | Per action or call | Allocations |201+|---|---|---|---|202+| Replay 1000 recorded actions, absolute references (entries, selects, extends, a command) | 2.05 ms | 2.0 us | 30 k, 1.6 MB |203+| The same with relative references (moves, formulas moved with the active cell) | 2.07 ms | 2.1 us | 31 k, 1.7 MB |204+| A script's loop: 5000 `set` and 5000 `get` calls | 13.2 ms | 1.3 us per call | 220 k, 7.8 MB |205+206+Reading a range walks its cells; a range larger than 4096 cells (whole207+columns) is first trimmed to the used range, which costs a pass over the208+sheet, so reading single cells never does. Every run stops after 10209+million Starlark steps (`macro.DefaultMaxSteps`), about a second of pure210+computation.211+190212 ## Hotspots found and fixed191213 192214 Each with its benchmark before and after, in the commit that fixed it.@@ -0,0 +1,242 @@
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+configuration directory, `$O12_CONFIG_DIR` or `012` under the system's,106+such as `~/Library/Application Support/012` or `~/.config/012`). Running107+a macro from a file made elsewhere asks once, on the context line:108+109+```110+Trust this file's macros? Enter Run Esc Cancel111+```112+113+Enter trusts all of the file's macros for this session and, once the file114+is saved, on this computer. Sessions served over SSH have no id of their115+own, so they ask once per session.116+117+Scripts can't read or write files, reach the network, start programs, or118+read the clock or random numbers: all they can reach is the functions119+below. What a macro can do is what you could do with the keyboard in that120+workbook, which is why it asks before running someone else's.121+122+## Writing scripts123+124+A script runs from top to bottom. It's Starlark: Python's syntax125+(`for`, `if`, `def`, lists, dicts, string formatting, `while`) without126+imports, classes or exceptions. Statements may be at the top level.127+128+```python129+# Total every numeric column of the selection, in the row below it.130+sel = selection() # e.g. "B2:D9"131+rows = get(sel)132+if type(rows) != "list":133+ rows = [[rows]] # a single cell134+below = offset(sel.split(":")[-1], 0, 1)135+first = offset(sel.split(":")[0], 0, len(rows))136+for c in range(len(rows[0])):137+ col = [r[c] for r in rows]138+ if all([type(v) in ("int", "float") for v in col]):139+ cell = offset(first, c, 0)140+ set_formula(cell, "SUM(%s:%s)" % (offset(sel.split(":")[0], c, 0), offset(cell, 0, -1)))141+select(first)142+run("format.bold")143+```144+145+References are strings in A1 notation: a cell (`"B3"`), a range146+(`"B3:D9"`), whole columns (`"B:D"`) or rows (`"3:5"`), a named range147+(`"Sales"`), any of them after a sheet name (`"Sheet2!A1"`,148+`"'Q3 plan'!A1:B2"`). Values come back as Starlark values: whole numbers149+as `int`, others as `float`, text as `str`, `True` and `False`, `None`150+for a blank cell, and error values as their code (`"#DIV/0!"`).151+152+### Cells153+154+| Function | Does |155+|---|---|156+| `get(ref)` | The value of a cell, or a list of rows of values for a range |157+| `get_formula(ref)` | A cell's formula as typed (`"=SUM(B2:B8)"`), `""` if it holds none; a list of rows for a range |158+| `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 |159+| `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 |160+| `clear(ref=None)` | Clear contents, keeping formatting; the selection by default |161+| `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%"` |162+| `get_number_format(ref)` | The kind of a cell's number format |163+| `offset(ref, cols=0, rows=0)` | `ref` moved, keeping its size and sheet: `offset("A1", 2, 3)` is `"C4"` |164+165+Formulas read inside a script are up to date: a value set a line earlier166+is already in the formulas that use it.167+168+### The selection169+170+| Function | Does |171+|---|---|172+| `selection()` | The selected range, e.g. `"B2:D9"`, `"B:D"` |173+| `active_cell()` | The active cell, e.g. `"B2"` |174+| `select(ref, active=None)` | Select a cell or range, on another sheet if it names one; `active` puts the active cell elsewhere in it |175+| `move(cols=0, rows=0)` | Move the active cell, dropping the selection |176+| `extend(cols=0, rows=0, whole=None)` | Select from the active cell to `cols`, `rows` away; `whole="columns"` or `"rows"` selects whole ones |177+| `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) |178+| `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 |179+| `paste_text(text)` | Paste tab-separated text from the active cell, as pasting from another program |180+| `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) |181+182+### Sheets183+184+| Function | Does |185+|---|---|186+| `sheets()` | The sheet names, in tab order |187+| `active_sheet()` | The name of the sheet shown |188+| `activate_sheet(name)` | Show a sheet |189+| `add_sheet(name=None)` | Add a sheet after the one shown and show it; returns its name |190+| `move_sheet(position)` | Move the sheet shown to a position, counting from 1 |191+| `set_width(cols, width)` | Set column widths, 1 to 240: `set_width("B", 14)`, `set_width("B:D", 8)` |192+193+### Commands194+195+`run(id, answer=None)` runs any command from the menus or palette by its196+id, on the selection, as if picked. A command that asks something (a197+width, a name, a confirmation) needs `answer`: the text you'd type, a range198+for a range question, or the key of a choice (`"enter"`, `"d"`) or its199+label. Commands that open a dialog or picker (Sort range, Insert chart,200+Named ranges) can't run in a script; nor can files, menus, help, undo or201+macros. The ids are those in the recorded scripts; a few:202+203+| Id | Command |204+|---|---|205+| `format.bold`, `format.italic`, `format.underline`, `format.strikethrough` | Text styles (toggles, as the keys) |206+| `format.currency`, `format.percent`, `format.date`, `format.decimals_more`, ... | Number formats |207+| `format.align_left`, `format.align_center`, `format.align_right`, `format.clear` | Alignment, clear formatting |208+| `edit.copy`, `edit.cut`, `edit.paste`, `edit.paste_values` | Clipboard (within 012; copying also sets the system clipboard) |209+| `edit.fill_down`, `edit.fill_right`, `clear` | Fill, clear the selection |210+| `insert.row_above`, `insert.row_below`, `insert.col_left`, `insert.col_right`, `delete.row`, `delete.col` | Rows and columns |211+| `column.width` (answer: the width), `column.reset` | Column widths |212+| `data.sort_sheet_az`, `data.sort_range_az`, `data.sort_range_za`, `data.filter`, `data.filter_remove` | Sorting and filters |213+| `data.define_name` (answer: the name) | Name the selection |214+| `sheet.new`, `sheet.duplicate`, `sheet.rename` (answer: the name), `sheet.delete` (answer: `"enter"` when it asks) | Sheets |215+| `view.freeze_rows1`, `view.freeze_cols1`, `view.freeze_rows0`, ... | Frozen panes |216+217+Record a macro that uses a command to see its id.218+219+### Output and errors220+221+`print(...)` shows its last line on the context line when the macro ends.222+A failure stops the macro and shows where it happened; there's no223+`try`. Calling a function wrongly (`move(1, 2, 3)`) or on a reference224+that doesn't exist fails the same way. Only what the functions above do is225+possible: no `load`, no files, no time, no randomness, so a script does226+the same thing every time it runs on the same sheet.227+228+## In the file229+230+Macros are saved in the `.012` file, one line each, after the named231+ranges:232+233+```json234+"macroOrigin": "3f9c0b1e5a7d42c8b61f0e9a2d4c7b18",235+"macros": [236+ {"name": "Totals", "key": "1", "api": 1, "source": "# Recorded with relative references...\nenter(\"Total\")\n"}237+],238+```239+240+`api` is the version of the functions above the script was written for.241+The file's version doesn't change: older builds of 012 open files with242+macros and ignore them (and drop them if they save).@@ -53,6 +53,7 @@ same, as an attribute for queries), `service` (`012`) and `dur_ms`.
5353 | `frames` | once a second while frames are drawn | `frames`, `render_p50_ms`, `render_p95_ms`, `render_max_ms` (View), `keys`, `key_p50_ms`, `key_p95_ms`, `key_max_ms` (key press to the end of its frame), `heap_bytes`, and gauges: `cells`, `jev_in_flight`, `jev_queued` |5454 | `frame` | every frame, at debug level | `key_ms` |5555 | `command` | every registered command | `id`, e.g. `data.sort` |56+| `macro` | each macro run, from start to end | `steps` (Starlark steps), `calls` (calls to the spreadsheet), `outcome` (`ok`, `error`, `cancelled` or `limit`) |5657 | `sort`, `filter`, `find`, `replace`, `fill` | the operation itself | `rows`, `cols`, `keys`; `hidden`; `matches`; `replaced`; `cells` |5758 | `import`, `export` | file transfers (`internal/fileio`) | `format`, `bytes`, `rows`, `cells`, `notes` |5859 | `save`, `open` | the native `.012` file | `cells`, `bytes` (save times serializing, on the UI goroutine) |@@ -28,6 +28,10 @@ cursor position, window title, hyperlinks, which screen is active after
2828 quitting. A fake TypeSafe server answers JEV questions, and the harness2929 clears `TYPESAFE_API_KEY` so tests never reach the real service.3030 31+Each session keeps 012's configuration (the machine id that macros'32+trust uses, `O12_CONFIG_DIR`) in its working directory, so sessions that33+share a directory are one computer and tests never touch the real one's.34+3135 `make e2e` builds libghostty-vt from source with Zig into `.deps/` on first3236 use. It is its own Go module so cgo never reaches the main binary.3337 @@ -67,15 +67,15 @@ type options struct {
6767 cols, rows uint16 // defaults to 100x306868 light bool // use the light reference palette6969 env []string // extra environment, e.g. a fake JEV endpoint70- // config is where 012 keeps its machine id for macros71- // (O12_CONFIG_DIR); by default inside dir, so sessions sharing a72- // directory are one computer and nothing touches the real one's.73- config string7470 jev bool // answer JEV functions with a fake service (screens)7571 // graphics turns on the kitty graphics protocol, so charts become7672 // images; off by default, so screens show the text charts every7773 // terminal gets.7874 graphics bool75+ // config is where 012 keeps its machine id for macros76+ // (O12_CONFIG_DIR); by default inside dir, so sessions sharing a77+ // directory are one computer and nothing touches the real one's.78+ config string7979 }8080 8181 // start launches 012 in dir (a fresh temp dir if empty) with args.@@ -408,3 +408,13 @@ func offset(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error
408408 }409409 return starlark.String(out), nil410410 }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+}@@ -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+}@@ -232,136 +232,6 @@ func TestStopWithNothingRecorded(t *testing.T) {
232232 }233233 }234234 235-// script saves src as a trusted macro and runs it to the end.236-func script(t *testing.T, m *Model, src string) {237- t.Helper()238- old := ""239- if hasMacro(m.book(), "S") {240- old = "S"241- }242- if err := m.book().SaveMacro(old, sheet.Macro{Name: "S", Source: src}, "add"); err != nil {243- t.Fatal(err)244- }245- m.trustHere()246- run(m, m.runMacro(mustMacro(t, m, "S")))247-}248-249-func TestScriptsReadWhatTheyWrote(t *testing.T) {250- m := newModel()251- script(t, m, `252-set("A1:A3", [[1], [2], [3]])253-set_formula("B1:B3", "A1*10")254-total = 0255-for row in get("B1:B3"):256- total += row[0]257-set("C1", total)258-set("C2", get_formula("B2"))259-number_format("C1", "currency", decimals=0)260-set("C3", get_number_format("C1"))261-add_sheet("Two")262-set("A1", "on " + active_sheet())263-activate_sheet("Sheet1")264-select("A:B", active="B5")265-set("D1", selection() + " " + active_cell())266-print("total", total)267-`)268- if m.warn != "" {269- t.Fatal(m.warn)270- }271- for a, want := range map[string]string{"C1": "60", "C2": "=A2*10", "C3": "currency", "D1": "A:B B5"} {272- if got := input(m, a); got != want {273- t.Errorf("%s = %q, want %q", a, got, want)274- }275- }276- if two := m.book().Lookup("Two"); two == nil || two.Cell(addr("A1")).Input != "on Two" {277- t.Error("sheet Two")278- }279- if m.whole != wholeCols || m.cur != addr("B5") {280- t.Errorf("selection %v whole %v", m.selection(), m.whole)281- }282- if m.note != "S: total 60" {283- t.Errorf("note %q", m.note)284- }285-}286-287-func TestScriptErrorsShowWhere(t *testing.T) {288- m := newModel()289- script(t, m, "set(\"A1\", 5)\nrun(\"format.bold\")\nget(\"nowhere\")\n")290- want := "S:3:4: get: not a cell or range: nowhere Ctrl+Z undoes what it did"291- if m.warn != want {292- t.Errorf("warn %q\nwant %q", m.warn, want)293- }294- if !strings.Contains(line(m, contextLine), "S:3:4: get: not a cell") {295- t.Errorf("context line %q", line(m, contextLine))296- }297- press(t, m, "<ctrl+z>")298- if m.sheet.Len() != 0 {299- t.Errorf("undo left cells: note %q A1 %q undo %q", m.note, input(m, "A1"), m.book().UndoLabel())300- }301-302- for src, want := range map[string]string{303- `run("file.save")`: "S:1:4: run: Save (file.save) can't run in a macro",304- `run("column.width")`: `S:1:4: run: Column width asks "Column width (1-240)": give run("column.width", answer=...)`,305- `run("no.such")`: `S:1:4: run: no command "no.such"`,306- `run("data.sort_range")`: "S:1:4: run: Sort range opens a dialog; it can't run in a macro",307- "move(0, -1)": "S:1:5: move: moving 0, -1 from A1 goes off the sheet",308- } {309- m := newModel()310- script(t, m, src)311- if !strings.HasPrefix(m.warn, want) {312- t.Errorf("%s: %q, want %q", src, m.warn, want)313- }314- if m.mode != modeReady || m.overlay != nil || m.prompt != nil {315- t.Errorf("%s left mode %v", src, m.mode)316- }317- }318-}319-320-func TestStepLimitStopsARunaway(t *testing.T) {321- defer func(n uint64) { macroMaxSteps = n }(macroMaxSteps)322- macroMaxSteps = 50_000323- m := newModel()324- script(t, m, "set(\"A1\", 1)\nwhile True:\n pass\n")325- if !strings.HasPrefix(m.warn, "S:") || !strings.Contains(m.warn, ": stopped after 50") || !strings.HasSuffix(m.warn, "Ctrl+Z undoes what it did") {326- t.Errorf("warn %q", m.warn)327- }328- if m.macros.run != nil || m.indicator() != "READY" {329- t.Errorf("still running: %q", m.indicator())330- }331-}332-333-func TestEscStopsARun(t *testing.T) {334- m := newModel()335- m.book().SaveMacro("", sheet.Macro{Name: "Spin", Source: "while True:\n move(0, 0)\n"}, "add")336- m.trustHere()337- cmd := m.runMacro(mustMacro(t, m, "Spin"))338- if m.indicator() != "CMD" || !strings.Contains(line(m, contextLine), "Running Spin") {339- t.Errorf("indicator %q, context %q", m.indicator(), line(m, contextLine))340- }341- msg := cmd()342- press(t, m, "x") // held back while running343- m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})344- for range 1000 {345- if _, done := msg.(macroDoneMsg); done {346- break347- }348- _, next := m.Update(msg)349- if next == nil {350- break351- }352- msg = next()353- if b, ok := msg.(tea.BatchMsg); ok {354- msg = b[len(b)-1]()355- }356- }357- if m.macros.run != nil {358- m.Update(msg)359- }360- if m.macros.run != nil || m.warn != "Stopped Spin with Esc" || m.sheet.Len() != 0 {361- t.Errorf("run %v warn %q cells %d", m.macros.run, m.warn, m.sheet.Len())362- }363-}364-365235 func TestMacrosFromElsewhereAskOnce(t *testing.T) {366236 w := sheet.NewBook()367237 w.SaveMacro("", sheet.Macro{Name: "Hi", Key: "3", Source: `set("A1", "hi")`}, "add")@@ -2,10 +2,10 @@ package ui
22 33 import (44 "cmp"5- "slices"65 "fmt"76 "os"87 "os/exec"8+ "slices"99 "strconv"1010 "strings"1111 @@ -0,0 +1,164 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// Scripts run against the model: the API, errors, limits and Esc.13+14+// script saves src as a trusted macro and runs it to the end.15+func script(t *testing.T, m *Model, src string) {16+ t.Helper()17+ old := ""18+ if hasMacro(m.book(), "S") {19+ old = "S"20+ }21+ if err := m.book().SaveMacro(old, sheet.Macro{Name: "S", Source: src}, "add"); err != nil {22+ t.Fatal(err)23+ }24+ m.trustHere()25+ run(m, m.runMacro(mustMacro(t, m, "S")))26+}27+28+func TestScriptsReadWhatTheyWrote(t *testing.T) {29+ m := newModel()30+ script(t, m, `31+set("A1:A3", [[1], [2], [3]])32+set_formula("B1:B3", "A1*10")33+total = 034+for row in get("B1:B3"):35+ total += row[0]36+set("C1", total)37+set("C2", get_formula("B2"))38+number_format("C1", "currency", decimals=0)39+set("C3", get_number_format("C1"))40+add_sheet("Two")41+set("A1", "on " + active_sheet())42+activate_sheet("Sheet1")43+select("A:B", active="B5")44+set("D1", selection() + " " + active_cell())45+print("total", total)46+`)47+ if m.warn != "" {48+ t.Fatal(m.warn)49+ }50+ for a, want := range map[string]string{"C1": "60", "C2": "=A2*10", "C3": "currency", "D1": "A:B B5"} {51+ if got := input(m, a); got != want {52+ t.Errorf("%s = %q, want %q", a, got, want)53+ }54+ }55+ if two := m.book().Lookup("Two"); two == nil || two.Cell(addr("A1")).Input != "on Two" {56+ t.Error("sheet Two")57+ }58+ if m.whole != wholeCols || m.cur != addr("B5") {59+ t.Errorf("selection %v whole %v", m.selection(), m.whole)60+ }61+ if m.note != "S: total 60" {62+ t.Errorf("note %q", m.note)63+ }64+}65+66+func TestScriptsAnswerQuestions(t *testing.T) {67+ m := newModel()68+ script(t, m, `69+add_sheet("Two")70+set("A1", 1)71+run("sheet.delete", answer="enter")72+select("A1:A3")73+run("data.define_name", answer="Sales")74+run("sheet.rename", answer="Budget")75+`)76+ if m.warn != "" {77+ t.Fatal(m.warn)78+ }79+ if m.book().Len() != 1 || m.sheet.Name() != "Budget" {80+ t.Errorf("sheets %d, %q", m.book().Len(), m.sheet.Name())81+ }82+ if n, ok := m.sheet.LookupName("Sales"); !ok || n.Range.String() != "A1:A3" {83+ t.Errorf("name %+v %v", n, ok)84+ }85+}86+87+func TestScriptErrorsShowWhere(t *testing.T) {88+ m := newModel()89+ script(t, m, "set(\"A1\", 5)\nrun(\"format.bold\")\nget(\"nowhere\")\n")90+ want := "S:3:4: get: not a cell or range: nowhere Ctrl+Z undoes what it did"91+ if m.warn != want {92+ t.Errorf("warn %q\nwant %q", m.warn, want)93+ }94+ if !strings.Contains(line(m, contextLine), "S:3:4: get: not a cell") {95+ t.Errorf("context line %q", line(m, contextLine))96+ }97+ press(t, m, "<ctrl+z>")98+ if m.sheet.Len() != 0 {99+ t.Errorf("undo left cells: note %q A1 %q undo %q", m.note, input(m, "A1"), m.book().UndoLabel())100+ }101+102+ for src, want := range map[string]string{103+ `run("file.save")`: "S:1:4: run: Save (file.save) can't run in a macro",104+ `run("column.width")`: `S:1:4: run: Column width asks "Column width (1-240)": give run("column.width", answer=...)`,105+ `run("no.such")`: `S:1:4: run: no command "no.such"`,106+ `run("data.sort_range")`: "S:1:4: run: Sort range opens a dialog; it can't run in a macro",107+ "move(0, -1)": "S:1:5: move: moving 0, -1 from A1 goes off the sheet",108+ } {109+ m := newModel()110+ script(t, m, src)111+ if !strings.HasPrefix(m.warn, want) {112+ t.Errorf("%s: %q, want %q", src, m.warn, want)113+ }114+ if m.mode != modeReady || m.overlay != nil || m.prompt != nil {115+ t.Errorf("%s left mode %v", src, m.mode)116+ }117+ }118+}119+120+func TestStepLimitStopsARunaway(t *testing.T) {121+ defer func(n uint64) { macroMaxSteps = n }(macroMaxSteps)122+ macroMaxSteps = 50_000123+ m := newModel()124+ script(t, m, "set(\"A1\", 1)\nwhile True:\n pass\n")125+ if !strings.HasPrefix(m.warn, "S:") || !strings.Contains(m.warn, ": stopped after 50") || !strings.HasSuffix(m.warn, "Ctrl+Z undoes what it did") {126+ t.Errorf("warn %q", m.warn)127+ }128+ if m.macros.run != nil || m.indicator() != "READY" {129+ t.Errorf("still running: %q", m.indicator())130+ }131+}132+133+func TestEscStopsARun(t *testing.T) {134+ m := newModel()135+ m.book().SaveMacro("", sheet.Macro{Name: "Spin", Source: "while True:\n move(0, 0)\n"}, "add")136+ m.trustHere()137+ cmd := m.runMacro(mustMacro(t, m, "Spin"))138+ if m.indicator() != "CMD" || !strings.Contains(line(m, contextLine), "Running Spin") {139+ t.Errorf("indicator %q, context %q", m.indicator(), line(m, contextLine))140+ }141+ msg := cmd()142+ press(t, m, "x") // held back while running143+ m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})144+ for range 1000 {145+ if _, done := msg.(macroDoneMsg); done {146+ break147+ }148+ _, next := m.Update(msg)149+ if next == nil {150+ break151+ }152+ msg = next()153+ if b, ok := msg.(tea.BatchMsg); ok {154+ msg = b[len(b)-1]()155+ }156+ }157+ if m.macros.run != nil {158+ m.Update(msg)159+ }160+ if m.macros.run != nil || m.warn != "Stopped Spin with Esc" || m.sheet.Len() != 0 {161+ t.Errorf("run %v warn %q cells %d", m.macros.run, m.warn, m.sheet.Len())162+ }163+}164+