cat / 012
clone

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

Merge branch 'main' into worktree-agent-a52a926d29bb7e613

Dami committed 1ee6d5e
commit
1ee6d5ee65b662ff279fe3588b7752506cc9f561
parent
1fe2d7ff7fcba10ac6cd3dc64854be2a88e16acf
parent
3cfea87db8b09be8f55def925737ba64bfe37804
author
Dami <dami.etoile@gmail.com>
94 files changed, +4172 -1552
MROADMAP.md9+-
Mdocs/contributing/architecture.md60+++-
Mdocs/contributing/extending.md43++--
Mdocs/reference/macro-api.md35++-
Mdocs/sheets/macros.md31++-
Me2e/macros_test.go34+++
Me2e/testdata/screens/palette-narrow.html2+-
Me2e/testdata/screens/palette-search-light.html24+-
Me2e/testdata/screens/palette-search.html24+-
Me2e/testdata/screens/palette-wide.html2+-
Me2e/testdata/screens/palette.html2+-
Ainternal/macro/answer.go162++++++++++++
Ainternal/macro/answer_test.go44+++
Minternal/macro/builtins.go10+-
Minternal/macro/macro.go11+-
Ainternal/sheet/linejson.go70+++++
Ainternal/sheet/linejson_test.go37+++
Minternal/ui/assist.go278+--------------------
Minternal/ui/assist_test.go28+-
Minternal/ui/assistsheets.go58+----
Minternal/ui/assistsheets_test.go18+-
Minternal/ui/chartaxes.go49++--
Minternal/ui/charteditor.go229+++++++++++------
Ainternal/ui/chartmacro.go245++++++++++++++++++
Minternal/ui/charts.go39++-
Minternal/ui/chartsel.go108+++++---
Ainternal/ui/choicebar/choicebar.go124+++++++++
Ainternal/ui/choicebar/choicebar_test.go90+++++++
Minternal/ui/columns.go8+-
Minternal/ui/commands.go6+
Minternal/ui/data_test.go14+-
Minternal/ui/dialog.go86+------
Minternal/ui/download.go5+-
Minternal/ui/entry.go13+-
Minternal/ui/file.go3+-
Minternal/ui/filter.go373+++-------------------------
Ainternal/ui/filterpick/filterpick.go399++++++++++++++++++++++++++++++
Ainternal/ui/filterpick/filterpick_test.go131++++++++++
Minternal/ui/find.go5+
Ainternal/ui/findbar/answer.go92+++++++
Minternal/ui/findbar/find.go30++-
Minternal/ui/findbar/find_test.go45+++-
Minternal/ui/format_test.go3+-
Minternal/ui/help.go150+-----------
Minternal/ui/help_test.go21+-
Minternal/ui/hosts.go35++-
Minternal/ui/keyboard.go8+-
Minternal/ui/layout_test.go3+-
Minternal/ui/layoutfmt.go3+-
Minternal/ui/macro.go9+-
Minternal/ui/macro_doc_test.go24++
Ainternal/ui/macrodialog_test.go319++++++++++++++++++++++++
Minternal/ui/macrohostnav.go39++-
Minternal/ui/macrorec.go37++-
Minternal/ui/macroscript_test.go11+-
Minternal/ui/model.go8+-
Minternal/ui/mouse.go1+
Minternal/ui/names.go50+++-
Minternal/ui/overlay.go4+-
Minternal/ui/overlay/overlay.go1+
Minternal/ui/panel.go7+-
Minternal/ui/pivot.go18+-
Minternal/ui/pivot_test.go3+-
Minternal/ui/pivotactions.go3+-
Minternal/ui/pivoteditor.go46+++-
Minternal/ui/prefs.go2+-
Minternal/ui/prompt.go133++++++----
Minternal/ui/protect.go4+-
Minternal/ui/protect_test.go7+-
Minternal/ui/recovery.go3+-
Minternal/ui/rowheights.go8+-
Ainternal/ui/rulemacro.go147+++++++++++
Minternal/ui/rules.go25+-
Minternal/ui/rules/condfmt.go6+-
Minternal/ui/rules/editor.go16+-
Minternal/ui/rules/editor_test.go36++-
Minternal/ui/rules/validation.go2+
Minternal/ui/rules_test.go8+-
Minternal/ui/saveas_test.go3+-
Minternal/ui/serve_test.go3+-
Ainternal/ui/shortcuts/shortcuts.go179+++++++++++++
Ainternal/ui/shortcuts/shortcuts_test.go82++++++
Minternal/ui/sort.go216+---------------
Ainternal/ui/sortbar/sortbar.go304+++++++++++++++++++++++
Ainternal/ui/sortbar/sortbar_test.go128++++++++++
Ainternal/ui/suggest/sheets.go69+++++
Ainternal/ui/suggest/suggest.go304+++++++++++++++++++++++
Ainternal/ui/suggest/suggest_test.go141+++++++++++
Minternal/ui/tabs.go2+-
Minternal/ui/transfer.go3+-
Minternal/ui/transfer_test.go3+-
Minternal/ui/undocost.go4+-
Minternal/ui/undocost_test.go3+-
Minternal/ui/vim_test.go4+-
M ROADMAP.md +2 -7
@@ -19,7 +19,6 @@ Sizes: S (a day or two), M (about a week), L (weeks).
1919 | Item | Why | Size |2020 |---|---|---|2121 | Conditional formatting and validation, what's left: data bars and icon sets, "top 10", "duplicate values", date periods (this week, last month), rules moving with cells cut to another sheet, a dropdown's chip look, and custom checkbox values | Excel's other rule types come in as notes | M |22-| Remaining overlays get narrow hosts: filter picker, sort and choice bars, chart editor, shortcuts, named ranges, cell entry and prompts | Components testable without the model ([Architecture](docs/contributing/architecture.md#the-ui)) | M |2322 2423 ### 2. Spreadsheet features Sheets users reach for2524
@@ -37,12 +36,6 @@ rises in measured steps; see [Bounds of support](docs/contributing/limits.md#wha
3736 |---|---|---|3837 | Linked, paged read-only ranges over Parquet and SQLite that feed pivots and formulas by streaming | Sources too big for any grid | L |3938 40-### 4. Macros, keys and the terminal41-42-| Item | Size |43-|---|---|44-| Record dialog choices (sort bar, filter picker, find and replace, chart editor) and chart drags; let scripts run commands that open dialogs, with answers | M |45-4639 ## Later: sharing a live sheet (shelved)4740 4841 Explored, not scheduled. `012 serve` gives each SSH session its own
@@ -93,6 +86,7 @@ by a gateway dialing the iroh ticket.
9386 - Protected sheets and ranges that warn on edit: [Notes and protection](docs/sheets/notes-protection.md#protected-sheets-and-ranges)9487 - Charts (column, bar, line, area, pie, scatter; stacking, trend lines, axis and legend options), as images or text: [Charts](docs/sheets/charts.md)9588 - Macros, recorded or written in Starlark: [Macros](docs/sheets/macros.md)89+- Macros record the choices made in dialogs and chart drags, and scripts answer dialogs: [Macro scripting API](docs/reference/macro-api.md#dialogs)9690 - JEV functions, with the API key in the OS keychain: [JEV functions](docs/formulas/jev.md)9791 9892 **The terminal**
@@ -115,3 +109,4 @@ by a gateway dialing the iroh ticket.
115109 - A release checklist (XLSX output opened in Excel, LibreOffice and Google Sheets) and `make dist`, release archives cross-compiled locally with SHA256SUMS: [Releasing](docs/contributing/releasing.md)116110 - `make stress-report` flags regressions against the last release's run, allowing for noise: [Observability](docs/contributing/observability.md#regressions-against-the-last-release)117111 - Grafana: recent traces and a trace view of the nested spans: [Observability](docs/contributing/observability.md#the-stack)112+- Every overlay, prompts and formula suggestions behind narrow hosts, most in packages of their own with fake-host tests: [Architecture](docs/contributing/architecture.md#the-ui)
M docs/contributing/architecture.md +43 -17
@@ -37,6 +37,11 @@ internal/ui the Bubble Tea model: modes, menus, overlays, rendering
3737 findbar find and replace, a bar on the context line3838 themepicker File > Settings > Theme, previewing as it moves3939 rules the conditional formatting and data validation panel40+ sortbar the sort bar: the columns and order to sort a range by41+ filterpick a filter column's values and condition42+ choicebar small questions on the context line answered with a key43+ shortcuts the keyboard shortcuts view44+ suggest formula suggestions and function signatures while typing4045 tabstrip the sheet tabs' layout and where each sheet was left4146 transfer imports running in the background and their progress4247 e2e/ end-to-end tests through libghostty (separate module, cgo)
@@ -364,9 +369,9 @@ draw. The components:
364369 |---|---|---|365370 | grid (embedded) | `grid` | the sheet shown, active cell, scroll, window size, selection; mapping rows and columns to the screen (rows as bands of lines, `bands.go`), frozen panes, moving and selecting (`grid.go`, `panes.go`, `selection.go`) |366371 | edit line | `lineedit.Line` | the one-line editor shared by cell entries, prompts and search fields (package `lineedit`) |367-| cell entry | `entry`, `assist` | typing into a cell, pointing at references, other sheets while pointing, formula suggestions and signatures (`entry.go`, `assist.go`) |372+| cell entry | `entry`, `suggest.List` | typing into a cell, pointing at references, other sheets while pointing (`entry.go`, `assistsheets.go`); formula suggestions and signatures (package `suggest`, with `assist.go`) |368373 | prompt | `prompt` | a question on the context line, typed or pointed at (`prompt.go`) |369-| overlays | `overlay.Overlay` | whatever has taken over input: menus (`menuoverlay.go`), the palette and pickers (package `picker`, with `palette.go`, `names.go`), the command line (package `cmdline`), the theme picker (package `themepicker`), the find bar (package `findbar`), the filter picker, the sort and choice bars, the chart editor and selection, the pivot editor (`pivoteditor.go`, `pivotactions.go`), the rules panel (package `rules`, with `rules.go`), the shortcuts |374+| overlays | `overlay.Overlay` | whatever has taken over input: menus (`menuoverlay.go`), the palette and pickers (package `picker`, with `palette.go`, `names.go`), the command line (package `cmdline`), the theme picker (package `themepicker`), the find bar (package `findbar`), the filter picker (package `filterpick`, with `filter.go`), the sort bar (package `sortbar`, with `sort.go`), choice bars (package `choicebar`, with `dialog.go`), the chart editor and selection (`charteditor.go`, `chartsel.go`), the pivot editor (`pivoteditor.go`, `pivotactions.go`), the rules panel (package `rules`, with `rules.go`), the shortcuts (package `shortcuts`, with `help.go`) |370375 | sheet tabs | `tabstrip.Strip` | where each sheet was left, the tab strip's scroll and layout (package `tabstrip`); what clicks on it do (`tabstrip.go`) |371376 | mouse | `mouseState` | drags, hover, double clicks, the fill handle (`mouse.go`, `fill.go`) |372377 | import | `transfer.Transfer` | the import in progress, its progress display and cancelling (package `transfer`); choosing and placing imports (`transfer.go`, `importplace.go`) |
@@ -391,23 +396,37 @@ methods off `ui.Model`'s exported API:
391396 | Package | Host | Methods |392397 |---|---|---|393398 | `picker` | `picker.Host` | theme, size, the edit line, close, record the answer to a command's question (5) |399+| `choicebar` | `choicebar.Host` | theme, close, record the key chosen (3); the choices are closures over the model |400+| `shortcuts` | `shortcuts.Host` | theme, size, close, the rows, built from the key bindings and the registry (4) |401+| `sortbar` | `sortbar.Host` | theme, size, the sheet, close, sort (recorded as the bar's command) (5) |402+| `filterpick` | `filterpick.Host` | theme, size, the edit line, close, the locale (5); what applying and cancelling do are callbacks, as the sheet's filter and a pivot's differ |394403 | `cmdline` | `cmdline.Host` | theme, size, the edit line, close, the commands to complete, run a line, fail, the session's history (8) |404+| `suggest` | `suggest.Host` | theme, size, the edit line, whether an entry is being typed, the entry's sheet, the formula as parsed, where the formula bar's text starts (7) |395405 | `themepicker` | `themepicker.Host` | a picker's host, and the current theme, the themes directory, preview, keep, whether keys can be held (10) |396-| `findbar` | `findbar.Host` | theme, size, the edit line, the workbook, the sheet and cell shown, show a cell, note, mark modified, leave keeping the search, pass a click to the grid (10) |397-| `rules` | `rules.Host` | theme, size, the edit line, close, the sheet and selection, save a conditional format or a validation rule (as the commands macros record), note a rule removed or moved, the terminal's palette colors (10) |406+| `rules` | `rules.Host` | theme, size, the edit line, close, the sheet and selection, save a conditional format or a validation rule, follow a rule removed or moved (each recorded as the commands that do it), the terminal's palette colors (10) |407+| `findbar` | `findbar.Host` | theme, size, the edit line, the workbook, the trace, the sheet and cell shown, show a cell, note, run a replacement (recorded as Find and replace), leave keeping the search, pass a click to the grid (11) |398408 | `tabstrip`, `transfer` | none | they're handed a view or messages and draw what they're given |399409 400410 Components that stay in package `ui` declare an unexported host the model401-implements itself: `menuHost` for menus (9 methods; their items are the402-command registry and the menu bar's definitions), `macrosHost` for the403-macro manager (9; it's a few keys over a picker, and what they do is the404-macro machinery), and `pivotHost` for the pivot editor and its field405-picker (14; it opens the model's pickers, filter values and range prompt406-and comes back from them). The rest (the filter picker, the sort and407-choice bars, the chart editor and selection, the shortcuts, the named408-ranges picker) still keep the model itself. Commands still run in one409-place: a host's way to run one (`runFromOverlay`, `cmdline.Host.Run`)410-goes through `runCommand`, so macros record them and pivots guard them.411+implements itself:412+413+| Host | For | Methods | Why it stays |414+|---|---|---|---|415+| `namesHost` | the named ranges picker | 6 | a few keys over a picker; what they do is the model's name prompts and selection, which open the picker again |416+| `promptHost` | prompts | 8 | a prompt is one of the model's modes, not an overlay: ranges are pointed at with the grid's movement and drawn by the grid |417+| `menuHost` | menus | 9 | their items are the command registry and the menu bar's definitions |418+| `macrosHost` | the macro manager | 9 | a few keys over a picker; what they do is the macro machinery |419+| `pivotHost` | the pivot editor and its field picker | 16 | it opens the model's pickers, filter values and range prompt and comes back from them |420+| `chartHost` | the chart editor and a selected chart | 25 | besides the charts, the grid's geometry, the pointer shape, the chart menu, holding Space to zoom, and prompts, and passing keys and clicks on to the grid |421+422+The cell entry has no host: typing into a cell is the model's ENTER,423+EDIT and POINT modes, whose state is the model's edit line, active cell424+and pointer, and committing goes through validation, the undo step and425+the recorder, so an interface for it would be the model's own methods426+over again. Its suggestions, which only read the entry, are package427+`suggest`. Commands still run in one place: a host's way to run one428+(`runFromOverlay`, `cmdline.Host.Run`, `chartHost.runCommand`) goes429+through `runCommand`, so macros record them and pivots guard them.411430 412431 **Drawing.** `View` (`panel.go`) stacks the control panel, the header,413432 the grid rows and the status line, then composites the floating layers
@@ -428,8 +447,9 @@ hints) are what every overlay is drawn with.
428447 **Packages.** `theme`, `rowtext`, `formula`, `overlay` and `lineedit`429448 depend on nothing in `ui`, so they can be tested and measured alone. The430449 components in `picker`, `cmdline`, `themepicker`, `findbar`, `rules`,431-`tabstrip` and `transfer` build on them and reach the model only through their432-hosts, with unit tests of their own. A component moves out of package450+`sortbar`, `filterpick`, `choicebar`, `shortcuts`, `suggest`, `tabstrip`451+and `transfer` build on them and reach the model only through their452+hosts, with unit tests of their own against fake hosts. A component moves out of package433453 `ui` when its host stays small (about ten methods or fewer); one that434454 needs more keeps a narrow interface inside `ui` instead, since moving it435455 would mean exporting half of the model. New leaf packages are split off
@@ -490,7 +510,13 @@ session dropped) stops after waiting 30 s for a call to be taken. The whole run
490510 491511 Recording (`macrorec.go`) listens where actions happen: `runCommand` for492512 commands (with the answer to a prompt or choice bar), the entry commit,493-pastes, the fill handle, column borders and tab drags. The selection is513+pastes, the fill handle, column borders and tab drags. A dialog records514+itself when it acts (`recordDialog`), as its command answered with the515+choices made, a JSON object the recording writes as a dict; the command's516+`answer` hook makes the same choices and applies them when a script runs517+it with that answer. Charts moved or resized record as Edit chart with518+the fields changed (`chartmacro.go`), and the rules panel as the commands519+that change rules from a line of the file (`rulemacro.go`). The selection is494520 recorded lazily, as absolute `select()` or relative `move()`/`extend()`,495521 just before something acts on it. Files record the computer their macros496522 were made or trusted on (an id from `cmd/012`); a macro from elsewhere
M docs/contributing/extending.md +23 -20
@@ -57,13 +57,15 @@ of the model (reading the sheet or the selection, running a command,
5757 opening or closing an overlay, a note, the theme), which the model5858 implements, so it can be tested against a fake. When the host stays5959 small, about ten methods or fewer, the component lives in a package of60-its own under `internal/ui` (`picker`, `cmdline`, `findbar`,61-`themepicker`), declares `Host`, and the model implements it through the62-`host` adapter in `hosts.go`; parts that need no host at all take plain63-values (`tabstrip`, `transfer`). A component that needs more keeps an64-unexported host inside package `ui` (`menuHost`, `pivotHost`,65-`macrosHost`) rather than exporting half of `Model`. Either way, running a66-command goes through `runCommand` (pattern 3).60+its own under `internal/ui` (`picker`, `cmdline`, `findbar`, `sortbar`,61+`filterpick`, `suggest`), declares `Host`, and the model implements it62+through the `host` adapter in `hosts.go`; parts that need no host at all63+take plain values (`tabstrip`, `transfer`). A component that needs more,64+or that acts through the model's own machinery, keeps an unexported host65+inside package `ui` (`menuHost`, `pivotHost`, `chartHost`, `promptHost`)66+rather than exporting half of `Model`; the table in67+[Architecture](architecture.md#the-ui) says which and why. Either way,68+running a command goes through `runCommand` (pattern 3).6769 6870 ### 3. Actions go through commands6971
@@ -82,10 +84,14 @@ column borders and dragged tabs, each recorded where it happens with
8284 `Model.record`, and the selection, recorded as a state (where it is just8385 before something acts on it) rather than as the keys or clicks that moved8486 it. So movement keys, typing and mouse selection needn't become commands85-to be recorded, and a replay doesn't depend on the window's size. A new86-action that changes the workbook outside a command records itself the same87-way; anything unrecorded that changes the workbook while recording becomes88-a comment in the script, so a gap shows.87+to be recorded, and a replay doesn't depend on the window's size. A88+dialog opened by a command records what was chosen in it once it acts,89+as that command answered with the choices (`recordDialog`), and gives the90+command an `answer` hook that makes the same choices, so scripts can run91+it; its package writes and reads the answer. A new action that changes92+the workbook outside a command records itself the same way; anything93+unrecorded that changes the workbook while recording becomes a comment in94+the script, so a gap shows.8995 9096 ### 4. Side effects stay at the edges9197
@@ -216,15 +222,12 @@ a golden screen reviewed in the gallery for anything visible (see
216222 217223 Where the code doesn't follow the patterns yet:218224 219-- Some overlays still keep the whole `*Model` as their host rather than a220- narrower interface: the filter picker, the sort and choice bars, the221- chart editor and selection, the shortcuts and the named ranges picker222- (pattern 2). The cell entry, its suggestions and prompts are handed the223- model too.225+- The cell entry (typing into a cell, pointing at references) is the226+ model's own modes rather than a component with a host (pattern 2); see227+ [Architecture](architecture.md#the-ui) for why.224228 - Movement keys, typing, F4 in formulas, Alt+letter menus and direct mouse225229 manipulation act without a registered command (pattern 3). The macro226230 recorder covers what matters for replay without them (entries, the227- selection, pastes, the fill handle, column borders, dragged tabs), but228- dragging and resizing charts and the choices made in dialogs (the sort229- bar, the filter picker, find and replace, the chart editor) aren't230- recorded: a recording notes them as comments.231+ selection, pastes, the fill handle, column borders, dragged tabs and232+ charts, the choices made in dialogs), but changes made in the named233+ ranges picker aren't recorded: a recording notes them as comments.
M docs/reference/macro-api.md +32 -3
@@ -85,9 +85,10 @@ id, on the selection, as if picked. A command that asks something (a
8585 width, a name, a confirmation) needs `answer`: the text you'd type, a range8686 for a range question, the key of a choice (`"enter"`, `"d"`) or its8787 label, or the item picked from a list (View > Hidden sheets takes the88-sheet's name). Commands that open a dialog or another picker (Sort89-range, Insert chart, Named ranges) can't run in a script; nor can files,90-menus, help, undo or macros. The ids are those in the recorded scripts; a few:88+sheet's name). A command that opens a dialog takes what you'd choose in89+it as a dict (see [Dialogs](#dialogs)). Other pickers (Named ranges) can't90+run in a script; nor can files, menus, help, undo or macros. The ids are91+those in the recorded scripts; a few:9192 9293 | Id | Command |9394 |---|---|
@@ -102,11 +103,39 @@ menus, help, undo or macros. The ids are those in the recorded scripts; a few:
102103 | `data.sort_sheet_az`, `data.sort_range_az`, `data.sort_range_za`, `data.filter`, `data.filter_remove` | Sorting and filters |103104 | `data.define_name` (answer: the name) | Name the selection |104105 | `format.conditional_add`, `data.validation_add` (answer: the rule as a line of the file, see [The .012 format](../files/format.md#conditional-formats-and-data-validation)), `insert.checkbox`, `format.conditional_clear`, `data.validation_clear`, `data.checkbox_toggle` | Conditional formats, data validation and checkboxes |106+| `format.conditional_set`, `data.validation_set` (answer: `{"rule": 2, ...}`, the rule's number and its new line), `format.conditional_remove`, `data.validation_remove` (answer: the rule's number), `format.conditional_move` (answer: `{"rule": 2, "to": 1}`) | Change the rules of the sheet shown, numbered from 1 as the panel lists them |105107 | `sheet.new`, `sheet.duplicate`, `sheet.rename` (answer: the name), `sheet.delete` (answer: `"enter"` when it asks), `sheet.hide`, `sheet.unhide` (answer: the sheet's name) | Sheets |106108 | `view.freeze_rows1`, `view.freeze_cols1`, `view.freeze_rows0`, ... | Frozen panes |107109 108110 Record a macro that uses a command to see its id.109111 112+## Dialogs113+114+A command that opens a dialog runs in a script with `answer` set to what115+you'd choose in it, as a dict: the dialog doesn't open, and what it would116+do is done. A recording writes dialogs this way, so recording one is the117+quickest way to get its answer right. Fields left out keep what the118+dialog would start with.119+120+```python121+run("data.sort_range", answer={"by": [{"column": "B", "order": "desc"}, {"column": "A"}], "header": True})122+run("data.filter_column", answer={"column": "A", "hidden": ["North"]})123+run("chart.edit", answer={"chart": 1, "at": "F3", "width": 30})124+```125+126+| Id | Answer |127+|---|---|128+| `data.sort_range` | `by`: the columns to sort by, in order, each `{"column": "B"}`, with `"order": "desc"` for Z to A; `header`: whether the range's first row is a header that stays in place (left out: as the bar guesses). The range is the selection, or the data around the active cell |129+| `data.filter_column` | `column`: the column of the filter (left out: the active one); `hidden`: the values it hides; `condition` and `value`: the condition rows must meet, as a filter column is written in [the .012 format](../files/format.md) (`gt`, `contains`, ...), the value in en-US's form |130+| `edit.replace` | `find` and `replace`; `matchCase`, `wholeCell`, `regex`, `inFormulas` set to `True` to turn them on; `within`: `"all"` for every sheet or a range of the sheet shown (left out: the sheet shown); `cell`: replace only the match in that cell (left out: replace all) |131+| `insert.chart` | The chart as a line of the file: `type`, `data`, `at`, `width`, `height`, `header`, `labels`, `title` and the options, see [The .012 format](../files/format.md); what's left out is the chart Insert > Chart makes of the selection |132+| `chart.edit` | `chart`: the chart's number on the sheet shown, counting from 1 (left out: the chart the command would edit), and the fields of its line to change, as for `insert.chart`; `None` puts a field back to its default |133+| `data.pivot`, `data.pivot_edit` | The pivot table as a line of the file: `source`, `rows`, `columns`, `values`, `filters`, `rowTotals`, `columnTotals`, see [The .012 format](../files/format.md). `data.pivot` makes it on a new sheet, as Data > Pivot table does; `data.pivot_edit` changes the pivot of the sheet shown |134+135+A dict's keys are strings, and its values `None`, `True`, `False`,136+numbers, strings, lists and dicts; `run` hands it on as JSON with the137+keys in the order written.138+110139 ## Output and errors111140 112141 `print(...)` shows its last line on the context line when the macro ends.
M docs/sheets/macros.md +20 -11
@@ -46,17 +46,26 @@ What gets recorded:
4646 - pasted text (`paste_text(...)`), the fill handle (`fill(...)`), column4747 borders dragged or fitted (`set_width(...)`) and tabs dragged4848 (`move_sheet(...)`);49-- rules added in the conditional formatting and data validation panels,50- as the commands that add a rule from a line of the file51- (`run("format.conditional_add", answer='{"ranges":"B2:B9",...}')`), and52- items picked from a dropdown, as entries. Editing, removing or moving a53- rule in the panel is noted as a comment, as the dialogs below are.54-55-What doesn't: dialogs such as sorting by several columns, the filter56-picker, find and replace, the chart editor, dragging a chart, and undo.57-Anything like that which changes the workbook is noted in the script as a58-comment, `# Not recorded: sort A2:C9`, so the script never silently does59-less than you did. Files, menus, help and other macros are never part of a49+- what you choose in dialogs, as the command that opens the dialog with50+ your choices as its answer: the sort bar51+ (`run("data.sort_range", answer={"by": [{"column": "B"}], "header": True})`),52+ the filter picker's values and condition, find and replace, the chart53+ editor, and the pivot editor, each as the dialog left things when you54+ pressed Enter. These name columns, ranges and cells as they were, with55+ either kind of references;56+- charts moved or resized, by keys or by dragging, as57+ `run("chart.edit", answer={"chart": 1, "at": "F3"})`, one call for a58+ move made in steps;59+- rules added, edited, removed or moved in the conditional formatting and60+ data validation panels, as the commands that do it from a line of the61+ file (`run("format.conditional_add", answer={"ranges": "B2:B9", ...})`,62+ `run("format.conditional_remove", answer=2)`), and items picked from a63+ dropdown, as entries.64+65+What doesn't: undo, and changes made in the named ranges picker. Anything66+like that which changes the workbook is noted in the script as a comment,67+`# Not recorded: undid: sort A2:C9`, so the script never silently does less68+than you did. Files, menus, help and other macros are never part of a6069 macro.6170 6271 ## Running
M e2e/macros_test.go +34 -0
@@ -98,3 +98,37 @@ func TestMacroFromAnotherComputerAsks(t *testing.T) {
9898 s.keys("<down>", "<ctrl+alt+shift+1>")9999 s.waitForBar("C4:D4", "Total")100100 }101+102+// TestMacroRecordsTheSortBar records the choices made in a dialog and103+// replays them.104+func TestMacroRecordsTheSortBar(t *testing.T) {105+ dir := t.TempDir()106+ s := start(t, dir, "book.012")107+ s.keys("Month", "<tab>", "Sales", "<enter>", "Jan", "<tab>", "10", "<enter>", "Feb", "<tab>", "30", "<enter>", "Mar", "<tab>", "20", "<enter>")108+ s.keys("<ctrl+home>", "<ctrl+k>", "record macro")109+ s.waitFor("Record macro")110+ s.keys("<enter>")111+ s.waitFor("Recording (absolute references)")112+ s.keys("<ctrl+k>", "sort range", "<enter>")113+ s.waitFor("Sort A2:B4 by A Month A→Z")114+ s.keys("<right>", "<space>", "<enter>")115+ s.waitFor("Sorted A2:B4 by B Z→A")116+ s.waitForLine(gridRow1+1, " 2 Feb 30")117+ s.keys("<ctrl+k>", "stop and save", "<enter>")118+ s.waitFor("Save macro as: Macro 1")119+ s.keys("Sort", "<enter>", "<enter>")120+ s.waitFor("Saved macro Sort")121+122+ // Sorted back by Sales A to Z, then replayed from the palette.123+ s.keys("<right>", "<ctrl+k>", "sort range a to z", "<enter>")124+ s.waitForLine(gridRow1+1, " 2 Jan 10")125+ s.keys("<left>")126+ s.keys("<ctrl+k>", "sort", "<enter>")127+ s.waitFor("Ran Sort")128+ s.waitForLine(gridRow1+1, " 2 Feb 30")129+ s.keys("<ctrl+s>")130+ s.eventually("saved", func() bool {131+ data, err := os.ReadFile(filepath.Join(dir, "book.012"))132+ return err == nil && strings.Contains(string(data), `run(\"data.sort_range\", answer={\"by\": [{\"column\": \"B\", \"order\": \"desc\"}], \"header\": True})`)133+ })134+}
M e2e/testdata/screens/palette-narrow.html +1 -1
@@ -12,5 +12,5 @@
1212 <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>1313 <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>1414 <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)">40</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">167</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)">40</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">168</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 +12 -12
@@ -3,18 +3,18 @@
33 <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> col <span style="color:var(--vt-palette-8)">│</span>44 <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>55 <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> Border <span style="color:var(--vt-palette-4);font-weight:bold">col</span>or <span style="color:var(--vt-palette-0)">Format</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Borders</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> 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>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> 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>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> 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>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> 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>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> 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>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> 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>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> <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)">│</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> 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>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> 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>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> 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>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)">26</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">167</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)">│</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)">28</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">168</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 +12 -12
@@ -3,18 +3,18 @@
33 <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> col <span style="color:var(--vt-palette-8)">│</span>44 <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>55 <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> Border <span style="color:var(--vt-palette-3);font-weight:bold">col</span>or <span style="color:var(--vt-palette-8)">Format</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Borders</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> 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>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> 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>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> 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>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> 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>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> 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>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> 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>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-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)">│</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> 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>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> 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>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> 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>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)">26</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">167</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)">│</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)">28</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">168</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>
M e2e/testdata/screens/palette-wide.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt rows be<span style="color:var(--vt-palette-3);font-weight:bold">l</span>ow <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Re<span style="color:var(--vt-palette-3);font-weight:bold">se</span>t co<span style="color:var(--vt-palette-3);font-weight:bold">l</span>umn width <span style="color:var(--vt-palette-8)">Format</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt co<span style="color:var(--vt-palette-3);font-weight:bold">l</span>umns right <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">22</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">167</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)">168</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 +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> 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>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Locale <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>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> 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>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)">167</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">167</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)">168</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">168</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>
A internal/macro/answer.go +162 -0
@@ -0,0 +1,162 @@
1+package macro2+3+import (4+ "bytes"5+ "encoding/json"6+ "fmt"7+ "io"8+ "strconv"9+ "strings"10+11+ "go.starlark.net/starlark"12+ "go.starlark.net/syntax"13+)14+15+// Structured answers. A dialog's choices (the columns of a sort, the16+// values a filter hides, a chart's options) don't fit one string, so a17+// recording writes them as a dict, run("data.sort_range", answer={"by":18+// [{"column": "B"}]}), and run hands a dict or list answer to the Host19+// as JSON text, keys in the order written.20+21+// JSON is JSON text an Action writes as a Starlark dict or list literal,22+// keys in the order they come.23+type JSON string24+25+// starlarkLiteral writes JSON text as a Starlark literal: objects as26+// dicts, arrays as lists, and null, true and false as None, True and27+// False.28+func starlarkLiteral(text string) (string, error) {29+ d := json.NewDecoder(strings.NewReader(text))30+ d.UseNumber()31+ var b strings.Builder32+ if err := writeLiteral(&b, d); err != nil {33+ return "", err34+ }35+ if _, err := d.Token(); err != io.EOF {36+ return "", fmt.Errorf("more than one value in %q", text)37+ }38+ return b.String(), nil39+}40+41+// writeLiteral writes the next JSON value of d.42+func writeLiteral(b *strings.Builder, d *json.Decoder) error {43+ t, err := d.Token()44+ if err != nil {45+ return err46+ }47+ switch t := t.(type) {48+ case json.Delim:49+ return writeContainer(b, d, t)50+ case string:51+ b.WriteString(syntax.Quote(t, false))52+ case json.Number:53+ b.WriteString(t.String())54+ case bool:55+ b.WriteString(map[bool]string{true: "True", false: "False"}[t])56+ case nil:57+ b.WriteString("None")58+ }59+ return nil60+}61+62+// writeContainer writes an object or array whose opening delimiter was63+// open.64+func writeContainer(b *strings.Builder, d *json.Decoder, open json.Delim) error {65+ obj := open == '{'66+ if obj {67+ b.WriteByte('{')68+ } else {69+ b.WriteByte('[')70+ }71+ for i := 0; d.More(); i++ {72+ if i > 0 {73+ b.WriteString(", ")74+ }75+ if obj {76+ k, err := d.Token()77+ if err != nil {78+ return err79+ }80+ b.WriteString(syntax.Quote(k.(string), false) + ": ")81+ }82+ if err := writeLiteral(b, d); err != nil {83+ return err84+ }85+ }86+ if _, err := d.Token(); err != nil { // the closing delimiter87+ return err88+ }89+ if obj {90+ b.WriteByte('}')91+ } else {92+ b.WriteByte(']')93+ }94+ return nil95+}96+97+// answerJSON writes a script's dict or list answer as JSON text, keys in98+// the order the dict holds them.99+func answerJSON(v starlark.Value) (string, error) {100+ var b bytes.Buffer101+ if err := writeJSON(&b, v); err != nil {102+ return "", err103+ }104+ return b.String(), nil105+}106+107+func writeJSON(b *bytes.Buffer, v starlark.Value) error {108+ switch v := v.(type) {109+ case starlark.NoneType:110+ b.WriteString("null")111+ case starlark.Bool:112+ b.WriteString(strconv.FormatBool(bool(v)))113+ case starlark.Int:114+ b.WriteString(v.String())115+ case starlark.Float:116+ raw, err := json.Marshal(float64(v))117+ if err != nil {118+ return fmt.Errorf("run: answer: %v", err)119+ }120+ b.Write(raw)121+ case starlark.String:122+ raw, _ := json.Marshal(string(v))123+ b.Write(raw)124+ case *starlark.Dict:125+ return writeDict(b, v)126+ case starlark.Indexable: // lists and tuples127+ b.WriteByte('[')128+ for i := range v.Len() {129+ if i > 0 {130+ b.WriteByte(',')131+ }132+ if err := writeJSON(b, v.Index(i)); err != nil {133+ return err134+ }135+ }136+ b.WriteByte(']')137+ default:138+ return fmt.Errorf("run: an answer can't hold a %s", v.Type())139+ }140+ return nil141+}142+143+func writeDict(b *bytes.Buffer, d *starlark.Dict) error {144+ b.WriteByte('{')145+ for i, kv := range d.Items() {146+ k, ok := kv[0].(starlark.String)147+ if !ok {148+ return fmt.Errorf("run: an answer's keys are strings, not %s", kv[0].Type())149+ }150+ if i > 0 {151+ b.WriteByte(',')152+ }153+ raw, _ := json.Marshal(string(k))154+ b.Write(raw)155+ b.WriteByte(':')156+ if err := writeJSON(b, kv[1]); err != nil {157+ return err158+ }159+ }160+ b.WriteByte('}')161+ return nil162+}
A internal/macro/answer_test.go +44 -0
@@ -0,0 +1,44 @@
1+package macro2+3+import (4+ "strings"5+ "testing"6+)7+8+func TestJSONAnswersAreWrittenAsDicts(t *testing.T) {9+ a := Call("run", "data.sort_range").With("answer", JSON(`{"by":[{"column":"B","order":"desc"},{"column":"A"}],"header":true,"min":-1.5,"x":null,"q":"it's \"so\""}`))10+ want := `run("data.sort_range", answer={"by": [{"column": "B", "order": "desc"}, {"column": "A"}], "header": True, "min": -1.5, "x": None, "q": "it's \"so\""})`11+ if got := a.String(); got != want {12+ t.Fatalf("got %s\nwant %s", got, want)13+ }14+ // The script hands the answer back as the JSON it came from, keys in15+ // the order written.16+ f := newFake()17+ if _, err := New("m", Source("x", []Action{a}), Env{Host: f}).Exec(); err != nil {18+ t.Fatal(err)19+ }20+ wantCall := `run data.sort_range "{\"by\":[{\"column\":\"B\",\"order\":\"desc\"},{\"column\":\"A\"}],\"header\":true,\"min\":-1.5,\"x\":null,\"q\":\"it's \\\"so\\\"\"}"`21+ if len(f.calls) != 1 || f.calls[0] != wantCall {22+ t.Fatalf("calls %q\nwant %q", f.calls, wantCall)23+ }24+}25+26+func TestListAnswersAndBadOnes(t *testing.T) {27+ f := newFake()28+ if _, err := New("m", `run("x", answer=[1, (2, 3), {"k": [True]}])`, Env{Host: f}).Exec(); err != nil {29+ t.Fatal(err)30+ }31+ if f.calls[0] != `run x "[1,[2,3],{\"k\":[true]}]"` {32+ t.Fatalf("calls %q", f.calls)33+ }34+ for src, want := range map[string]string{35+ `run("x", answer={1: 2})`: "keys are strings, not int",36+ `run("x", answer={"f": print})`: "can't hold a builtin_function_or_method",37+ `run("x", answer=[float("nan")])`: "unsupported value",38+ } {39+ _, err := New("m", src, Env{Host: newFake()}).Exec()40+ if err == nil || !strings.Contains(err.Error(), want) {41+ t.Errorf("%s: %v, want %q", src, err, want)42+ }43+ }44+}
M internal/macro/builtins.go +9 -1
M internal/macro/macro.go +9 -2
A internal/sheet/linejson.go +70 -0
@@ -0,0 +1,70 @@
1+package sheet2+3+import (4+ "encoding/json"5+ "fmt"6+)7+8+// A chart, a pivot table and a filter column's criteria as the lines of9+// the file that hold them, to be written and read one at a time: macros10+// answer the dialogs that make them with these (see11+// docs/reference/macro-api.md), as they answer the rules panel with a12+// rule's line.13+14+// JSON is the chart as a line of the file.15+func (c Chart) JSON() string {16+ raw, _ := encodeChart(c)17+ return string(raw)18+}19+20+// ParseChart reads a chart written by JSON.21+func ParseChart(line string) (Chart, error) {22+ var fc fileChart23+ if err := json.Unmarshal([]byte(line), &fc); err != nil {24+ return Chart{}, err25+ }26+ return decodeChart(fc)27+}28+29+// JSON is the pivot table's definition as a line of the file.30+func (p Pivot) JSON() string {31+ raw, _ := encodePivot(&p)32+ return string(raw)33+}34+35+// ParsePivot reads a pivot table's definition written by JSON.36+func ParsePivot(line string) (Pivot, error) {37+ var fp filePivot38+ if err := json.Unmarshal([]byte(line), &fp); err != nil {39+ return Pivot{}, err40+ }41+ p, err := decodePivot(fp)42+ if err != nil {43+ return Pivot{}, err44+ }45+ return *p, nil46+}47+48+// JSON is the criteria as a filter column's are written in the file:49+// the values hidden, and a condition and its value.50+func (cr Criteria) JSON() string {51+ raw, _ := json.Marshal(fileCriteria{Hidden: cr.Hidden, Condition: cr.Cond.Op.String(), Value: cr.Cond.Arg})52+ return string(raw)53+}54+55+// ParseCriteria reads criteria written by JSON.56+func ParseCriteria(line string) (Criteria, error) {57+ var fc fileCriteria58+ if err := json.Unmarshal([]byte(line), &fc); err != nil {59+ return Criteria{}, err60+ }61+ op, ok := ParseCondOp(fc.Condition)62+ if !ok {63+ return Criteria{}, fmt.Errorf("unknown filter condition %q", fc.Condition)64+ }65+ cr := Criteria{Hidden: fc.Hidden, Cond: Condition{Op: op, Arg: fc.Value}}66+ if !op.TakesArg() {67+ cr.Cond.Arg = ""68+ }69+ return cr, nil70+}
A internal/sheet/linejson_test.go +37 -0
@@ -0,0 +1,37 @@
1+package sheet2+3+import (4+ "strings"5+ "testing"6+)7+8+func TestLinesRoundTrip(t *testing.T) {9+ c := Chart{Type: ChartLine, Data: Rect{To: Addr{Col: 2, Row: 6}}, At: Addr{Col: 4, Row: 1}, W: 44, H: 14, Header: true, Title: "Sales"}10+ c.Legend = LegendNone11+ got, err := ParseChart(c.JSON())12+ if err != nil || got.JSON() != c.JSON() {13+ t.Fatalf("chart %s came back %s, %v", c.JSON(), got.JSON(), err)14+ }15+ if _, err := ParseChart(`{"type":"blimp","data":"A1:B2","at":"C1"}`); err == nil {16+ t.Error("a chart of an unknown type was read")17+ }18+19+ p := Pivot{Source: "Q3 plan", Range: Rect{To: Addr{Col: 3, Row: 9}}, Rows: []PivotGroup{{Col: 1, Desc: true}},20+ Values: []PivotValue{{Col: 3, Summarize: SumBy}}, Filters: []PivotFilter{{Col: 0, Criteria: Criteria{Hidden: []string{"North"}}}}, RowTotals: true}21+ back, err := ParsePivot(p.JSON())22+ if err != nil || back.JSON() != p.JSON() || !strings.HasPrefix(p.JSON(), `{"source":"'Q3 plan'!A1:D10"`) {23+ t.Fatalf("pivot %s came back %s, %v", p.JSON(), back.JSON(), err)24+ }25+26+ cr := Criteria{Hidden: []string{"", "x"}, Cond: Condition{Op: CondGreater, Arg: "3"}}27+ if cr.JSON() != `{"hidden":["","x"],"condition":"gt","value":"3"}` {28+ t.Fatalf("criteria %s", cr.JSON())29+ }30+ crBack, err := ParseCriteria(cr.JSON())31+ if err != nil || crBack.JSON() != cr.JSON() {32+ t.Fatalf("criteria came back %s, %v", crBack.JSON(), err)33+ }34+ if empty, _ := ParseCriteria(`{"condition":"empty","value":"ignored"}`); empty.Cond.Arg != "" {35+ t.Errorf("a condition without a value kept %q", empty.Cond.Arg)36+ }37+}
M internal/ui/assist.go +13 -265
@@ -1,237 +1,22 @@
11 package ui22 33 import (4- "slices"5- "strconv"6- "strings"7-84 tea "charm.land/bubbletea/v2"9- "github.com/charmbracelet/x/ansi"105 116 "github.com/FelineStateMachine/012/internal/sheet"127 "github.com/FelineStateMachine/012/internal/ui/formula"13- "github.com/FelineStateMachine/012/internal/ui/overlay"14- "github.com/FelineStateMachine/012/internal/ui/theme"8+ "github.com/FelineStateMachine/012/internal/ui/suggest"159 )1610 17-// Formula assistance, as in Sheets: while a function, range or sheet18-// name is being typed, a list of matching functions, named ranges and19-// other sheets (assistsheets.go) drops down from the formula bar at the20-// caret, and while the caret is inside a21-// function's parentheses the context line shows its signature with the22-// current argument marked. The list takes only the keys Sheets gives it23-// (Up/Down, Tab, Enter, Esc) and only while it shows, so arrows after an24-// operator still point at cells.25-26-// assist is the suggestion list: its state, and the keys, mouse, box27-// and status line it takes over while it shows. Like an overlay, it is28-// handed the model, for the entry and the sheet's names.29-type assist struct {30- active bool // the last key typed or deleted text; moving the caret hides the list31- sel, top int // highlighted suggestion and first one shown; reset as the word changes32-}33-34-// assistRows is the most suggestions shown at once.35-const assistRows = 836-37-const assistID = "assist"38-39-// suggestion is a function, named range or sheet offered for the word40-// at the caret.41-type suggestion struct {42- name string // e.g. "SUM", "Sales" or "'Q3 plan'!"43- detail string // the signature, the range a name stands for, or a sheet's cells44- desc string45- fn bool // a function: accepting it adds "("46-}47-48-// suggestions lists the functions, named ranges and sheets for word:49-// those starting with it first (names, then sheets, then functions),50-// then, from two letters on, those containing it, such as COUNTIFS for51-// "ifs". A word that is already a cell reference only gets those that52-// start with it, and a word in quotes ('Q3) only sheets.53-func suggestions(sh *sheet.Sheet, word string) []suggestion {54- if rest, quoted := strings.CutPrefix(word, "'"); quoted {55- return sheetSuggestions(sh, strings.ToUpper(rest))56- }57- w := strings.ToUpper(word)58- var prefix, inner []suggestion59- add := func(s suggestion, name string) {60- switch {61- case strings.HasPrefix(name, w):62- prefix = append(prefix, s)63- case len(w) >= 2 && strings.Contains(name, w):64- inner = append(inner, s)65- }66- }67- for _, n := range sh.Names() {68- add(suggestion{name: n.Name, detail: n.Ref(), desc: "Named range " + n.Name + ": " + n.Ref()}, strings.ToUpper(n.Name))69- }70- for _, s := range otherSheets(sh) {71- add(sheetSuggestion(s), strings.ToUpper(s.Name()))72- }73- for _, f := range sheet.Funcs() {74- add(suggestion{name: f.Name, detail: f.Name + "(" + f.Args + ")", desc: f.Desc, fn: true}, f.Name)75- }76- if _, isRef := sheet.ParseAddr(w); isRef {77- return prefix78- }79- return append(prefix, inner...)80-}81-82-// shown returns the list for the word at the caret, and where83-// the word starts, or nothing when the list is hidden: outside ENTER and84-// EDIT, in plain text, after the caret moved, or when the word already85-// names a range exactly and nothing else matches.86-func (a *assist) shown(m *Model) ([]suggestion, int) {87- if (m.mode != modeEnter && m.mode != modeEdit) || !m.line.IsFormula() || !a.active || m.overlay != nil {88- return nil, 089- }90- c := formula.ScanCaret(m.storedFormula(m.line.Buf), m.line.Pos)91- if c.Word == "" {92- return nil, 093- }94- list := suggestions(m.entrySheet(), c.Word)95- if len(list) == 0 || len(list) == 1 && !list[0].fn && strings.EqualFold(list[0].name, c.Word) {96- return nil, 097- }98- a.sel = min(a.sel, len(list)-1)99- return list, c.WordStart100-}11+// Formula suggestions and signatures are package suggest; here is what12+// they ask of the model.10113 10214 // typeKey applies a line-editing key to the entry. Typing or deleting10315 // shows suggestions; moving the caret hides them, as in Sheets.10416 func (m *Model) typeKey(k tea.KeyPressMsg) {10517 before := m.line.Text()10618 m.line.Key(k)107- m.entry.assist = assist{active: m.line.Text() != before}108-}109-110-// key handles the keys the suggestion list takes while it shows.111-func (a *assist) key(m *Model, key string) bool {112- list, start := a.shown(m)113- if list == nil {114- return false115- }116- switch key {117- case "up", "ctrl+p":118- a.sel = (a.sel - 1 + len(list)) % len(list)119- case "down", "ctrl+n":120- a.sel = (a.sel + 1) % len(list)121- case "tab", "enter":122- a.accept(m, list[a.sel], start)123- case "esc":124- a.active = false125- default:126- return false127- }128- return true129-}130-131-// accept replaces the word at the caret with s, adding "(" after132-// a function unless one is already there.133-func (a *assist) accept(m *Model, s suggestion, start int) {134- text := []rune(s.name)135- rest := m.line.Buf[m.line.Pos:]136- if s.fn && (len(rest) == 0 || rest[0] != '(') {137- text = append(text, '(')138- }139- if strings.HasSuffix(s.name, "!") {140- rest = trimSheetEnd(rest) // the rest of a sheet name typed before141- }142- pos := start + len(text)143- if s.fn && len(rest) > 0 && rest[0] == '(' {144- pos++ // into the existing parentheses145- }146- m.line.Buf = slices.Concat(m.line.Buf[:start], text, rest)147- m.line.Pos = pos148- a.active = false149-}150-151-// box draws the list under the formula bar, its text aligned with152-// the word being typed: the name, then the signature or range dimmed.153-func (a *assist) box(m *Model) (overlay.Box, bool) {154- list, start := a.shown(m)155- if list == nil {156- return overlay.Box{}, false157- }158- rows := min(len(list), assistRows, m.height-gridTop-2)159- if rows < 1 {160- return overlay.Box{}, false161- }162- a.top = clamp(a.top, a.sel-rows+1, a.sel)163- a.top = clamp(a.top, 0, len(list)-rows)164- nw, dw := 0, 0165- for _, s := range list {166- nw = max(nw, ansi.StringWidth(s.name))167- dw = max(dw, ansi.StringWidth(s.detail))168- }169- inner := min(1+nw+3+dw+1, 64, m.width-2)170- lines := make([]string, 0, rows)171- for i := a.top; i < a.top+rows; i++ {172- s := list[i]173- base, dim := m.th.MenuBar, m.th.Muted174- if i == a.sel {175- base, dim = m.th.MenuSelected, m.th.MenuSelected176- }177- detail := s.detail178- if s.fn {179- detail = s.detail[len(s.name):] // just the arguments: (value1, ...)180- }181- row := base.Render(" "+theme.PadRight(s.name, nw)+" ") + dim.Render(detail)182- lines = append(lines, ansi.Truncate(row, inner, "…")+base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row), 0))))183- }184- footer := ""185- if len(list) > rows {186- footer = strconv.Itoa(a.sel+1) + " of " + strconv.Itoa(len(list))187- }188- // The box's text starts one column in, under the word's first letter.189- x := formulaBarTextX() + ansi.StringWidth(string(m.line.Buf[:start])) - 2190- x = clamp(x, 0, max(m.width-inner-2, 0))191- return overlay.Box{ID: assistID, X: x, Y: contextLine + 1, Lines: m.th.Frame(inner, "", footer, lines)}, true192-}193-194-// mouse lets the mouse hover and click suggestions and scroll the195-// list. It reports false for events outside the list.196-func (a *assist) mouse(m *Model, msg tea.MouseMsg) bool {197- b, ok := a.box(m)198- if !ok {199- return false200- }201- mouse := msg.Mouse()202- h := compositor([]overlay.Box{b}).Hit(mouse.X, mouse.Y)203- if h.Empty() {204- return false205- }206- list, start := a.shown(m)207- i := a.top + mouse.Y - b.Y - 1208- switch msg.(type) {209- case tea.MouseWheelMsg:210- d := 1211- if mouse.Button == tea.MouseWheelUp {212- d = -1213- }214- a.sel = clamp(a.sel+d, 0, len(list)-1)215- case tea.MouseMotionMsg:216- if i >= a.top && i < min(len(list), a.top+b.Height()-2) {217- a.sel = i218- }219- case tea.MouseClickMsg:220- if mouse.Button == tea.MouseLeft && i >= a.top && i < min(len(list), a.top+b.Height()-2) {221- a.accept(m, list[i], start)222- }223- }224- return true225-}226-227-// status is the status line while the list shows: what the228-// highlighted suggestion is, and the keys that apply.229-func (a *assist) status(m *Model) (desc, keys string, ok bool) {230- list, _ := a.shown(m)231- if list == nil {232- return "", "", false233- }234- return list[a.sel].desc, m.th.KeyHints("Up/Down", "move", "Tab", "insert", "Esc", "hide"), true19+ m.entry.assist = suggest.List{Active: m.line.Text() != before}23520 }23621 23722 // inFunction reports whether the caret is inside a known function's
@@ -241,51 +26,14 @@ func (m *Model) inFunction() bool {
24126 return ok24227 }24328 244-// signatureLine puts the signature of the function around the caret on245-// the context line, with what the function does and the keys that apply246-// (hints) as room allows. It reports false outside a function.247-func signatureLine(th *theme.Theme, width int, buf []rune, pos int, sep byte, hints string) (left, right string, ok bool) {248- sig, desc := signature(th, buf, pos, sep)249- if sig == "" {250- return "", "", false251- }252- full := sig + " " + th.Muted.Render(desc)253- for _, try := range [][2]string{{full, hints}, {sig, hints}, {full, ""}} {254- if ansi.StringWidth(try[0])+3+ansi.StringWidth(try[1]) <= width {255- return try[0], try[1], true256- }257- }258- return sig, "", true259-}29+// The suggestions' host.26030 261-// signature renders the signature of the function around the caret, e.g.262-// SUM(value1, [value2, ...]), with the current argument marked, and what263-// the function does. It is "" outside a function. buf is the formula in264-// the syntax it's parsed in (see Model.storedFormula); sep separates the265-// arguments shown, ; in a locale with a decimal comma.266-func signature(th *theme.Theme, buf []rune, pos int, sep byte) (sig, desc string) {267- c := formula.ScanCaret(buf, pos)268- f, ok := sheet.LookupFunc(c.Fn)269- if c.Fn == "" || !ok {270- return "", ""271- }272- parts := formula.SplitArgs(f.Args)273- cur := formula.ArgPart(parts, c.Arg, f.Max < 0)274- var b strings.Builder275- b.WriteString(th.Key.Render(f.Name) + "(")276- for i, p := range parts {277- if i > 0 {278- b.WriteString(string(sep) + " ")279- }280- if sep != ',' {281- p = strings.ReplaceAll(p, ",", string(sep)) // [value2; ...]282- }283- if i == cur {284- b.WriteString(th.Argument.Render(p))285- } else {286- b.WriteString(p)287- }288- }289- b.WriteString(")")290- return b.String(), f.Desc31+// Typing reports whether an entry is being typed in the cell (ENTER or32+// EDIT) with nothing open over it.33+func (h host) Typing() bool {34+ return (h.m.mode == modeEnter || h.m.mode == modeEdit) && h.m.overlay == nil29135 }36+37+func (h host) EntrySheet() *sheet.Sheet { return h.m.entrySheet() }38+func (h host) Stored(buf []rune) []rune { return h.m.storedFormula(buf) }39+func (h host) TextX() int { return formulaBarTextX() }
M internal/ui/assist_test.go +15 -13
@@ -5,18 +5,20 @@ import (
55 "testing"66 77 tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/ui/suggest"810 )911 1012 func TestAutocompleteFunctions(t *testing.T) {1113 m := newModel()1214 press(t, m, "=su")13- list, _ := m.entry.assist.shown(m)14- if len(list) == 0 || list[0].name != "SUBSTITUTE" && list[0].name != "SUM" {15+ list, _ := m.entry.assist.Shown(m.host())16+ if len(list) == 0 || list[0].Name != "SUBSTITUTE" && list[0].Name != "SUM" {1517 t.Fatalf("suggestions %v", list)1618 }1719 for _, s := range list {18- if !strings.HasPrefix(s.name, "SU") && !strings.Contains(s.name, "SU") {19- t.Errorf("unexpected %s", s.name)20+ if !strings.HasPrefix(s.Name, "SU") && !strings.Contains(s.Name, "SU") {21+ t.Errorf("unexpected %s", s.Name)2022 }2123 }2224 scr := screen(m)
@@ -27,7 +29,7 @@ func TestAutocompleteFunctions(t *testing.T) {
2729 t.Errorf("status %q", st)2830 }2931 // Down moves the highlight rather than committing; Tab inserts.30- for list[m.entry.assist.sel].name != "SUM" {32+ for list[m.entry.assist.Sel()].Name != "SUM" {3133 press(t, m, "<down>")3234 }3335 press(t, m, "<tab>")
@@ -38,7 +40,7 @@ func TestAutocompleteFunctions(t *testing.T) {
3840 t.Errorf("signature %q", ctx)3941 }4042 press(t, m, "1,")41- sig, _ := signature(&m.th, m.line.Buf, m.line.Pos, ',')43+ sig, _ := suggest.Signature(&m.th, m.line.Buf, m.line.Pos, ',')4244 if !strings.Contains(sig, m.th.Argument.Render("[value2, ...]")) {4345 t.Errorf("second argument not marked: %q", sig)4446 }
@@ -62,7 +64,7 @@ func TestAutocompleteKeys(t *testing.T) {
6264 // Esc hides the list, a second Esc cancels the entry.6365 press(t, m, "=ro")6466 press(t, m, "<esc>")65- if list, _ := m.entry.assist.shown(m); list != nil || m.mode != modeEnter {67+ if list, _ := m.entry.assist.Shown(m.host()); list != nil || m.mode != modeEnter {6668 t.Fatalf("esc: %v %v", list, m.mode)6769 }6870 press(t, m, "<esc>")
@@ -77,16 +79,16 @@ func TestAutocompleteKeys(t *testing.T) {
7779 press(t, m, "<esc>", "<esc>")7880 // Moving the caret hides the list; a cell reference gets no list.7981 press(t, m, "=co", "<left>")80- if list, _ := m.entry.assist.shown(m); list != nil {82+ if list, _ := m.entry.assist.Shown(m.host()); list != nil {8183 t.Error("list after moving the caret")8284 }8385 press(t, m, "<esc>", "=B2")84- if list, _ := m.entry.assist.shown(m); list != nil {86+ if list, _ := m.entry.assist.Shown(m.host()); list != nil {8587 t.Errorf("list for a reference: %v", list)8688 }8789 // Plain text gets no list.8890 press(t, m, "<esc>", "su")89- if list, _ := m.entry.assist.shown(m); list != nil {91+ if list, _ := m.entry.assist.Shown(m.host()); list != nil {9092 t.Error("list for text")9193 }9294 }
@@ -95,12 +97,12 @@ func TestAutocompleteNamesAndMouse(t *testing.T) {
9597 m := tallModel()9698 m.sheet.DefineName("Sales", rectOf("B1:B3"))9799 press(t, m, "=SUM(sa")98- list, start := m.entry.assist.shown(m)99- if list[0].name != "Sales" || list[0].fn {100+ list, start := m.entry.assist.Shown(m.host())101+ if list[0].Name != "Sales" || list[0].Fn {100102 t.Fatalf("names first: %v", list)101103 }102104 // Clicking a suggestion inserts it.103- b, _ := m.entry.assist.box(m)105+ b, _ := m.entry.assist.Box(m.host())104106 send(m, tea.MouseClickMsg{X: b.X + 3, Y: b.Y + 1, Button: tea.MouseLeft})105107 if m.line.Text() != "=SUM(Sales" || m.line.Pos != start+5 {106108 t.Fatalf("click: %q", m.line.Text())
M internal/ui/assistsheets.go +4 -54
@@ -1,64 +1,14 @@
11 package ui22 33 import (4- "slices"5- "strings"6-74 "github.com/FelineStateMachine/012/internal/sheet"85 "github.com/FelineStateMachine/012/internal/ui/lineedit"96 )107 11-// Sheet names in formula suggestions: typing =Su offers Summary! with the12-// functions and named ranges, and ='Q3 offers 'Q3 plan'!, quoted as a13-// reference needs; the sheet the formula is on isn't, as its cells need14-// no name, and neither are hidden sheets. Accepting one inserts the sheet15-// and its "!"; an arrow then points into that sheet, as clicking its tab16-// would, so the reference comes out as Summary!B3.17-18-// sheetSuggestion is the suggestion for sheet s.19-func sheetSuggestion(s *sheet.Sheet) suggestion {20- detail, desc := "sheet, empty", "Sheet "+s.Name()+", empty"21- if used, ok := s.UsedRange(); ok {22- detail = "sheet, " + used.String()23- desc = "Sheet " + s.Name() + ": " + used.String() + ", " + cellCount(s.Len())24- }25- return suggestion{name: sheet.QuoteSheet(s.Name()) + "!", detail: detail, desc: desc}26-}27-28-// otherSheets are the sheets offered in formulas typed on sh: the29-// visible ones but sh, whose cells need no sheet name.30-func otherSheets(sh *sheet.Sheet) []*sheet.Sheet {31- return slices.DeleteFunc(sh.Book().Visible(), func(s *sheet.Sheet) bool { return s == sh })32-}33-34-// sheetSuggestions lists the other visible sheets for w, a quoted name typed35-// so far in upper case: those starting with it, then from two letters36-// on, those containing it.37-func sheetSuggestions(sh *sheet.Sheet, w string) []suggestion {38- var prefix, inner []suggestion39- for _, s := range otherSheets(sh) {40- name := strings.ToUpper(s.Name())41- switch {42- case strings.HasPrefix(name, w):43- prefix = append(prefix, sheetSuggestion(s))44- case len(w) >= 2 && strings.Contains(name, w):45- inner = append(inner, sheetSuggestion(s))46- }47- }48- return append(prefix, inner...)49-}50-51-// trimSheetEnd drops the end of a sheet reference ("!" or "'!") that52-// follows the caret, which an accepted sheet brings with it.53-func trimSheetEnd(rest []rune) []rune {54- switch {55- case len(rest) > 0 && rest[0] == '!':56- return rest[1:]57- case len(rest) > 1 && rest[0] == '\'' && rest[1] == '!':58- return rest[2:]59- }60- return rest61-}8+// Pointing after a sheet's name: once a suggestion (package suggest) or9+// typing has put Summary! before the caret, an arrow points into that10+// sheet, as clicking its tab would, so the reference comes out as11+// Summary!B3.6212 6313 // sheetBeforeCaret returns the sheet named just before the caret, as in6414 // "=SUM(Summary!" or "='Q3 plan'!", and where its name starts.
M internal/ui/assistsheets_test.go +10 -8
@@ -3,6 +3,8 @@ package ui
33 import (44 "strings"55 "testing"6+7+ "github.com/FelineStateMachine/012/internal/ui/suggest"68 )79 810 // sheetsModel has Sheet1 (shown), Summary with 7 in B3, and Q3 plan with
@@ -19,10 +21,10 @@ func sheetsModel(t *testing.T) *Model {
1921 return m2022 }2123 22-func names(list []suggestion) []string {24+func names(list []suggest.Suggestion) []string {2325 var out []string2426 for _, s := range list {25- out = append(out, s.name)27+ out = append(out, s.Name)2628 }2729 return out2830 }
@@ -30,7 +32,7 @@ func names(list []suggestion) []string {
3032 func TestAutocompleteSheets(t *testing.T) {3133 m := sheetsModel(t)3234 press(t, m, "=su")33- list, _ := m.entry.assist.shown(m)35+ list, _ := m.entry.assist.Shown(m.host())3436 got := names(list)3537 if len(got) < 2 || got[0] != "Summary!" || !strings.HasPrefix(got[1], "SU") {3638 t.Fatalf("suggestions %v", got)
@@ -55,8 +57,8 @@ func TestAutocompleteSheets(t *testing.T) {
5557 // inside quotes.5658 for _, typed := range []string{"=q3", "='q3 p", "='pl"} {5759 press(t, m, "<esc>", "<esc>", typed)58- list, start := m.entry.assist.shown(m)59- if len(list) == 0 || list[0].name != "'Q3 plan'!" || start != 1 {60+ list, start := m.entry.assist.Shown(m.host())61+ if len(list) == 0 || list[0].Name != "'Q3 plan'!" || start != 1 {6062 t.Errorf("%s: %v from %d", typed, names(list), start)6163 }6264 }
@@ -81,7 +83,7 @@ func TestAutocompleteSheets(t *testing.T) {
8183 func TestAutocompleteSheetsEdges(t *testing.T) {8284 m := sheetsModel(t)8385 press(t, m, "=she")84- if list, _ := m.entry.assist.shown(m); len(list) > 0 && list[0].name == "Sheet1!" {86+ if list, _ := m.entry.assist.Shown(m.host()); len(list) > 0 && list[0].Name == "Sheet1!" {8587 t.Errorf("the formula's own sheet offered: %v", names(list))8688 }8789 press(t, m, "et1!", "<right>")
@@ -92,7 +94,7 @@ func TestAutocompleteSheetsEdges(t *testing.T) {
9294 9395 m.book().HideSheet(m.book().Lookup("Summary"))9496 press(t, m, "=summ")95- if list, _ := m.entry.assist.shown(m); len(list) > 0 && list[0].name == "Summary!" {97+ if list, _ := m.entry.assist.Shown(m.host()); len(list) > 0 && list[0].Name == "Summary!" {9698 t.Errorf("hidden sheet offered: %v", names(list))9799 }98100 press(t, m, "<esc>")
@@ -100,7 +102,7 @@ func TestAutocompleteSheetsEdges(t *testing.T) {
100102 // Accepting inside an existing reference replaces the name only.101103 m.line.Set("='Q3 p'!A1")102104 m.line.Pos = len("='Q3 p")103- m.entry.assist.active = true105+ m.entry.assist.Active = true104106 m.mode = modeEnter105107 press(t, m, "<tab>")106108 if m.line.Text() != "='Q3 plan'!A1" || m.line.Pos != len("='Q3 plan'!") {
M internal/ui/chartaxes.go +23 -26
@@ -4,8 +4,6 @@ import (
44 "strconv"55 "strings"66 7- tea "charm.land/bubbletea/v2"8-97 "github.com/FelineStateMachine/012/internal/sheet"108 )119
@@ -23,8 +21,8 @@ const (
2321 axisLegend = "p"2422 )2523 26-func (e *chartEditor) axisParts(m *Model) []editorPart {27- c := e.chart(m)24+func (e *chartEditor) axisParts() []editorPart {25+ c := e.chart()2826 end := func(name string, v float64, set bool, key string) editorPart {2927 text := name + " auto"3028 if set {
@@ -41,57 +39,56 @@ func (e *chartEditor) axisParts(m *Model) []editorPart {
4139 }4240 }4341 44-func (e *chartEditor) axisKey(m *Model, key string) {42+func (e *chartEditor) axisKey(key string) {4543 switch key {4644 case "enter", "esc":4745 e.axes = false4846 case axisMin:49- e.editAxisEnd(m, "Axis minimum (blank for auto):", func(c *sheet.Chart) (*float64, *bool) { return &c.Min, &c.HasMin })47+ e.editAxisEnd("Axis minimum (blank for auto):", func(c *sheet.Chart) (*float64, *bool) { return &c.Min, &c.HasMin })5048 case axisMax:51- e.editAxisEnd(m, "Axis maximum (blank for auto):", func(c *sheet.Chart) (*float64, *bool) { return &c.Max, &c.HasMax })49+ e.editAxisEnd("Axis maximum (blank for auto):", func(c *sheet.Chart) (*float64, *bool) { return &c.Max, &c.HasMax })5250 case axisLog:53- e.set(m, func(c *sheet.Chart) { c.Log = !c.Log })51+ e.set(func(c *sheet.Chart) { c.Log = !c.Log })5452 case axisGrid:55- e.set(m, func(c *sheet.Chart) { c.NoGrid = !c.NoGrid })53+ e.set(func(c *sheet.Chart) { c.NoGrid = !c.NoGrid })5654 case axisLegend:57- e.set(m, func(c *sheet.Chart) { c.Legend = (c.Legend + 1) % 3 })55+ e.set(func(c *sheet.Chart) { c.Legend = (c.Legend + 1) % 3 })5856 }5957 }6058 6159 // editAxisEnd asks for an end of the value axis, as a number the way6260 // cells take one ("500", "$1,200", "10%"); blank makes it automatic.63-func (e *chartEditor) editAxisEnd(m *Model, label string, end func(*sheet.Chart) (*float64, *bool)) {64- c := e.chart(m)61+func (e *chartEditor) editAxisEnd(label string, end func(*sheet.Chart) (*float64, *bool)) {62+ c := e.chart()6563 v, set := end(&c)6664 initial := ""6765 if *set {6866 initial = strconv.FormatFloat(*v, 'g', -1, 64)6967 }70- m.closeOverlay()71- e.askAxisEnd(m, label, label, initial, end)68+ e.m.closeOverlay()69+ e.askAxisEnd(label, label, initial, end)7270 }7371 7472 // askAxisEnd asks with the prompt label, and asks again, saying so, until7573 // the answer is a number or blank.76-func (e *chartEditor) askAxisEnd(m *Model, label, question, initial string, end func(*sheet.Chart) (*float64, *bool)) {77- m.openText(label, initial, func(m *Model, text string) tea.Cmd {74+func (e *chartEditor) askAxisEnd(label, question, initial string, end func(*sheet.Chart) (*float64, *bool)) {75+ e.m.askText(label, initial, func(text string) {7876 text = strings.TrimSpace(text)7977 n, _, ok := sheet.ParseValue(text)8078 switch {8179 case text == "":82- e.set(m, func(c *sheet.Chart) { _, set := end(c); *set = false })80+ e.set(func(c *sheet.Chart) { _, set := end(c); *set = false })8381 case !ok:84- e.askAxisEnd(m, "Not a number. "+question, question, text, end)85- return nil82+ e.askAxisEnd("Not a number. "+question, question, text, end)83+ return8684 default:87- e.set(m, func(c *sheet.Chart) { v, set := end(c); *v, *set = n, true })85+ e.set(func(c *sheet.Chart) { v, set := end(c); *v, *set = n, true })8886 }89- e.reopen(m)90- return nil91- })92- m.prompt.onCancel = e.reopen87+ e.reopen()88+ }, e.reopen)9389 }9490 95-func (e *chartEditor) axisStatus(m *Model) string {96- return fitHints(m, []string{"N", "min", "X", "max", "L", "log", "G", "grid", "P", "legend", "Esc", "back"})91+func (e *chartEditor) axisStatus() string {92+ width, _ := e.m.size()93+ return fitHints(e.m.styles(), width, []string{"N", "min", "X", "max", "L", "log", "G", "grid", "P", "legend", "Esc", "back"})9794 }
M internal/ui/charteditor.go +143 -86
@@ -9,8 +9,57 @@ import (
99 1010 "github.com/FelineStateMachine/012/internal/sheet"1111 "github.com/FelineStateMachine/012/internal/ui/overlay"12+ "github.com/FelineStateMachine/012/internal/ui/theme"1213 )1314 15+// chartHost is what the chart editor and a selected chart act on. The16+// model implements it. They stay in package ui: besides the charts of17+// the sheet shown, they reach the grid's geometry (where a chart is on18+// screen, the cell under the mouse), the pointer's shape, the chart's19+// context menu, and the model's prompts, and hand the keys and clicks20+// they don't take back to the grid, which takes more than a small21+// interface.22+type chartHost interface {23+ styles() *theme.Theme24+ size() (width, height int)25+ sheetShown() *sheet.Sheet26+ // syncChanged makes the modified flag follow the undo history.27+ syncChanged()28+ openOverlay(o overlay.Overlay)29+ closeOverlay()30+ runCommand(id string) tea.Cmd31+ // selectChart selects chart i and returns the selection.32+ selectChart(i int) *chartSel33+ // forgetChart leaves chart commands no chart to act on but the one34+ // under the active cell or the newest.35+ forgetChart()36+ // showChart scrolls chart i into view.37+ showChart(i int)38+ chartScreen(c sheet.Chart) (x, y int)39+ chartAt(x, y int) int40+ chartCellAt(x, y int) sheet.Addr41+ setShape(shape string) tea.Cmd42+ // holdsKeys reports whether keys can be held, and zoomed is chart c43+ // spread over the grid, as Space held shows it (keyboard.go).44+ holdsKeys() bool45+ zoomed(c sheet.Chart) sheet.Chart46+ showChartMenu(x, y int)47+ // say puts msg on the context line.48+ say(msg string)49+ askText(label, initial string, done func(string), cancel func())50+ pointRange(label string, done func(sheet.Rect), cancel func())51+ selectRect(r sheet.Rect)52+ clearSelection()53+ // passKey and passMouse hand what a chart doesn't take to the grid,54+ // as if nothing were selected.55+ passKey(k tea.KeyPressMsg) tea.Cmd56+ passMouse(e overlay.MouseEvent) tea.Cmd57+ // recordChart records a chart inserted, edited, moved or resized, as58+ // the command that does it answered with the chart; placed is set59+ // for a move or resize without a command (chartmacro.go).60+ recordChart(id string, i int, before, after sheet.Chart, placed bool)61+}62+1463 // chartEditor is the chart editor: a bar on the context line, in the1564 // spirit of the find bar, with the type as a row of chips and the options1665 // as toggles. A second bar (A) holds the value axis, gridlines and
@@ -18,139 +67,148 @@ import (
1867 // at once; Enter keeps them and Esc undoes them, removing a chart that1968 // was just inserted.2069 type chartEditor struct {21- m *Model // the model it acts on22- i int23- start int // the sheet's state before editing, to undo back to24- isNew bool25- axes bool // showing the axis options70+ m chartHost // the model, through what the editor needs of it71+ i int72+ start int // the sheet's state before editing, to undo back to73+ before sheet.Chart // the chart when the editor opened, for a macro74+ isNew bool75+ axes bool // showing the axis options2676 }2777 2878 // openChartEditor edits chart i. start is the sheet's state to return to2979 // on Esc.30-func (m *Model) openChartEditor(i int, isNew bool, start int) {31- if i < 0 || i >= len(m.sheet.Charts()) {32- return80+func (m *Model) openChartEditor(i int, isNew bool, start int) *chartEditor {81+ charts := m.sheet.Charts()82+ if i < 0 || i >= len(charts) {83+ return nil3384 }3485 m.charts.last = i35- m.openOverlay(&chartEditor{m: m, i: i, start: start, isNew: isNew})86+ e := &chartEditor{m: m, i: i, start: start, before: charts[i], isNew: isNew}87+ m.openOverlay(e)88+ return e3689 }3790 3891 func (e *chartEditor) Indicator() string { return "CHART" }3992 4093 func (e *chartEditor) Layout() []overlay.Box { return nil }4194 42-func (e *chartEditor) chart(m *Model) sheet.Chart { return m.sheet.Charts()[e.i] }95+func (e *chartEditor) chart() sheet.Chart { return e.m.sheetShown().Charts()[e.i] }4396 4497 // set applies a change to the chart as its own undo step.45-func (e *chartEditor) set(m *Model, fn func(c *sheet.Chart)) {46- c := e.chart(m)98+func (e *chartEditor) set(fn func(c *sheet.Chart)) {99+ c := e.chart()47100 fn(&c)48- m.sheet.SetChart(e.i, c, "edit chart")49- m.changed = m.sheet.StateID() != m.saved101+ e.m.sheetShown().SetChart(e.i, c, "edit chart")102+ e.m.syncChanged()103+}104+105+// keep closes the editor keeping the changes, and records them.106+func (e *chartEditor) keep() {107+ m := e.m108+ m.closeOverlay()109+ id := "chart.edit"110+ if e.isNew {111+ id = "insert.chart"112+ m.say("Inserted a chart of " + e.chart().Data.String())113+ }114+ m.recordChart(id, e.i, e.before, e.chart(), false)50115 }51116 52117 func (e *chartEditor) Key(k tea.KeyPressMsg) tea.Cmd {53118 m := e.m54- if e.i >= len(m.sheet.Charts()) {119+ if e.i >= len(m.sheetShown().Charts()) {55120 m.closeOverlay()56121 return nil57122 }58123 key := strings.ToLower(k.String())59124 if e.axes {60- e.axisKey(m, key)125+ e.axisKey(key)61126 return nil62127 }63128 switch key {64129 case "enter":130+ e.keep()65131 m.selectChart(e.i)66- if e.isNew {67- m.note = "Inserted a chart of " + e.chart(m).Data.String()68- }69132 case "esc":70- for m.sheet.StateID() != e.start && m.sheet.CanUndo() {71- m.sheet.Undo()133+ s := m.sheetShown()134+ for s.StateID() != e.start && s.CanUndo() {135+ s.Undo()72136 }73- m.changed = m.sheet.StateID() != m.saved137+ m.syncChanged()74138 m.closeOverlay()75- m.charts.last = -1139+ m.forgetChart()76140 case "left", "right", "shift+tab", "tab":77141 d := 178142 if key == "left" || key == "shift+tab" {79143 d = -180144 }81- e.set(m, func(c *sheet.Chart) {145+ e.set(func(c *sheet.Chart) {82146 n := len(sheet.ChartTypes)83147 c.Type = sheet.ChartTypes[((int(c.Type)+d)%n+n)%n]84148 })85149 case editorAxes:86150 e.axes = true87151 default:88- e.optionKey(m, key)152+ e.optionKey(key)89153 }90154 return nil91155 }92156 93157 // optionKey handles the keys of the main bar's options: a type's number,94158 // the toggles, the range and the title.95-func (e *chartEditor) optionKey(m *Model, key string) {159+func (e *chartEditor) optionKey(key string) {96160 if n, err := strconv.Atoi(key); err == nil && n >= 1 && n <= len(sheet.ChartTypes) {97- e.set(m, func(c *sheet.Chart) { c.Type = sheet.ChartTypes[n-1] })161+ e.set(func(c *sheet.Chart) { c.Type = sheet.ChartTypes[n-1] })98162 return99163 }100164 switch key {101165 case editorSwitch:102- e.set(m, func(c *sheet.Chart) { c.ByRow = !c.ByRow })166+ e.set(func(c *sheet.Chart) { c.ByRow = !c.ByRow })103167 case editorHeader:104- e.set(m, func(c *sheet.Chart) { c.Header = !c.Header })168+ e.set(func(c *sheet.Chart) { c.Header = !c.Header })105169 case editorLabels:106- e.set(m, func(c *sheet.Chart) { c.Labels = !c.Labels })170+ e.set(func(c *sheet.Chart) { c.Labels = !c.Labels })107171 case editorStack:108- if e.chart(m).Type.Stackable() {109- e.set(m, func(c *sheet.Chart) { c.Stack = (c.Stack + 1) % 3 })172+ if e.chart().Type.Stackable() {173+ e.set(func(c *sheet.Chart) { c.Stack = (c.Stack + 1) % 3 })110174 }111175 case editorTrend:112- if e.chart(m).Type == sheet.ChartScatter {113- e.set(m, func(c *sheet.Chart) { c.Trend = !c.Trend })176+ if e.chart().Type == sheet.ChartScatter {177+ e.set(func(c *sheet.Chart) { c.Trend = !c.Trend })114178 }115179 case editorRange:116- e.editRange(m)180+ e.editRange()117181 case editorTitle:118- e.editTitle(m)182+ e.editTitle()119183 }120184 }121185 122186 // reopen returns to the editor after a prompt.123-func (e *chartEditor) reopen(m *Model) {124- m.openOverlay(e)125-}187+func (e *chartEditor) reopen() { e.m.openOverlay(e) }126188 127189 // editRange asks for the data range by pointing, starting from the128190 // current one.129-func (e *chartEditor) editRange(m *Model) {130- d := e.chart(m).Data191+func (e *chartEditor) editRange() {192+ m := e.m131193 m.closeOverlay()132- m.cur, m.ext, m.selecting, m.whole = d.From, d.To, true, wholeNone133- m.openRange("Chart data:", func(m *Model, r sheet.Rect) tea.Cmd {194+ m.selectRect(e.chart().Data)195+ m.pointRange("Chart data:", func(r sheet.Rect) {134196 m.clearSelection()135- e.set(m, func(c *sheet.Chart) { c.Data = r })136- e.reopen(m)137- return nil138- })139- m.prompt.onCancel = func(m *Model) {197+ e.set(func(c *sheet.Chart) { c.Data = r })198+ e.reopen()199+ }, func() {140200 m.clearSelection()141- e.reopen(m)142- }201+ e.reopen()202+ })143203 }144204 145-func (e *chartEditor) editTitle(m *Model) {146- title := e.chart(m).Title205+func (e *chartEditor) editTitle() {206+ m := e.m147207 m.closeOverlay()148- m.openText("Chart title:", title, func(m *Model, text string) tea.Cmd {149- e.set(m, func(c *sheet.Chart) { c.Title = text })150- e.reopen(m)151- return nil152- })153- m.prompt.onCancel = e.reopen208+ m.askText("Chart title:", e.chart().Title, func(text string) {209+ e.set(func(c *sheet.Chart) { c.Title = text })210+ e.reopen()211+ }, e.reopen)154212 }155213 156214 // editorPart is a piece of the editor bar: a type chip or a toggle.
@@ -161,9 +219,9 @@ type editorPart struct {
161219 162220 // chip is a part drawn as a toggle, highlighted when on.163221 func (e *chartEditor) chip(on bool, name, key string) editorPart {164- style := e.m.th.Muted222+ style := e.m.styles().Muted165223 if on {166- style = e.m.th.MenuSelected224+ style = e.m.styles().MenuSelected167225 }168226 return editorPart{text: style.Render(" " + name + " "), key: key}169227 }
@@ -171,11 +229,11 @@ func (e *chartEditor) chip(on bool, name, key string) editorPart {
171229 // parts returns the bar's parts, and how many of them lead it: the172230 // types, which are never dropped and have a wider gap after them. Compact,173231 // the types are one chip of the current type, which a click moves on.174-func (e *chartEditor) parts(m *Model, compact bool) ([]editorPart, int) {232+func (e *chartEditor) parts(compact bool) ([]editorPart, int) {175233 if e.axes {176- return e.axisParts(m), 0234+ return e.axisParts(), 0177235 }178- c := e.chart(m)236+ c := e.chart()179237 var parts []editorPart180238 for i, t := range sheet.ChartTypes {181239 switch {
@@ -187,7 +245,7 @@ func (e *chartEditor) parts(m *Model, compact bool) ([]editorPart, int) {
187245 }188246 }189247 lead := len(parts)190- parts = append(parts, editorPart{text: m.th.Key.Render(c.Data.String()), key: editorRange})248+ parts = append(parts, editorPart{text: e.m.styles().Key.Render(c.Data.String()), key: editorRange})191249 switch {192250 case c.Type.Stackable():193251 parts = append(parts, e.chip(c.Stack != sheet.StackNone, c.Stack.Title(), editorStack))
@@ -212,10 +270,10 @@ func (e *chartEditor) parts(m *Model, compact bool) ([]editorPart, int) {
212270 // x. On narrow screens the types become one chip when that leaves room213271 // for more options, and options that still don't fit are dropped from the214272 // right.215-func (e *chartEditor) spans(m *Model) ([]editorPart, []int) {216- parts, xs := e.fit(m, false)217- if all, _ := e.parts(m, false); len(parts) < len(all) && !e.axes {218- if cp, cxs := e.fit(m, true); len(cp)+len(sheet.ChartTypes)-1 > len(parts) {273+func (e *chartEditor) spans() ([]editorPart, []int) {274+ parts, xs := e.fit(false)275+ if all, _ := e.parts(false); len(parts) < len(all) && !e.axes {276+ if cp, cxs := e.fit(true); len(cp)+len(sheet.ChartTypes)-1 > len(parts) {219277 return cp, cxs220278 }221279 }
@@ -223,8 +281,9 @@ func (e *chartEditor) spans(m *Model) ([]editorPart, []int) {
223281 }224282 225283 // fit lays out the parts, dropping options from the right until they fit.226-func (e *chartEditor) fit(m *Model, compact bool) ([]editorPart, []int) {227- parts, lead := e.parts(m, compact)284+func (e *chartEditor) fit(compact bool) ([]editorPart, []int) {285+ width, _ := e.m.size()286+ parts, lead := e.parts(compact)228287 for {229288 xs := make([]int, len(parts))230289 x := 0
@@ -235,7 +294,7 @@ func (e *chartEditor) fit(m *Model, compact bool) ([]editorPart, []int) {
235294 xs[i] = x236295 x += ansi.StringWidth(p.text) + 1237296 }238- if x-1 <= m.width || len(parts) <= max(lead, 1) {297+ if x-1 <= width || len(parts) <= max(lead, 1) {239298 return parts, xs240299 }241300 parts = parts[:len(parts)-1]
@@ -243,11 +302,10 @@ func (e *chartEditor) fit(m *Model, compact bool) ([]editorPart, []int) {
243302 }244303 245304 func (e *chartEditor) ContextLine() (string, string) {246- m := e.m247- if e.i >= len(m.sheet.Charts()) {305+ if e.i >= len(e.m.sheetShown().Charts()) {248306 return "", ""249307 }250- parts, xs := e.spans(m)308+ parts, xs := e.spans()251309 var b strings.Builder252310 for i, p := range parts {253311 b.WriteString(strings.Repeat(" ", xs[i]-ansi.StringWidth(b.String())))
@@ -257,16 +315,15 @@ func (e *chartEditor) ContextLine() (string, string) {
257315 }258316 259317 func (e *chartEditor) Mouse(ev overlay.MouseEvent) tea.Cmd {260- m := e.m261318 if ev.Kind != overlay.MousePress {262319 return nil263320 }264321 if ev.Y != contextLine {265322 // A click elsewhere keeps the changes and acts as usual.266- m.closeOverlay()267- return m.handlePress(tea.Mouse{X: ev.X, Y: ev.Y, Button: ev.Button})323+ e.keep()324+ return e.m.passMouse(ev)268325 }269- parts, xs := e.spans(m)326+ parts, xs := e.spans()270327 for i, p := range parts {271328 if ev.X >= xs[i] && ev.X < xs[i]+ansi.StringWidth(p.text) {272329 return e.Key(tea.KeyPressMsg{Code: rune(p.key[0]), Text: p.key})
@@ -276,25 +333,25 @@ func (e *chartEditor) Mouse(ev overlay.MouseEvent) tea.Cmd {
276333 }277334 278335 func (e *chartEditor) Status() (string, string) {279- m := e.m280336 if e.axes {281- return "", e.axisStatus(m)337+ return "", e.axisStatus()282338 }283339 pairs := []string{"←/→", "type", "A", "axes and legend", "S", "switch rows/columns", "H", "header", "L", "labels", "R", "range", "T", "title", "Enter", "done", "Esc", "cancel"}284- if c := e.chart(m); c.Type.Stackable() {340+ if c := e.chart(); c.Type.Stackable() {285341 pairs = append(pairs[:4], append([]string{"K", "stacking"}, pairs[4:]...)...)286342 } else if c.Type == sheet.ChartScatter {287343 pairs = append(pairs[:4], append([]string{"E", "trend line"}, pairs[4:]...)...)288344 }289- return "", fitHints(m, pairs)345+ width, _ := e.m.size()346+ return "", fitHints(e.m.styles(), width, pairs)290347 }291348 292349 // fitHints renders key hints, leaving out the pairs before the last two293-// (Enter and Esc) from the right until they fit.294-func fitHints(m *Model, pairs []string) string {350+// (Enter and Esc) from the right until they fit in width.351+func fitHints(th *theme.Theme, width int, pairs []string) string {295352 for {296- keys := m.th.KeyHints(pairs...)297- if ansi.StringWidth(keys) <= m.width || len(pairs) <= 4 {353+ keys := th.KeyHints(pairs...)354+ if ansi.StringWidth(keys) <= width || len(pairs) <= 4 {298355 return keys299356 }300357 pairs = append(pairs[:len(pairs)-6], pairs[len(pairs)-4:]...)
A internal/ui/chartmacro.go +245 -0
@@ -0,0 +1,245 @@
1+package ui2+3+import (4+ "bytes"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "slices"9+ "strconv"10+11+ tea "charm.land/bubbletea/v2"12+13+ "github.com/FelineStateMachine/012/internal/macro"14+ "github.com/FelineStateMachine/012/internal/sheet"15+)16+17+// Charts in macros. A chart inserted is recorded as18+// run("insert.chart", answer={...}) with the whole chart as a line of the19+// file writes it; a chart edited, moved or resized as20+// run("chart.edit", answer={"chart": 2, "at": "F3"}), its number21+// (counting from 1) and the fields that changed, a field left out of the22+// file's line given as its default (False, "" or None). Scripts answer23+// both commands the same way, and a replay changes only the fields24+// named. Moving a chart a step at a time records one call.25+26+// jsonField is a field of a JSON object, in the order written.27+type jsonField struct {28+ key string29+ value json.RawMessage30+}31+32+// jsonFields reads a JSON object's fields in order.33+func jsonFields(text string) ([]jsonField, error) {34+ d := json.NewDecoder(bytes.NewReader([]byte(text)))35+ if t, err := d.Token(); err != nil || t != json.Delim('{') {36+ return nil, errors.New("the answer isn't an object: give a dict")37+ }38+ var out []jsonField39+ for d.More() {40+ k, err := d.Token()41+ if err != nil {42+ return nil, err43+ }44+ var v json.RawMessage45+ if err := d.Decode(&v); err != nil {46+ return nil, err47+ }48+ out = append(out, jsonField{k.(string), v})49+ }50+ return out, nil51+}52+53+// jsonObject writes fields as a JSON object.54+func jsonObject(fields []jsonField) string {55+ var b bytes.Buffer56+ b.WriteByte('{')57+ for i, f := range fields {58+ if i > 0 {59+ b.WriteByte(',')60+ }61+ k, _ := json.Marshal(f.key)62+ b.Write(k)63+ b.WriteByte(':')64+ b.Write(f.value)65+ }66+ b.WriteByte('}')67+ return b.String()68+}69+70+func fieldIndex(fields []jsonField, key string) int {71+ return slices.IndexFunc(fields, func(f jsonField) bool { return f.key == key })72+}73+74+// chartChanges are the fields of after's line that differ from before's.75+// A field before's line has and after's leaves out, at its default, is76+// given as that default: the other boolean, "", or null.77+func chartChanges(before, after sheet.Chart) []jsonField {78+ bf, _ := jsonFields(before.JSON())79+ af, _ := jsonFields(after.JSON())80+ var out []jsonField81+ for _, f := range af {82+ if i := fieldIndex(bf, f.key); i < 0 || !bytes.Equal(bf[i].value, f.value) {83+ out = append(out, f)84+ }85+ }86+ for _, f := range bf {87+ if fieldIndex(af, f.key) >= 0 {88+ continue89+ }90+ def := json.RawMessage("null")91+ switch f.value[0] {92+ case 't':93+ def = json.RawMessage("false")94+ case 'f':95+ def = json.RawMessage("true")96+ case '"':97+ def = json.RawMessage(`""`)98+ }99+ out = append(out, jsonField{f.key, def})100+ }101+ return out102+}103+104+// chartWith is c with the fields of a macro's answer: null leaves a105+// field at its default; "chart" says which chart, and isn't a field.106+func chartWith(c sheet.Chart, answer []jsonField) (sheet.Chart, error) {107+ fields, _ := jsonFields(c.JSON())108+ for _, f := range answer {109+ i := fieldIndex(fields, f.key)110+ switch {111+ case f.key == "chart":112+ case string(f.value) == "null" && i >= 0:113+ fields = slices.Delete(fields, i, i+1)114+ case string(f.value) == "null":115+ case i >= 0:116+ fields[i].value = f.value117+ default:118+ fields = append(fields, f)119+ }120+ }121+ return sheet.ParseChart(jsonObject(fields))122+}123+124+// recordChart records chart i inserted or changed from before to after,125+// as the command id that does it answered with the chart. A chart placed126+// (moved or resized while selected, not by a command) comes after the127+// selection it follows.128+func (m *Model) recordChart(id string, i int, before, after sheet.Chart, placed bool) {129+ r := m.rec130+ if r == nil || r.depth > 0 {131+ return132+ }133+ if placed {134+ r.flush(m)135+ }136+ if id == "insert.chart" {137+ m.recordDialog(id, after.JSON())138+ return139+ }140+ changes := chartChanges(before, after)141+ if len(changes) == 0 {142+ return143+ }144+ n := json.RawMessage(strconv.Itoa(i + 1))145+ if last, ok := r.lastPlacement(i + 1); ok && placed && placement(changes) {146+ for _, f := range changes {147+ if k := fieldIndex(last, f.key); k >= 0 {148+ last[k].value = f.value149+ } else {150+ last = append(last, f)151+ }152+ }153+ r.actions[len(r.actions)-1] = macro.Call("run", id).With("answer", macro.JSON(jsonObject(last)))154+ r.acted = true155+ return156+ }157+ m.recordDialog(id, jsonObject(append([]jsonField{{"chart", n}}, changes...)))158+}159+160+// placement reports whether fields only place a chart: where it is and161+// its size.162+func placement(fields []jsonField) bool {163+ for _, f := range fields {164+ switch f.key {165+ case "chart", "at", "width", "height":166+ default:167+ return false168+ }169+ }170+ return true171+}172+173+// lastPlacement is the answer of the last action recorded when it placed174+// chart n, so that moving a chart step by step records one call.175+func (r *recorder) lastPlacement(n int) ([]jsonField, bool) {176+ if len(r.actions) == 0 {177+ return nil, false178+ }179+ a := r.actions[len(r.actions)-1]180+ if a.Func != "run" || len(a.Args) != 1 || a.Args[0] != "chart.edit" || len(a.Named) != 1 {181+ return nil, false182+ }183+ text, ok := a.Named[0].Value.(macro.JSON)184+ if !ok {185+ return nil, false186+ }187+ fields, err := jsonFields(string(text))188+ if err != nil || !placement(fields) {189+ return nil, false190+ }191+ if i := fieldIndex(fields, "chart"); i < 0 || string(fields[i].value) != strconv.Itoa(n) {192+ return nil, false193+ }194+ return fields, true195+}196+197+// answerInsertChart inserts a chart as a script answers Insert > Chart:198+// the chart the selection would make, with the answer's fields.199+func (m *Model) answerInsertChart(text string) (tea.Cmd, error) {200+ fields, err := jsonFields(text)201+ if err != nil {202+ return nil, err203+ }204+ m.insertChart()205+ e, ok := m.overlay.(*chartEditor)206+ if !ok {207+ return nil, errors.New(m.note)208+ }209+ return nil, e.answer(fields)210+}211+212+// answerEditChart changes a chart as a script answers Edit chart: the213+// chart numbered "chart", or the one the command would edit, with the214+// answer's fields.215+func (m *Model) answerEditChart(text string) (tea.Cmd, error) {216+ fields, err := jsonFields(text)217+ if err != nil {218+ return nil, err219+ }220+ charts := m.sheet.Charts()221+ i := m.targetChart()222+ if k := fieldIndex(fields, "chart"); k >= 0 {223+ n, err := strconv.Atoi(string(fields[k].value))224+ if err != nil || n < 1 || n > len(charts) {225+ return nil, fmt.Errorf("there's no chart %s on %s: it has %d", fields[k].value, m.sheet.Name(), len(charts))226+ }227+ i = n - 1228+ }229+ if i < 0 {230+ return nil, fmt.Errorf("there's no chart on %s", m.sheet.Name())231+ }232+ return nil, m.openChartEditor(i, false, m.sheet.StateID()).answer(fields)233+}234+235+// answer makes the chart the answer's fields say and closes the editor.236+func (e *chartEditor) answer(fields []jsonField) error {237+ c, err := chartWith(e.chart(), fields)238+ if err != nil {239+ e.m.closeOverlay()240+ return err241+ }242+ e.set(func(x *sheet.Chart) { *x = c })243+ e.m.closeOverlay()244+ return nil245+}
M internal/ui/charts.go +31 -8
@@ -31,12 +31,14 @@ const newChartW, newChartH = 48, 16
3131 3232 func init() {3333 register(34- &command{id: "insert.chart", title: "Chart", desc: "Chart the selected data, or the table around the active cell", run: (*Model).insertChart},34+ &command{id: "insert.chart", title: "Chart", desc: "Chart the selected data, or the table around the active cell", run: (*Model).insertChart,35+ answer: (*Model).answerInsertChart},3536 &command{id: "chart.edit", title: "Edit chart", desc: "Change a chart's type, data and labels",3637 enabled: hasCharts, run: func(m *Model) tea.Cmd {3738 m.openChartEditor(m.targetChart(), false, m.sheet.StateID())3839 return nil39- }},40+ },41+ answer: (*Model).answerEditChart},4042 &command{id: "chart.delete", title: "Delete chart", desc: "Remove a chart from the sheet",4143 enabled: hasCharts, run: func(m *Model) tea.Cmd {4244 m.deleteChart(m.targetChart())
@@ -333,23 +335,44 @@ func (m *Model) chartClick(mouse tea.Mouse) (tea.Cmd, bool) {
333335 if i < 0 {334336 return nil, false335337 }336- m.selectChart(i)337- s := m.overlay.(*chartSel)338+ s := m.selectChart(i)338339 if mouse.Button == tea.MouseRight {339- m.showContextMenu(chartMenu, mouse.X, mouse.Y+1)340+ m.showChartMenu(mouse.X, mouse.Y+1)340341 return nil, true341342 }342- s.press(m, mouse.X, mouse.Y)343+ s.press(mouse.X, mouse.Y)343344 return nil, true344345 }345346 346347 var chartMenu = []menuItem{{cmd: "chart.edit"}, {cmd: "chart.delete"}}347348 348349 // selectChart selects chart i.349-func (m *Model) selectChart(i int) {350+func (m *Model) selectChart(i int) *chartSel {350351 m.clearSelection()351352 m.charts.last = i352- m.openOverlay(&chartSel{m: m, i: i})353+ s := &chartSel{m: m, i: i}354+ m.openOverlay(s)355+ return s356+}357+358+func (m *Model) forgetChart() { m.charts.last = -1 }359+func (m *Model) showChartMenu(x, y int) { m.showContextMenu(chartMenu, x, y) }360+func (m *Model) say(msg string) { m.note = msg }361+func (m *Model) passKey(k tea.KeyPressMsg) tea.Cmd {362+ return m.handleKey(k)363+}364+365+// passMouse hands a press or the wheel to the grid, as if no chart were366+// selected.367+func (m *Model) passMouse(e overlay.MouseEvent) tea.Cmd {368+ mouse := tea.Mouse{X: e.X, Y: e.Y, Button: e.Button}369+ switch e.Kind {370+ case overlay.MousePress:371+ return m.handlePress(mouse)372+ case overlay.MouseWheel:373+ m.handleWheel(mouse)374+ }375+ return nil353376 }354377 355378 // setShape sets the terminal's pointer shape, if it changed.
M internal/ui/chartsel.go +62 -46
@@ -13,7 +13,7 @@ import (
1313 // shows it across the grid (keyboard.go); the mouse drags it or its1414 // corner. Other keys deselect it and act as usual.1515 type chartSel struct {16- m *Model // the model it acts on16+ m chartHost // the model, through what a selected chart needs of it1717 i int1818 drag int // chartDragNone, chartDragMove or chartDragResize1919 grabX int // where the chart was grabbed, relative to its corner
@@ -28,17 +28,25 @@ func (s *chartSel) Layout() []overlay.Box {
2828 return nil // drawn with the other charts, see chartBoxes2929 }3030 31-func (s *chartSel) chart(m *Model) (sheet.Chart, bool) {32- charts := m.sheet.Charts()31+func (s *chartSel) chart() (sheet.Chart, bool) {32+ charts := s.m.sheetShown().Charts()3333 if s.i < 0 || s.i >= len(charts) {3434 return sheet.Chart{}, false3535 }3636 return charts[s.i], true3737 }3838 39+// shown is the chart as drawn: where it's being dragged, if it is.40+func (s *chartSel) shown() (sheet.Chart, bool) {41+ if s.preview != nil {42+ return *s.preview, true43+ }44+ return s.chart()45+}46+3947 func (s *chartSel) Key(k tea.KeyPressMsg) tea.Cmd {4048 m := s.m41- c, ok := s.chart(m)49+ c, ok := s.chart()4250 if !ok {4351 m.closeOverlay()4452 return nil
@@ -56,7 +64,7 @@ func (s *chartSel) Key(k tea.KeyPressMsg) tea.Cmd {
5664 case "delete", "backspace":5765 return m.runCommand("chart.delete")5866 case "tab":59- m.selectChart((s.i + 1) % len(m.sheet.Charts()))67+ m.selectChart((s.i + 1) % len(m.sheetShown().Charts()))6068 return nil6169 case "up":6270 moved.At.Row--
@@ -76,25 +84,35 @@ func (s *chartSel) Key(k tea.KeyPressMsg) tea.Cmd {
7684 moved.W += 27785 default:7886 m.closeOverlay()79- return m.handleKey(k)87+ return m.passKey(k)8088 }8189 label := "move chart"8290 if moved.W != c.W || moved.H != c.H {8391 label = "resize chart"8492 }85- m.sheet.SetChart(s.i, moved, label)86- m.changed = true93+ s.place(c, moved, label)8794 m.showChart(s.i)8895 return nil8996 }9097 98+// place puts the chart at to, from where it was, as one undo step, and99+// records it.100+func (s *chartSel) place(from, to sheet.Chart, label string) {101+ sh := s.m.sheetShown()102+ sh.SetChart(s.i, to, label)103+ s.m.syncChanged()104+ if now, ok := s.chart(); ok {105+ s.m.recordChart("chart.edit", s.i, from, now, true)106+ }107+}108+91109 // press starts a drag at x, y: the corner resizes, anywhere else moves.92-func (s *chartSel) press(m *Model, x, y int) {93- c, ok := s.chart(m)110+func (s *chartSel) press(x, y int) {111+ c, ok := s.chart()94112 if !ok {95113 return96114 }97- cx, cy := m.chartScreen(c)115+ cx, cy := s.m.chartScreen(c)98116 s.grabX, s.grabY = x-cx, y-cy99117 s.drag = chartDragMove100118 if x >= cx+c.W-2 && y == cy+c.H-1 {
@@ -104,49 +122,49 @@ func (s *chartSel) press(m *Model, x, y int) {
104122 }105123 106124 func (s *chartSel) Mouse(e overlay.MouseEvent) tea.Cmd {107- m := s.m108125 if e.Kind != overlay.MouseMotion {109126 s.unzoom()110127 }111128 switch e.Kind {112129 case overlay.MousePress:113- return s.pressAt(m, e)130+ return s.pressAt(e)114131 case overlay.MouseMotion:115- return s.motion(m, e)132+ return s.motion(e)116133 case overlay.MouseRelease:117- s.release(m)134+ s.release()118135 case overlay.MouseWheel:119- m.handleWheel(tea.Mouse{X: e.X, Y: e.Y, Button: e.Button})136+ return s.m.passMouse(e)120137 }121138 return nil122139 }123140 124141 // pressAt selects the chart pressed and starts dragging it, or opens its125142 // menu; pressing off the charts deselects and acts as usual.126-func (s *chartSel) pressAt(m *Model, e overlay.MouseEvent) tea.Cmd {143+func (s *chartSel) pressAt(e overlay.MouseEvent) tea.Cmd {144+ m := s.m127145 i := m.chartAt(e.X, e.Y)128146 switch {129147 case i < 0:130148 m.closeOverlay()131- return m.handlePress(tea.Mouse{X: e.X, Y: e.Y, Button: e.Button})149+ return m.passMouse(e)132150 case e.Button == tea.MouseRight:133151 m.selectChart(i)134- m.showContextMenu(chartMenu, e.X, e.Y+1)152+ m.showChartMenu(e.X, e.Y+1)135153 case e.Button == tea.MouseLeft:136154 if i != s.i {137- m.selectChart(i)138- s = m.overlay.(*chartSel)155+ s = m.selectChart(i)139156 }140- s.press(m, e.X, e.Y)157+ s.press(e.X, e.Y)141158 }142159 return nil143160 }144161 145162 // motion moves or resizes the chart being dragged, or else sets the146163 // pointer shape for what's under the mouse.147-func (s *chartSel) motion(m *Model, e overlay.MouseEvent) tea.Cmd {164+func (s *chartSel) motion(e overlay.MouseEvent) tea.Cmd {165+ m := s.m148166 if s.drag == chartDragNone || s.preview == nil {149- return m.setShape(s.hoverShape(m, e.X, e.Y))167+ return m.setShape(s.hoverShape(e.X, e.Y))150168 }151169 p := *s.preview152170 if s.drag == chartDragResize {
@@ -162,13 +180,13 @@ func (s *chartSel) motion(m *Model, e overlay.MouseEvent) tea.Cmd {
162180 163181 // hoverShape is the pointer shape at x, y: a move cursor over charts,164182 // and a resize one over the selected chart's corner.165-func (s *chartSel) hoverShape(m *Model, x, y int) string {166- i := m.chartAt(x, y)183+func (s *chartSel) hoverShape(x, y int) string {184+ i := s.m.chartAt(x, y)167185 if i < 0 {168186 return "default"169187 }170- if c := m.displayCharts()[i]; i == s.i {171- cx, cy := m.chartScreen(c)188+ if c, ok := s.shown(); ok && i == s.i {189+ cx, cy := s.m.chartScreen(c)172190 if x >= cx+c.W-2 && y == cy+c.H-1 {173191 return "nwse-resize"174192 }
@@ -177,17 +195,14 @@ func (s *chartSel) hoverShape(m *Model, x, y int) string {
177195 }178196 179197 // release drops the chart being dragged where it is, as one undo step.180-func (s *chartSel) release(m *Model) {181- if s.preview != nil && s.drag != chartDragNone {198+func (s *chartSel) release() {199+ c, ok := s.chart()200+ if ok && s.preview != nil && s.drag != chartDragNone && *s.preview != c {182201 label := "move chart"183202 if s.drag == chartDragResize {184203 label = "resize chart"185204 }186- before := m.sheet.StateID()187- m.sheet.SetChart(s.i, *s.preview, label)188- if m.sheet.StateID() != before {189- m.changed = true190- }205+ s.place(c, *s.preview, label)191206 }192207 s.drag, s.preview = chartDragNone, nil193208 }
@@ -213,20 +228,21 @@ func (g *grid) chartCellAt(x, y int) sheet.Addr {
213228 }214229 215230 func (s *chartSel) Status() (string, string) {216- m := s.m217- c, ok := s.chart(m)231+ th := s.m.styles()232+ width, _ := s.m.size()233+ c, ok := s.chart()218234 if !ok {219235 return "", ""220236 }221- desc := m.th.Muted.Render("Chart of " + c.Data.String())237+ desc := th.Muted.Render("Chart of " + c.Data.String())222238 if s.zoom {223- return desc, m.th.KeyHints("Space", "let go to go back")239+ return desc, th.KeyHints("Space", "let go to go back")224240 }225241 pairs := []string{"Enter", "edit", "Del", "delete", "Arrows", "move", "Shift+arrows", "resize", "Esc", "done"}226242 for {227- keys := m.th.KeyHints(pairs...)243+ keys := th.KeyHints(pairs...)228244 switch {229- case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:245+ case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= width:230246 return desc, keys231247 case desc != "":232248 desc = ""
@@ -240,17 +256,17 @@ func (s *chartSel) Status() (string, string) {
240256 241257 // ContextLine says what's selected and how to change it.242258 func (s *chartSel) ContextLine() (string, string) {243- m := s.m244- c, ok := s.chart(m)259+ th := s.m.styles()260+ c, ok := s.chart()245261 if !ok {246262 return "", ""247263 }248264 how := " drag to move, drag the corner to resize"249- if m.session.releases { // Space held enlarges the chart: keyboard.go265+ if s.m.holdsKeys() { // Space held enlarges the chart: keyboard.go250266 how += ", hold Space to zoom"251267 }252- return m.th.Key.Render(c.Type.Title()+" chart") + m.th.Muted.Render(" of ") + c.Data.String() +253- m.th.Muted.Render(how), ""268+ return th.Key.Render(c.Type.Title()+" chart") + th.Muted.Render(" of ") + c.Data.String() +269+ th.Muted.Render(how), ""254270 }255271 256272 const (
A internal/ui/choicebar/choicebar.go +124 -0
@@ -0,0 +1,124 @@
1+// Package choicebar is a small question asked on the context line and2+// answered with a key, the way terminal programs confirm things, e.g.3+// "You have unsaved changes. Enter Save and quit D Discard Esc Cancel".4+// It takes the keyboard like an overlay but draws no box, and knows the5+// UI only through Host.6+package choicebar7+8+import (9+ "strings"10+11+ tea "charm.land/bubbletea/v2"12+ "github.com/charmbracelet/x/ansi"13+14+ "github.com/FelineStateMachine/012/internal/ui/overlay"15+ "github.com/FelineStateMachine/012/internal/ui/theme"16+)17+18+// Host is what a choice bar needs of the UI.19+type Host interface {20+ Theme() *theme.Theme21+ // Close closes the open overlay.22+ Close()23+ // RecordAnswer tells a macro recording the key chosen, for the24+ // command that asked; Esc cancels it.25+ RecordAnswer(answer string, cancelled bool)26+}27+28+// Choice is one answer: a key as tea reports it ("enter", "d"), its29+// label, and what choosing it does, after the bar has closed.30+type Choice struct {31+ Key string32+ Label string33+ Run func() tea.Cmd34+}35+36+// Bar is the question and its choices.37+type Bar struct {38+ h Host39+ Msg string40+ Warn bool // the message is a warning, e.g. about losing work41+ Desc string // more on the status line, when the question needs it42+ Choices []Choice43+}44+45+// New returns a bar asking msg.46+func New(h Host, msg string, warn bool, desc string, choices []Choice) *Bar {47+ return &Bar{h: h, Msg: msg, Warn: warn, Desc: desc, Choices: choices}48+}49+50+func (b *Bar) Indicator() string { return "MENU" }51+func (b *Bar) Layout() []overlay.Box { return nil }52+func (b *Bar) Status() (string, string) { return b.Desc, "" }53+54+func (b *Bar) Key(k tea.KeyPressMsg) tea.Cmd {55+ key := strings.ToLower(k.String())56+ for i, ch := range b.Choices {57+ if key == ch.Key {58+ return b.Choose(i)59+ }60+ }61+ return nil62+}63+64+// Find is the index of the choice with key or label answer, ignoring65+// case, or -1.66+func (b *Bar) Find(answer string) int {67+ for i, ch := range b.Choices {68+ if strings.EqualFold(ch.Key, answer) || strings.EqualFold(ch.Label, answer) {69+ return i70+ }71+ }72+ return -173+}74+75+// Choose closes the bar and runs choice i.76+func (b *Bar) Choose(i int) tea.Cmd {77+ ch := b.Choices[i]78+ b.h.Close()79+ cmd := ch.Run()80+ b.h.RecordAnswer(ch.Key, ch.Key == "esc")81+ return cmd82+}83+84+// Mouse runs a choice when its key chip or label is clicked. A click85+// anywhere else cancels, like Esc.86+func (b *Bar) Mouse(e overlay.MouseEvent) tea.Cmd {87+ if e.Kind != overlay.MousePress {88+ return nil89+ }90+ if e.Y == overlay.ContextLine {91+ x := ansi.StringWidth(b.prefix())92+ for i, ch := range b.Choices {93+ w := ansi.StringWidth(b.item(ch))94+ if e.X >= x && e.X < x+w {95+ return b.Choose(i)96+ }97+ x += w + len(gap)98+ }99+ }100+ b.h.Close()101+ return nil102+}103+104+const gap = " "105+106+func (b *Bar) prefix() string {107+ if b.Warn {108+ return b.h.Theme().Warning.Render(b.Msg) + gap109+ }110+ return b.Msg + gap111+}112+113+func (b *Bar) item(ch Choice) string {114+ return b.h.Theme().Chip(theme.KeyLabel(ch.Key)) + " " + ch.Label115+}116+117+// ContextLine renders the bar for the context line.118+func (b *Bar) ContextLine() (string, string) {119+ items := make([]string, len(b.Choices))120+ for i, ch := range b.Choices {121+ items[i] = b.item(ch)122+ }123+ return b.prefix() + strings.Join(items, gap), ""124+}
A internal/ui/choicebar/choicebar_test.go +90 -0
@@ -0,0 +1,90 @@
1+package choicebar2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/ui/overlay"11+ "github.com/FelineStateMachine/012/internal/ui/theme"12+)13+14+// fakeHost records what the bar did.15+type fakeHost struct {16+ th theme.Theme17+ closed int18+ answers []string19+}20+21+func (f *fakeHost) Theme() *theme.Theme { return &f.th }22+func (f *fakeHost) Close() { f.closed++ }23+24+func (f *fakeHost) RecordAnswer(answer string, cancelled bool) {25+ if cancelled {26+ answer = "(cancelled)"27+ }28+ f.answers = append(f.answers, answer)29+}30+31+// newBar asks about unsaved changes, noting the choice made in *ran.32+func newBar(ran *string) (*Bar, *fakeHost) {33+ h := &fakeHost{th: theme.New(true)}34+ run := func(what string) func() tea.Cmd { return func() tea.Cmd { *ran = what; return nil } }35+ return New(h, "You have unsaved changes.", true, "More", []Choice{36+ {Key: "enter", Label: "Save and quit", Run: run("save")},37+ {Key: "d", Label: "Discard", Run: run("discard")},38+ {Key: "esc", Label: "Cancel", Run: run("cancel")},39+ }), h40+}41+42+func TestKeysChoose(t *testing.T) {43+ var ran string44+ b, h := newBar(&ran)45+ b.Key(tea.KeyPressMsg{Code: 'x', Text: "x"})46+ if ran != "" || h.closed != 0 {47+ t.Fatalf("a key of no choice acted: %q, closed %d", ran, h.closed)48+ }49+ b.Key(tea.KeyPressMsg{Code: 'D', Text: "D", Mod: tea.ModShift})50+ if ran != "discard" || h.closed != 1 || strings.Join(h.answers, ",") != "d" {51+ t.Fatalf("D: ran %q, closed %d, answers %v", ran, h.closed, h.answers)52+ }53+ b.Key(tea.KeyPressMsg{Code: tea.KeyEscape})54+ if ran != "cancel" || h.answers[1] != "(cancelled)" {55+ t.Fatalf("Esc: ran %q, answers %v", ran, h.answers)56+ }57+}58+59+func TestFindByKeyOrLabel(t *testing.T) {60+ var ran string61+ b, _ := newBar(&ran)62+ for answer, want := range map[string]int{"enter": 0, "DISCARD": 1, "Esc": 2, "nope": -1} {63+ if got := b.Find(answer); got != want {64+ t.Errorf("Find(%q) = %d, want %d", answer, got, want)65+ }66+ }67+}68+69+func TestClickChoosesOrCancels(t *testing.T) {70+ var ran string71+ b, h := newBar(&ran)72+ line, _ := b.ContextLine()73+ plain := ansi.Strip(line)74+ if !strings.HasPrefix(plain, "You have unsaved changes. ") || !strings.Contains(plain, "Discard ") {75+ t.Fatalf("context line %q", plain)76+ }77+ x := strings.Index(plain, "Discard")78+ b.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, X: x, Y: overlay.ContextLine})79+ if ran != "discard" {80+ t.Fatalf("clicking Discard ran %q", ran)81+ }82+ ran = ""83+ b.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, X: 0, Y: overlay.ContextLine + 3})84+ if ran != "" || h.closed != 2 {85+ t.Fatalf("a click elsewhere should only close: ran %q, closed %d", ran, h.closed)86+ }87+ if desc, _ := b.Status(); desc != "More" {88+ t.Fatalf("status %q", desc)89+ }90+}
M internal/ui/columns.go +4 -4
M internal/ui/commands.go +6 -0
M internal/ui/data_test.go +7 -7
@@ -8,6 +8,7 @@ import (
88 "github.com/charmbracelet/x/ansi"99 1010 "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/filterpick"1112 )1213 1314 // handleX is the screen x of the fill handle in column c (scrolled to A).
@@ -165,8 +166,7 @@ func TestFilterPicker(t *testing.T) {
165166 t.Fatalf("filter %v %v", r, ok)166167 }167168 press(t, m, "<alt+down>")168- p, ok := m.overlay.(*filterPicker)169- if !ok {169+ if _, ok := m.overlay.(*filterpick.Picker); !ok {170170 t.Fatalf("overlay %T", m.overlay)171171 }172172 out := screen(m)
@@ -177,8 +177,8 @@ func TestFilterPicker(t *testing.T) {
177177 }178178 // Uncheck 0 and 1; the search narrows the list.179179 press(t, m, "<down>", "<space>", "<down>", "<space>", "3")180- if len(p.shown) != 1 || !strings.Contains(screen(m), "1 of 4") {181- t.Errorf("search shows %d", len(p.shown))180+ if !strings.Contains(screen(m), "1 of 4") {181+ t.Errorf("search shows:\n%s", screen(m))182182 }183183 press(t, m, "<enter>")184184 if m.sheet.HiddenRows() != 4 || m.note != "Filtered column B: 4 rows hidden" {
@@ -219,12 +219,12 @@ func TestFilterButtonClick(t *testing.T) {
219219 t.Fatalf("button at %d in %q", x, line(m, headerLine))220220 }221221 click(m, x, headerLine, 0)222- if p, ok := m.overlay.(*filterPicker); !ok || !strings.HasPrefix(p.title, "Filter B") {222+ if _, ok := m.overlay.(*filterpick.Picker); !ok || !strings.Contains(screen(m), "Filter B") {223223 t.Fatalf("overlay %T", m.overlay)224224 }225225 // Clicking a value toggles it; clicking outside cancels.226- click(m, x+4, gridTop+filterFirstRow+1, 0)227- if p := m.overlay.(*filterPicker); p.checked["0"] {226+ click(m, x+4, gridTop+5+1, 0) // the list starts five rows into the box227+ if !strings.Contains(screen(m), "[ ] 0") {228228 t.Error("click didn't uncheck 0")229229 }230230 click(m, 70, 18, 0)
M internal/ui/dialog.go +11 -75
@@ -1,21 +1,15 @@
11 package ui22 33 import (4- "strings"5-64 tea "charm.land/bubbletea/v2"7- "github.com/charmbracelet/x/ansi"85 9- "github.com/FelineStateMachine/012/internal/ui/overlay"10- "github.com/FelineStateMachine/012/internal/ui/theme"6+ "github.com/FelineStateMachine/012/internal/ui/choicebar"117 )128 13-// choiceBar is a small question asked on the context line and answered14-// with a key, the way terminal programs confirm things, e.g.9+// question is a choice bar (package choicebar) as the model asks it:10+// a small question on the context line answered with a key, e.g.1511 // "You have unsaved changes. Enter Save and quit D Discard Esc Cancel".16-// It takes the keyboard like an overlay but draws no box.17-type choiceBar struct {18- m *Model // the model it acts on12+type question struct {1913 msg string2014 warn bool // the message is a warning, e.g. about losing work2115 desc string // more on the status line, when the question needs it
@@ -28,70 +22,13 @@ type choice struct {
2822 run func(m *Model) tea.Cmd2923 }3024 31-func (c *choiceBar) Indicator() string { return "MENU" }32-func (c *choiceBar) Layout() []overlay.Box { return nil }33-func (c *choiceBar) Status() (string, string) { return c.desc, "" }34-35-func (c *choiceBar) Key(k tea.KeyPressMsg) tea.Cmd {36- m := c.m37- key := strings.ToLower(k.String())38- for _, ch := range c.choices {39- if key == ch.key {40- return c.choose(m, ch)41- }42- }43- return nil44-}45-46-func (c *choiceBar) choose(m *Model, ch choice) tea.Cmd {47- m.closeOverlay()48- cmd := ch.run(m)49- m.recordAnswer(ch.key, ch.key == "esc")50- return cmd51-}52-53-// Mouse runs a choice when its key chip or label is clicked. A click54-// anywhere else cancels, like Esc.55-func (c *choiceBar) Mouse(e overlay.MouseEvent) tea.Cmd {56- m := c.m57- if e.Kind != overlay.MousePress {58- return nil59- }60- if e.Y == contextLine {61- x := ansi.StringWidth(c.prefix(m))62- for _, ch := range c.choices {63- w := ansi.StringWidth(c.item(m, ch))64- if e.X >= x && e.X < x+w {65- return c.choose(m, ch)66- }67- x += w + len(choiceGap)68- }69- }70- m.closeOverlay()71- return nil72-}73-74-const choiceGap = " "75-76-func (c *choiceBar) prefix(m *Model) string {77- if c.warn {78- return m.th.Warning.Render(c.msg) + choiceGap79- }80- return c.msg + choiceGap81-}82-83-func (c *choiceBar) item(m *Model, ch choice) string {84- return m.th.Chip(theme.KeyLabel(ch.key)) + " " + ch.label85-}86-87-// ContextLine renders the bar for the context line.88-func (c *choiceBar) ContextLine() (string, string) {89- m := c.m90- items := make([]string, len(c.choices))91- for i, ch := range c.choices {92- items[i] = c.item(m, ch)25+// ask opens a choice bar with q's question.26+func (m *Model) ask(q question) {27+ chs := make([]choicebar.Choice, len(q.choices))28+ for i, ch := range q.choices {29+ chs[i] = choicebar.Choice{Key: ch.key, Label: ch.label, Run: func() tea.Cmd { return ch.run(m) }}9330 }94- return c.prefix(m) + strings.Join(items, choiceGap), ""31+ m.openOverlay(choicebar.New(m.host(), q.msg, q.warn, q.desc, chs))9532 }9633 9734 // quit exits, asking first when there are unsaved changes. Enter saves
@@ -100,8 +37,7 @@ func (m *Model) quit() tea.Cmd {
10037 if !m.changed {10138 return m.exit()10239 }103- m.openOverlay(&choiceBar{104- m: m,40+ m.ask(question{10541 msg: "You have unsaved changes.",10642 warn: true,10743 choices: []choice{
M internal/ui/download.go +2 -3
M internal/ui/entry.go +7 -6
@@ -9,6 +9,7 @@ import (
99 "github.com/FelineStateMachine/012/internal/macro"1010 "github.com/FelineStateMachine/012/internal/sheet"1111 "github.com/FelineStateMachine/012/internal/ui/overlay"12+ "github.com/FelineStateMachine/012/internal/ui/suggest"1213 )1314 1415 // Entry follows Google Sheets: typing replaces the cell (ENTER), Enter or
@@ -30,7 +31,7 @@ type entry struct {
3031 // tabs.go.3132 home *sheet.Sheet3233 33- assist assist // function and name suggestions: assist.go34+ assist suggest.List // function and name suggestions: package suggest3435 3536 // tabStart remembers where a run of Tab-committed entries began, so3637 // Enter returns to that column on the next row, as in Sheets.
@@ -51,7 +52,7 @@ func (m *Model) startEntry(md mode, text string) {
5152 m.mode = md5253 m.entry.home = nil5354 m.line.Clear()54- m.entry.hint, m.entry.assist = "", assist{}55+ m.entry.hint, m.entry.assist = "", suggest.List{}5556 m.line.Insert(text)5657 }5758
@@ -68,7 +69,7 @@ func (m *Model) startEdit() tea.Cmd {
6869 // in a formula where a reference may follow the caret: then they point.6970 func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {7071 key := k.String()71- if m.entry.assist.key(m, key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {72+ if m.entry.assist.Key(m.host(), key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {7273 return nil7374 }7475 if key == "f2" {
@@ -99,7 +100,7 @@ func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {
99100 // editKey handles EDIT mode, where left and right move the caret.100101 func (m *Model) editKey(k tea.KeyPressMsg) tea.Cmd {101102 key := k.String()102- if m.entry.assist.key(m, key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {103+ if m.entry.assist.Key(m.host(), key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {103104 return nil104105 }105106 if m.line.IsFormula() && strings.HasPrefix(key, "shift+") && (m.pointAfterSheet(key) || m.canPoint() && m.startPoint(key)) {
@@ -213,7 +214,7 @@ func (m *Model) pointKey(k tea.KeyPressMsg) tea.Cmd {
213214 if text := typed(k); text != "" {214215 m.resumeEntry(m.pointRef())215216 m.line.Insert(text)216- m.entry.assist = assist{active: true}217+ m.entry.assist = suggest.List{Active: true}217218 }218219 return nil219220 }
@@ -260,7 +261,7 @@ func (m *Model) cancelEntry() {
260261 m.returnHome()261262 m.mode = modeReady262263 m.line.Clear()263- m.entry.hint, m.entry.assist = "", assist{}264+ m.entry.hint, m.entry.assist = "", suggest.List{}264265 }265266 266267 func (m *Model) handlePaste(content string) {
M internal/ui/file.go +1 -2
M internal/ui/filter.go +43 -330
@@ -1,17 +1,17 @@
11 package ui22 33 import (4+ "encoding/json"5+ "errors"6+ "fmt"47 "log/slog"58 "strconv"69 "strings"710 811 tea "charm.land/bubbletea/v2"9- "github.com/charmbracelet/x/ansi"10- "github.com/sahilm/fuzzy"1112 1213 "github.com/FelineStateMachine/012/internal/sheet"13- "github.com/FelineStateMachine/012/internal/ui/overlay"14- "github.com/FelineStateMachine/012/internal/ui/theme"14+ "github.com/FelineStateMachine/012/internal/ui/filterpick"1515 )1616 1717 // Filters follow Sheets' Data > Create a filter: the headers of the
@@ -31,6 +31,7 @@ func init() {
3131 m.openFilterPicker(m.cur.Col)3232 return nil3333 },34+ answer: (*Model).answerFilter,3435 enabled: func(m *Model) bool {3536 r, on := m.sheet.FilterRange()3637 return on && m.cur.Col >= r.From.Col && m.cur.Col <= r.To.Col
@@ -93,164 +94,69 @@ func (g *grid) filterButtonX(c int) int {
9394 return g.colStart(c) + (w-lw)/2 + lw - 19495 }9596 96-// filterPicker is the values list and condition for one column: of the97-// sheet's filter, or of a pivot table's (pivoteditor.go), which set what98-// applying and cancelling do.99-type filterPicker struct {100- m *Model // the model it acts on101- title string102- x int // where the box goes, e.g. over its column103- onApply func(m *Model, cr sheet.Criteria)104- // onCancel, when set, runs after Esc or a click outside closes the105- // picker.106- onCancel func(m *Model)107- values []sheet.FilterValue108- checked map[string]bool109- cond sheet.Condition110- field int // 0 is the search, 1 the condition's value111- fields [2]string // the text of each field112- shown []int // values matching the search, best first113- overlay.List // over the rows: "Select all", then shown114-}115-116-const filterID = "filter"117-118-// Rows of the box: the top border, the condition, a separator, the119-// search, a separator, then the list.120-const filterFirstRow = 5121-12297 // openFilterPicker opens the picker for column col of the filter.123-func (m *Model) openFilterPicker(col int) {98+func (m *Model) openFilterPicker(col int) *filterpick.Picker {12499 r, on := m.sheet.FilterRange()125100 if !on || col < r.From.Col || col > r.To.Col {126- return101+ return nil127102 }128103 title := "Filter " + sheet.ColName(col)129104 if h := m.sheet.LocalText(sheet.Addr{Col: col, Row: r.From.Row}); h != "" {130105 title += " " + h131106 }132- m.openValuesPicker(title, m.colStart(col), m.sheet.FilterValues(col), m.sheet.Filter().Cols[col].Cond,133- func(m *Model, cr sheet.Criteria) { m.filterColumn(col, cr) })107+ return m.openValuesPicker(title, m.colStart(col), m.sheet.FilterValues(col), m.sheet.Filter().Cols[col].Cond,108+ func(cr sheet.Criteria) {109+ m.filterColumn(col, cr)110+ m.recordDialog("data.filter_column", filterAnswer(col, cr))111+ })134112 }135113 136114 // openValuesPicker opens a filter picker titled title at screen column x137115 // over values, with cond as the condition, calling apply with the138116 // criteria chosen.139-func (m *Model) openValuesPicker(title string, x int, values []sheet.FilterValue, cond sheet.Condition, apply func(*Model, sheet.Criteria)) *filterPicker {140- p := &filterPicker{m: m, title: title, x: x, onApply: apply, values: values, checked: map[string]bool{}, cond: cond}141- for _, v := range p.values {142- p.checked[v.Text] = v.Shown143- }144- p.fields[1] = sheet.LocalCondArg(p.cond.Op, p.cond.Arg, m.locale())117+func (m *Model) openValuesPicker(title string, x int, values []sheet.FilterValue, cond sheet.Condition, apply func(sheet.Criteria)) *filterpick.Picker {118+ p := filterpick.New(m.host(), title, x, values, cond, apply)145119 m.openOverlay(p)146- m.line.Clear()147- p.search()120+ p.Start()148121 return p149122 }150123 151-// close closes the picker without applying it.152-func (p *filterPicker) close(m *Model) {153- m.closeOverlay()154- if p.onCancel != nil {155- p.onCancel(m)156- }157-}158-159-func (p *filterPicker) Indicator() string { return "FILTER" }160-161-// focus moves editing to field i, keeping the other field's text.162-func (p *filterPicker) focus(m *Model, i int) {163- p.fields[p.field] = m.line.Text()164- p.field = i165- m.line.Set(p.fields[i])166-}167-168-func (p *filterPicker) Changed() {169- m := p.m170- p.fields[p.field] = m.line.Text()171- if p.field == 0 {172- p.search()173- }174-}175-176-// valueLabel is how a value shows in the list: as the sheet's locale177-// shows it.178-func valueLabel(v sheet.FilterValue) string {179- switch {180- case v.Text == "":181- return "(Blanks)"182- case v.Label != "":183- return v.Label184- }185- return v.Text186-}187-188-// search narrows the list to the values matching the search.189-func (p *filterPicker) search() {190- p.Sel, p.Top = 0, 0191- p.shown = p.shown[:0]192- q := strings.TrimSpace(p.fields[0])193- if q == "" {194- for i := range p.values {195- p.shown = append(p.shown, i)196- }197- return198- }199- labels := make([]string, len(p.values))200- for i, v := range p.values {201- labels[i] = valueLabel(v)202- }203- for _, mt := range fuzzy.Find(q, labels) {204- p.shown = append(p.shown, mt.Index)124+// filterAnswer is the answer to data.filter_column that sets column125+// col's criteria to cr: {"column": "B", "hidden": [...]}, the criteria126+// as a filter column's are written in the file.127+func filterAnswer(col int, cr sheet.Criteria) string {128+ rest := strings.TrimPrefix(cr.JSON(), "{")129+ if rest != "}" {130+ rest = "," + rest205131 }132+ return `{"column":"` + sheet.ColName(col) + `"` + rest206133 }207134 208-// toggle checks or unchecks list row i: "Select all" (row 0) checks every209-// shown value, or unchecks them if they all are.210-func (p *filterPicker) toggle(i int) {211- if i > 0 {212- t := p.values[p.shown[i-1]].Text213- p.checked[t] = !p.checked[t]214- return215- }216- all := p.allChecked()217- for _, k := range p.shown {218- p.checked[p.values[k].Text] = !all135+// answerFilter sets a column's criteria from a macro's answer, as the136+// picker does: the column named, or the active one.137+func (m *Model) answerFilter(text string) (tea.Cmd, error) {138+ var a struct {139+ Column string `json:"column"`219140 }220-}221-222-func (p *filterPicker) allChecked() bool {223- for _, k := range p.shown {224- if !p.checked[p.values[k].Text] {225- return false226- }141+ if err := json.Unmarshal([]byte(text), &a); err != nil {142+ return nil, err227143 }228- return true229-}230-231-// cycle steps the condition through Sheets' list.232-func (p *filterPicker) cycle(d int) {233- n := len(sheet.CondOps())234- p.cond.Op = sheet.CondOp((int(p.cond.Op) + d + n) % n)235-}236-237-// apply sets the column's criteria and closes the picker.238-func (p *filterPicker) apply(m *Model) {239- p.fields[p.field] = m.line.Text()240- var cr sheet.Criteria241- for _, v := range p.values {242- if !p.checked[v.Text] {243- cr.Hidden = append(cr.Hidden, v.Text)144+ col := m.cur.Col145+ if a.Column != "" {146+ c, ok := sheet.ParseCol(strings.ToUpper(a.Column))147+ if !ok {148+ return nil, fmt.Errorf("no column %q", a.Column)244149 }150+ col = c245151 }246- cr.Cond = sheet.Condition{Op: p.cond.Op, Arg: sheet.CondArg(p.cond.Op, strings.TrimSpace(p.fields[1]), m.locale())}247- if !cr.Cond.Op.TakesArg() {248- cr.Cond.Arg = ""249- } else if cr.Cond.Arg == "" {250- cr.Cond = sheet.Condition{}152+ r, on := m.sheet.FilterRange()153+ switch {154+ case !on:155+ return nil, errors.New("the sheet has no filter: run(\"data.filter\") creates one")156+ case col < r.From.Col || col > r.To.Col:157+ return nil, fmt.Errorf("column %s isn't in the filter's range %s", sheet.ColName(col), r)251158 }252- m.closeOverlay()253- p.onApply(m, cr)159+ return nil, m.openFilterPicker(col).Answer(text)254160 }255161 256162 // filterColumn sets the criteria of column col of the sheet's filter.
@@ -272,196 +178,3 @@ func rowCount(n int) string {
272178 }273179 return strconv.Itoa(n) + " rows"274180 }275-276-func (p *filterPicker) Key(k tea.KeyPressMsg) tea.Cmd {277- m := p.m278- n := len(p.shown) + 1279- switch key := k.String(); {280- case key == "esc":281- p.close(m)282- case key == "enter":283- p.apply(m)284- case key == "tab" || key == "shift+tab":285- p.focus(m, 1-p.field)286- case p.field == 1 && (key == "up" || key == "down"):287- if key == "up" {288- p.cycle(-1)289- } else {290- p.cycle(1)291- }292- case key == "up" || key == "ctrl+p":293- p.Move(-1, n)294- case key == "down" || key == "ctrl+n":295- p.Move(1, n)296- case key == "pgup":297- p.Sel = max(p.Sel-p.rows(m), 0)298- case key == "pgdown":299- p.Sel = min(p.Sel+p.rows(m), n-1)300- case p.field == 0 && key == "space":301- p.toggle(p.Sel)302- default:303- before := m.line.Text()304- m.line.Key(k)305- if m.line.Text() != before {306- p.Changed()307- }308- }309- return nil310-}311-312-func (p *filterPicker) Mouse(e overlay.MouseEvent) tea.Cmd {313- m := p.m314- if e.Box != filterID {315- if e.Kind == overlay.MousePress {316- p.close(m)317- }318- return nil319- }320- n := len(p.shown) + 1321- i := p.Top + e.Row - filterFirstRow322- switch {323- case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:324- p.Move(-1, n)325- case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:326- p.Move(1, n)327- case e.Kind != overlay.MousePress && e.Kind != overlay.MouseMotion:328- case e.Row == 1 && e.Kind == overlay.MousePress:329- if p.field != 1 {330- p.focus(m, 1)331- }332- switch x := e.Col - 1 - len(" If "); {333- case x == 0:334- p.cycle(-1)335- case x == ansi.StringWidth(p.condChip())-1:336- p.cycle(1)337- }338- case e.Row == 3 && e.Kind == overlay.MousePress:339- p.focus(m, 0)340- case e.Row < filterFirstRow || i >= n || i >= p.Top+p.rows(m):341- case e.Kind == overlay.MouseMotion:342- p.Sel = i343- case e.Button == tea.MouseLeft:344- p.Sel = i345- p.toggle(i)346- }347- return nil348-}349-350-func (p *filterPicker) Status() (string, string) {351- m := p.m352- if p.field == 1 {353- return "Rows must also meet the condition", m.th.KeyHints("Up/Down", "condition", "Tab", "values", "Enter", "apply", "Esc", "cancel")354- }355- pairs := []string{"Space", "check", "Tab", "condition", "Enter", "apply", "Esc", "cancel"}356- desc := "Type to search the values"357- for {358- keys := m.th.KeyHints(pairs...)359- switch {360- case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:361- return m.th.Muted.Render(desc), keys362- case desc != "":363- desc = ""364- case len(pairs) > 4:365- pairs = append(pairs[:2], pairs[4:]...)366- default:367- return "", keys368- }369- }370-}371-372-// rows is how many list rows show: all of them if they fit above the373-// status line, at most twelve.374-func (p *filterPicker) rows(m *Model) int {375- return max(min(len(p.shown)+1, 12, m.height-1-gridTop-filterFirstRow-1), 1)376-}377-378-func (p *filterPicker) box(m *Model) (x, y, inner int) {379- w := 0380- for _, v := range p.values {381- w = max(w, ansi.StringWidth(valueLabel(v))+len(strconv.Itoa(v.Count)))382- }383- inner = clamp(w+10, 36, 48)384- inner = min(inner, m.width-2)385- h := p.rows(m) + filterFirstRow + 1386- x, y = m.clampBox(p.x, gridTop, inner+2, h)387- return x, min(y, max(m.height-1-h, 0)), inner388-}389-390-func (p *filterPicker) Cursor() (int, int) {391- m := p.m392- x, y, _ := p.box(m)393- caret := ansi.StringWidth(m.line.Head())394- if p.field == 1 {395- return x + 1 + len(" If ") + ansi.StringWidth(p.condChip()) + 2 + caret, y + 1396- }397- return x + 1 + ansi.StringWidth(overlay.SearchPrompt) + caret, y + 3398-}399-400-// condChip is the condition's name between arrows that change it.401-func (p *filterPicker) condChip() string {402- return "‹ " + p.cond.Op.Title() + " ›"403-}404-405-func (p *filterPicker) Layout() []overlay.Box {406- m := p.m407- x, y, inner := p.box(m)408- rows := p.rows(m)409- p.Show(rows)410-411- chipStyle := m.th.KeyChip412- if p.field == 1 {413- chipStyle = m.th.MenuSelected414- }415- cond := m.th.Muted.Render(" If ") + chipStyle.Render(p.condChip())416- if p.cond.Op.TakesArg() {417- arg := p.fields[1]418- if p.field == 1 {419- arg = m.line.Text()420- }421- if arg == "" && p.field != 1 {422- arg = m.th.Muted.Render("value")423- }424- cond += " " + arg425- }426- search := p.fields[0]427- if p.field == 0 {428- search = m.line.Text()429- }430- input := m.th.Title.Render(overlay.SearchPrompt) + search431- if search == "" {432- input += m.th.Muted.Render("Search values")433- }434- lines := []string{theme.Cells(m.th.MenuBar, cond, inner), theme.SepRow, theme.Cells(m.th.MenuBar, input, inner), theme.SepRow}435-436- for r := range rows {437- i := p.Top + r438- if i > len(p.shown) {439- lines = append(lines, theme.Cells(m.th.MenuBar, "", inner))440- continue441- }442- label, count, checked := "Select all", "", p.allChecked()443- dim := false444- if i > 0 {445- v := p.values[p.shown[i-1]]446- label, count, checked, dim = valueLabel(v), strconv.Itoa(v.Count), p.checked[v.Text], v.Text == ""447- }448- box := "[ ] "449- if checked {450- box = "[x] "451- }452- base, muted := m.th.MenuBar, m.th.Muted453- if i == p.Sel {454- base, muted = m.th.MenuSelected, m.th.MenuSelected455- }456- labelStyle := base457- if dim {458- labelStyle = muted459- }460- text := base.Render(" "+box) + theme.Cells(labelStyle, label, inner-6-len(count)-1)461- text += muted.Render(theme.PadLeft(count, len(count)+1)) + base.Render(" ")462- lines = append(lines, ansi.Truncate(text, inner, ""))463- }464- title := p.title465- footer := strconv.Itoa(len(p.shown)) + " of " + strconv.Itoa(len(p.values))466- return []overlay.Box{{ID: filterID, X: x, Y: y, Lines: m.th.Frame(inner, title, footer, lines)}}467-}
A internal/ui/filterpick/filterpick.go +399 -0
@@ -0,0 +1,399 @@
1+// Package filterpick is the values list and condition of one column of a2+// filter, in the manner of fzf: a condition on top, then the column's3+// values with checkboxes, narrowed by a fuzzy search as you type. The4+// sheet's filter and a pivot table's filters open it, each saying what5+// applying and cancelling do. It knows the UI only through Host.6+package filterpick7+8+import (9+ "strconv"10+ "strings"11+12+ tea "charm.land/bubbletea/v2"13+ "github.com/charmbracelet/x/ansi"14+ "github.com/sahilm/fuzzy"15+16+ "github.com/FelineStateMachine/012/internal/locale"17+ "github.com/FelineStateMachine/012/internal/sheet"18+ "github.com/FelineStateMachine/012/internal/ui/lineedit"19+ "github.com/FelineStateMachine/012/internal/ui/overlay"20+ "github.com/FelineStateMachine/012/internal/ui/theme"21+)22+23+// Host is what the filter picker needs of the UI.24+type Host interface {25+ Theme() *theme.Theme26+ Size() (width, height int)27+ // Line is the shared edit line the focused field is typed in.28+ Line() *lineedit.Line29+ // Close closes the open overlay.30+ Close()31+ // Locale is how the condition's value is typed and shown.32+ Locale() *locale.Locale33+}34+35+// Picker is the values list and condition for one column.36+type Picker struct {37+ h Host38+ title string39+ x int // where the box goes, e.g. over its column40+ // OnApply gets the criteria chosen, after the picker has closed.41+ OnApply func(cr sheet.Criteria)42+ // OnCancel, when set, runs after Esc or a click outside closes the43+ // picker.44+ OnCancel func()45+ values []sheet.FilterValue46+ checked map[string]bool47+ cond sheet.Condition48+ field int // 0 is the search, 1 the condition's value49+ fields [2]string // the text of each field50+ shown []int // values matching the search, best first51+ overlay.List // over the rows: "Select all", then shown52+}53+54+// ID identifies the picker's box in mouse events.55+const ID = "filter"56+57+// Rows of the box: the top border, the condition, a separator, the58+// search, a separator, then the list.59+const firstRow = 560+61+// New returns a picker titled title at screen column x over values, with62+// cond as the condition, calling apply with the criteria chosen. The63+// caller opens it, then calls Start.64+func New(h Host, title string, x int, values []sheet.FilterValue, cond sheet.Condition, apply func(sheet.Criteria)) *Picker {65+ p := &Picker{h: h, title: title, x: x, OnApply: apply, values: values, checked: map[string]bool{}, cond: cond}66+ for _, v := range p.values {67+ p.checked[v.Text] = v.Shown68+ }69+ p.fields[1] = sheet.LocalCondArg(p.cond.Op, p.cond.Arg, h.Locale())70+ return p71+}72+73+// Start readies the edit line for the search, once the picker is open.74+func (p *Picker) Start() {75+ p.h.Line().Clear()76+ p.search()77+}78+79+// close closes the picker without applying it.80+func (p *Picker) close() {81+ p.h.Close()82+ if p.OnCancel != nil {83+ p.OnCancel()84+ }85+}86+87+func (p *Picker) Indicator() string { return "FILTER" }88+89+// focus moves editing to field i, keeping the other field's text.90+func (p *Picker) focus(i int) {91+ line := p.h.Line()92+ p.fields[p.field] = line.Text()93+ p.field = i94+ line.Set(p.fields[i])95+}96+97+func (p *Picker) Changed() {98+ p.fields[p.field] = p.h.Line().Text()99+ if p.field == 0 {100+ p.search()101+ }102+}103+104+// valueLabel is how a value shows in the list: as the sheet's locale105+// shows it.106+func valueLabel(v sheet.FilterValue) string {107+ switch {108+ case v.Text == "":109+ return "(Blanks)"110+ case v.Label != "":111+ return v.Label112+ }113+ return v.Text114+}115+116+// search narrows the list to the values matching the search.117+func (p *Picker) search() {118+ p.Sel, p.Top = 0, 0119+ p.shown = p.shown[:0]120+ q := strings.TrimSpace(p.fields[0])121+ if q == "" {122+ for i := range p.values {123+ p.shown = append(p.shown, i)124+ }125+ return126+ }127+ labels := make([]string, len(p.values))128+ for i, v := range p.values {129+ labels[i] = valueLabel(v)130+ }131+ for _, mt := range fuzzy.Find(q, labels) {132+ p.shown = append(p.shown, mt.Index)133+ }134+}135+136+// toggle checks or unchecks list row i: "Select all" (row 0) checks every137+// shown value, or unchecks them if they all are.138+func (p *Picker) toggle(i int) {139+ if i > 0 {140+ t := p.values[p.shown[i-1]].Text141+ p.checked[t] = !p.checked[t]142+ return143+ }144+ all := p.allChecked()145+ for _, k := range p.shown {146+ p.checked[p.values[k].Text] = !all147+ }148+}149+150+func (p *Picker) allChecked() bool {151+ for _, k := range p.shown {152+ if !p.checked[p.values[k].Text] {153+ return false154+ }155+ }156+ return true157+}158+159+// cycle steps the condition through Sheets' list.160+func (p *Picker) cycle(d int) {161+ n := len(sheet.CondOps())162+ p.cond.Op = sheet.CondOp((int(p.cond.Op) + d + n) % n)163+}164+165+// apply closes the picker and hands on the criteria chosen.166+func (p *Picker) apply() {167+ p.fields[p.field] = p.h.Line().Text()168+ var cr sheet.Criteria169+ for _, v := range p.values {170+ if !p.checked[v.Text] {171+ cr.Hidden = append(cr.Hidden, v.Text)172+ }173+ }174+ cr.Cond = sheet.Condition{Op: p.cond.Op, Arg: sheet.CondArg(p.cond.Op, strings.TrimSpace(p.fields[1]), p.h.Locale())}175+ if !cr.Cond.Op.TakesArg() {176+ cr.Cond.Arg = ""177+ } else if cr.Cond.Arg == "" {178+ cr.Cond = sheet.Condition{}179+ }180+ p.h.Close()181+ p.OnApply(cr)182+}183+184+// Answer applies criteria a macro recorded, as sheet.Criteria.JSON writes185+// them: the values hidden, and a condition with its value in en-US's186+// form, as if checked and typed.187+func (p *Picker) Answer(text string) error {188+ cr, err := sheet.ParseCriteria(text)189+ if err != nil {190+ return err191+ }192+ hidden := map[string]bool{}193+ for _, v := range cr.Hidden {194+ hidden[v] = true195+ }196+ for _, v := range p.values {197+ p.checked[v.Text] = !hidden[v.Text]198+ }199+ p.cond.Op = cr.Cond.Op200+ p.field = 1201+ p.h.Line().Set(sheet.LocalCondArg(cr.Cond.Op, cr.Cond.Arg, p.h.Locale()))202+ p.apply()203+ return nil204+}205+206+func (p *Picker) Key(k tea.KeyPressMsg) tea.Cmd {207+ n := len(p.shown) + 1208+ switch key := k.String(); {209+ case key == "esc":210+ p.close()211+ case key == "enter":212+ p.apply()213+ case key == "tab" || key == "shift+tab":214+ p.focus(1 - p.field)215+ case p.field == 1 && (key == "up" || key == "down"):216+ if key == "up" {217+ p.cycle(-1)218+ } else {219+ p.cycle(1)220+ }221+ case key == "up" || key == "ctrl+p":222+ p.Move(-1, n)223+ case key == "down" || key == "ctrl+n":224+ p.Move(1, n)225+ case key == "pgup":226+ p.Sel = max(p.Sel-p.rows(), 0)227+ case key == "pgdown":228+ p.Sel = min(p.Sel+p.rows(), n-1)229+ case p.field == 0 && key == "space":230+ p.toggle(p.Sel)231+ default:232+ line := p.h.Line()233+ before := line.Text()234+ line.Key(k)235+ if line.Text() != before {236+ p.Changed()237+ }238+ }239+ return nil240+}241+242+func (p *Picker) Mouse(e overlay.MouseEvent) tea.Cmd {243+ if e.Box != ID {244+ if e.Kind == overlay.MousePress {245+ p.close()246+ }247+ return nil248+ }249+ n := len(p.shown) + 1250+ i := p.Top + e.Row - firstRow251+ switch {252+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:253+ p.Move(-1, n)254+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:255+ p.Move(1, n)256+ case e.Kind != overlay.MousePress && e.Kind != overlay.MouseMotion:257+ case e.Row == 1 && e.Kind == overlay.MousePress:258+ if p.field != 1 {259+ p.focus(1)260+ }261+ switch x := e.Col - 1 - len(" If "); {262+ case x == 0:263+ p.cycle(-1)264+ case x == ansi.StringWidth(p.condChip())-1:265+ p.cycle(1)266+ }267+ case e.Row == 3 && e.Kind == overlay.MousePress:268+ p.focus(0)269+ case e.Row < firstRow || i >= n || i >= p.Top+p.rows():270+ case e.Kind == overlay.MouseMotion:271+ p.Sel = i272+ case e.Button == tea.MouseLeft:273+ p.Sel = i274+ p.toggle(i)275+ }276+ return nil277+}278+279+func (p *Picker) Status() (string, string) {280+ th := p.h.Theme()281+ if p.field == 1 {282+ return "Rows must also meet the condition", th.KeyHints("Up/Down", "condition", "Tab", "values", "Enter", "apply", "Esc", "cancel")283+ }284+ width, _ := p.h.Size()285+ pairs := []string{"Space", "check", "Tab", "condition", "Enter", "apply", "Esc", "cancel"}286+ desc := "Type to search the values"287+ for {288+ keys := th.KeyHints(pairs...)289+ switch {290+ case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= width:291+ return th.Muted.Render(desc), keys292+ case desc != "":293+ desc = ""294+ case len(pairs) > 4:295+ pairs = append(pairs[:2], pairs[4:]...)296+ default:297+ return "", keys298+ }299+ }300+}301+302+// rows is how many list rows show: all of them if they fit above the303+// status line, at most twelve.304+func (p *Picker) rows() int {305+ _, height := p.h.Size()306+ return max(min(len(p.shown)+1, 12, height-1-overlay.GridTop-firstRow-1), 1)307+}308+309+func (p *Picker) box() (x, y, inner int) {310+ width, height := p.h.Size()311+ w := 0312+ for _, v := range p.values {313+ w = max(w, ansi.StringWidth(valueLabel(v))+len(strconv.Itoa(v.Count)))314+ }315+ inner = min(max(w+10, 36), 48, width-2)316+ h := p.rows() + firstRow + 1317+ x = max(min(p.x, width-inner-2), 0)318+ y = max(min(overlay.GridTop, height-h), 0)319+ return x, min(y, max(height-1-h, 0)), inner320+}321+322+func (p *Picker) Cursor() (int, int) {323+ x, y, _ := p.box()324+ caret := ansi.StringWidth(p.h.Line().Head())325+ if p.field == 1 {326+ return x + 1 + len(" If ") + ansi.StringWidth(p.condChip()) + 2 + caret, y + 1327+ }328+ return x + 1 + ansi.StringWidth(overlay.SearchPrompt) + caret, y + 3329+}330+331+// condChip is the condition's name between arrows that change it.332+func (p *Picker) condChip() string {333+ return "‹ " + p.cond.Op.Title() + " ›"334+}335+336+func (p *Picker) Layout() []overlay.Box {337+ th := p.h.Theme()338+ x, y, inner := p.box()339+ rows := p.rows()340+ p.Show(rows)341+342+ chipStyle := th.KeyChip343+ if p.field == 1 {344+ chipStyle = th.MenuSelected345+ }346+ cond := th.Muted.Render(" If ") + chipStyle.Render(p.condChip())347+ if p.cond.Op.TakesArg() {348+ arg := p.fields[1]349+ if p.field == 1 {350+ arg = p.h.Line().Text()351+ }352+ if arg == "" && p.field != 1 {353+ arg = th.Muted.Render("value")354+ }355+ cond += " " + arg356+ }357+ search := p.fields[0]358+ if p.field == 0 {359+ search = p.h.Line().Text()360+ }361+ input := th.Title.Render(overlay.SearchPrompt) + search362+ if search == "" {363+ input += th.Muted.Render("Search values")364+ }365+ lines := []string{theme.Cells(th.MenuBar, cond, inner), theme.SepRow, theme.Cells(th.MenuBar, input, inner), theme.SepRow}366+ for r := range rows {367+ lines = append(lines, p.row(th, p.Top+r, inner))368+ }369+ footer := strconv.Itoa(len(p.shown)) + " of " + strconv.Itoa(len(p.values))370+ return []overlay.Box{{ID: ID, X: x, Y: y, Lines: th.Frame(inner, p.title, footer, lines)}}371+}372+373+// row draws list row i: "Select all", then the values shown.374+func (p *Picker) row(th *theme.Theme, i, inner int) string {375+ if i > len(p.shown) {376+ return theme.Cells(th.MenuBar, "", inner)377+ }378+ label, count, checked := "Select all", "", p.allChecked()379+ dim := false380+ if i > 0 {381+ v := p.values[p.shown[i-1]]382+ label, count, checked, dim = valueLabel(v), strconv.Itoa(v.Count), p.checked[v.Text], v.Text == ""383+ }384+ box := "[ ] "385+ if checked {386+ box = "[x] "387+ }388+ base, muted := th.MenuBar, th.Muted389+ if i == p.Sel {390+ base, muted = th.MenuSelected, th.MenuSelected391+ }392+ labelStyle := base393+ if dim {394+ labelStyle = muted395+ }396+ text := base.Render(" "+box) + theme.Cells(labelStyle, label, inner-6-len(count)-1)397+ text += muted.Render(theme.PadLeft(count, len(count)+1)) + base.Render(" ")398+ return ansi.Truncate(text, inner, "")399+}
A internal/ui/filterpick/filterpick_test.go +131 -0
@@ -0,0 +1,131 @@
1+package filterpick2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/locale"11+ "github.com/FelineStateMachine/012/internal/sheet"12+ "github.com/FelineStateMachine/012/internal/ui/lineedit"13+ "github.com/FelineStateMachine/012/internal/ui/overlay"14+ "github.com/FelineStateMachine/012/internal/ui/theme"15+)16+17+// fakeHost is a screen and an edit line in a locale.18+type fakeHost struct {19+ th theme.Theme20+ line lineedit.Line21+ loc *locale.Locale22+ closed int23+}24+25+func (h *fakeHost) Theme() *theme.Theme { return &h.th }26+func (h *fakeHost) Size() (int, int) { return 100, 30 }27+func (h *fakeHost) Line() *lineedit.Line { return &h.line }28+func (h *fakeHost) Close() { h.closed++ }29+func (h *fakeHost) Locale() *locale.Locale { return h.loc }30+31+var values = []sheet.FilterValue{{Text: "East", Count: 2, Shown: true}, {Text: "North", Count: 1, Shown: true}, {Text: "", Count: 1, Shown: false}}32+33+// open opens a picker as the model does, noting the criteria applied34+// and whether it was cancelled.35+func open(h *fakeHost, got *sheet.Criteria, cancelled *bool) *Picker {36+ p := New(h, "Filter A Region", 3, values, sheet.Condition{}, func(cr sheet.Criteria) { *got = cr })37+ p.OnCancel = func() { *cancelled = true }38+ p.Start()39+ return p40+}41+42+func newHost() *fakeHost {43+ loc, _ := locale.Lookup("en-US")44+ return &fakeHost{th: theme.New(true), loc: loc}45+}46+47+func keys(p *Picker, ks ...tea.KeyPressMsg) {48+ for _, k := range ks {49+ p.Key(k)50+ }51+}52+53+func text(p *Picker, s string) {54+ for _, r := range s {55+ p.Key(tea.KeyPressMsg{Code: r, Text: string(r)})56+ }57+}58+59+var (60+ down = tea.KeyPressMsg{Code: tea.KeyDown}61+ space = tea.KeyPressMsg{Code: tea.KeySpace}62+ tab = tea.KeyPressMsg{Code: tea.KeyTab}63+ enter = tea.KeyPressMsg{Code: tea.KeyEnter}64+ esc = tea.KeyPressMsg{Code: tea.KeyEscape}65+)66+67+func TestCheckAndApply(t *testing.T) {68+ h := newHost()69+ var cr sheet.Criteria70+ var cancelled bool71+ p := open(h, &cr, &cancelled)72+ out := ansi.Strip(strings.Join(p.Layout()[0].Lines, "\n"))73+ for _, want := range []string{"Filter A Region", "[ ] Select all", "[x] East", "[ ] (Blanks)", "3 of 3"} {74+ if !strings.Contains(out, want) {75+ t.Errorf("box lacks %q:\n%s", want, out)76+ }77+ }78+ keys(p, down, space) // uncheck East79+ text(p, "nor")80+ if out := ansi.Strip(strings.Join(p.Layout()[0].Lines, "\n")); !strings.Contains(out, "1 of 3") {81+ t.Errorf("search didn't narrow:\n%s", out)82+ }83+ keys(p, enter)84+ if h.closed != 1 || strings.Join(cr.Hidden, ",") != "East," || cr.Cond.Op != sheet.CondNone {85+ t.Fatalf("applied %+v, closed %d", cr, h.closed)86+ }87+}88+89+func TestConditionAndCancel(t *testing.T) {90+ h := newHost()91+ var cr sheet.Criteria92+ var cancelled bool93+ p := open(h, &cr, &cancelled)94+ keys(p, tab, down, down, down) // the condition: Text contains95+ text(p, "or")96+ if x, y := p.Cursor(); y != overlay.GridTop+1 || x < 3 {97+ t.Errorf("cursor %d, %d", x, y)98+ }99+ keys(p, enter)100+ if cr.Cond.Op != sheet.CondContains || cr.Cond.Arg != "or" {101+ t.Fatalf("condition %+v", cr.Cond)102+ }103+ p = open(h, &cr, &cancelled)104+ keys(p, esc)105+ if !cancelled {106+ t.Fatal("Esc didn't cancel")107+ }108+ cancelled = false109+ p = open(h, &cr, &cancelled)110+ p.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, X: 90, Y: 20})111+ if !cancelled {112+ t.Fatal("a click outside didn't cancel")113+ }114+}115+116+func TestAnswerInTheLocale(t *testing.T) {117+ h := newHost()118+ h.loc, _ = locale.Lookup("de-DE")119+ var cr sheet.Criteria120+ var cancelled bool121+ p := open(h, &cr, &cancelled)122+ if err := p.Answer(`{"hidden":["North"],"condition":"gt","value":"1.5"}`); err != nil {123+ t.Fatal(err)124+ }125+ if strings.Join(cr.Hidden, ",") != "North" || cr.Cond.Op != sheet.CondGreater || cr.Cond.Arg != "1.5" {126+ t.Fatalf("answered %+v", cr)127+ }128+ if err := open(h, &cr, &cancelled).Answer(`{"condition":"bigger"}`); err == nil || !strings.Contains(err.Error(), `"bigger"`) {129+ t.Fatalf("a bad condition: %v", err)130+ }131+}
M internal/ui/find.go +5 -0
A internal/ui/findbar/answer.go +92 -0
@@ -0,0 +1,92 @@
1+package findbar2+3+import (4+ "encoding/json"5+ "errors"6+ "fmt"7+ "slices"8+ "strings"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// answerJSON is a replacement as a macro answers Find and replace:14+//15+// {"find": "Rent", "replace": "Lease", "matchCase": true, "within": "all"}16+//17+// the query and the replacement, the options that are on, where to18+// search (left out for the sheet shown, "all" for every sheet, or a19+// range of the sheet shown), and "cell" to replace only the match in20+// that cell rather than all of them.21+type answerJSON struct {22+ Find string `json:"find"`23+ Replace string `json:"replace"`24+ MatchCase bool `json:"matchCase,omitempty"`25+ WholeCell bool `json:"wholeCell,omitempty"`26+ Regex bool `json:"regex,omitempty"`27+ InFormulas bool `json:"inFormulas,omitempty"`28+ Within string `json:"within,omitempty"`29+ Cell string `json:"cell,omitempty"`30+}31+32+// answer is the bar's search and replacement as a macro records them,33+// replacing only in cell when it isn't "".34+func (f *Bar) answer(cell string) string {35+ a := answerJSON{Find: f.fields[0], Replace: f.fields[1], MatchCase: f.opts.MatchCase, WholeCell: f.opts.WholeCell,36+ Regex: f.opts.Regex, InFormulas: f.opts.InFormulas, Cell: cell}37+ switch f.where {38+ case inAll:39+ a.Within = "all"40+ case inRange:41+ a.Within = f.scope.String()42+ }43+ raw, _ := json.Marshal(a)44+ return string(raw)45+}46+47+// Answer makes the replacement a macro recorded (see answerJSON), as if48+// typed and chosen in the bar.49+func (f *Bar) Answer(text string) error {50+ var a answerJSON51+ if err := json.Unmarshal([]byte(text), &a); err != nil {52+ return fmt.Errorf("the answer: %w", err)53+ }54+ if a.Find == "" {55+ return errors.New(`the answer has nothing to find: give "find"`)56+ }57+ f.fields, f.replace, f.field = [2]string{a.Find, a.Replace}, true, 158+ f.h.Line().Set(a.Replace)59+ f.opts = sheet.FindOptions{MatchCase: a.MatchCase, WholeCell: a.WholeCell, Regex: a.Regex, InFormulas: a.InFormulas}60+ s, at := f.h.At()61+ switch a.Within {62+ case "":63+ f.where = inSheet64+ case "all":65+ f.where = inAll66+ default:67+ r, ok := sheet.ParseRange(strings.ToUpper(a.Within))68+ if !ok {69+ return fmt.Errorf(`"within" is %q: "all", or a range such as B2:D9`, a.Within)70+ }71+ f.scope, f.home, f.where = &r, s, inRange72+ }73+ f.from = Match{s, at}74+ if f.search(); f.err != "" {75+ return errors.New(f.err)76+ }77+ if a.Cell == "" {78+ f.replaceAll()79+ return nil80+ }81+ c, ok := sheet.ParseAddr(strings.ToUpper(a.Cell))82+ if !ok {83+ return fmt.Errorf("%q isn't a cell", a.Cell)84+ }85+ i := slices.Index(f.matches, Match{s, c})86+ if i < 0 {87+ return fmt.Errorf("%s doesn't match %q", c, a.Find)88+ }89+ f.cur = i90+ f.replaceOne()91+ return nil92+}
M internal/ui/findbar/find.go +21 -9
@@ -35,8 +35,11 @@ type Host interface {
3535 Show(s *sheet.Sheet, a sheet.Addr)3636 // Note says how the last action went, on the context line.3737 Note(msg string)38- // Edited marks the file modified.39- Edited()38+ // Replace runs a replacement, do, which returns what it did as a39+ // macro answers Find and replace (see Answer), or "" when it changed40+ // nothing: the file is then modified, and a macro being recorded41+ // records it after the selection it started from.42+ Replace(do func() string)4043 // Leave closes the bar, keeping its search for Ctrl+F and find next.4144 Leave()4245 // Press handles a mouse press outside the bar, as on the grid.
@@ -343,26 +346,34 @@ func (f *Bar) replaceOne() {
343346 if f.cur < 0 {344347 return345348 }349+ f.h.Replace(f.doReplaceOne)350+}351+352+func (f *Bar) doReplaceOne() string {346353 c := f.matches[f.cur]347354 a := c.At348355 changed, err := c.Sheet.Replace(a, f.fields[0], f.fields[1], f.options())349356 switch {350357 case err != nil:351358 f.h.Note("Can't replace in " + a.String() + ": " + err.Error())352- return359+ return ""353360 case !changed:354361 f.h.Note("Formulas in " + a.String() + " change only when searching within formulas")355- default:356- f.h.Edited()357362 }358363 f.from = c359364 f.search()360365 if changed && f.cur >= 0 && f.matches[f.cur] == c {361366 f.step(1) // the cell still matches; move past it362367 }368+ if !changed {369+ return ""370+ }371+ return f.answer(a.String())363372 }364373 365-func (f *Bar) replaceAll() {374+func (f *Bar) replaceAll() { f.h.Replace(f.doReplaceAll) }375+376+func (f *Bar) doReplaceAll() string {366377 // Across sheets, still one undo step.367378 span := f.h.Trace().Start("replace")368379 n := 0
@@ -378,9 +389,6 @@ func (f *Bar) replaceAll() {
378389 return nil379390 })380391 span.End(slog.Int("replaced", n))381- if n > 0 {382- f.h.Edited()383- }384392 switch {385393 case err != nil:386394 f.h.Note("Replaced " + cellCount(n) + ", then stopped: " + err.Error())
@@ -390,6 +398,10 @@ func (f *Bar) replaceAll() {
390398 f.h.Note("Replaced " + cellCount(n))391399 }392400 f.search()401+ if n == 0 {402+ return ""403+ }404+ return f.answer("")393405 }394406 395407 // cellCount is n cells in words: "1 cell", "3 cells".
M internal/ui/findbar/find_test.go +43 -2
@@ -23,6 +23,7 @@ type fakeHost struct {
2323 cur sheet.Addr2424 note string2525 edited bool26+ answers []string2627 left int2728 pressed []int2829 }
@@ -51,8 +52,13 @@ func (f *fakeHost) Trace() *telemetry.Trace { return nil }
5152 func (f *fakeHost) At() (*sheet.Sheet, sheet.Addr) { return f.s, f.cur }5253 func (f *fakeHost) Show(s *sheet.Sheet, a sheet.Addr) { f.s, f.cur = s, a }5354 func (f *fakeHost) Note(msg string) { f.note = msg }54-func (f *fakeHost) Edited() { f.edited = true }55-func (f *fakeHost) Leave() { f.left++ }55+func (f *fakeHost) Replace(do func() string) {56+ if a := do(); a != "" {57+ f.edited = true58+ f.answers = append(f.answers, a)59+ }60+}61+func (f *fakeHost) Leave() { f.left++ }5662 5763 func (f *fakeHost) Press(x, y int, _ tea.MouseButton) tea.Cmd {5864 f.pressed = []int{x, y}
@@ -144,6 +150,41 @@ func TestReplace(t *testing.T) {
144150 if got := h.s.Cell(addr("A3")).Input; got != "total lease" || h.note != "Replaced 2 cells" {145151 t.Errorf("replace all: A3 %q, note %q", got, h.note)146152 }153+ // What a macro records of each.154+ want := `{"find":"rent","replace":"lease","cell":"A1"}` + "\n" + `{"find":"rent","replace":"lease"}`155+ if got := strings.Join(h.answers, "\n"); got != want {156+ t.Errorf("answers:\n%s\nwant:\n%s", got, want)157+ }158+}159+160+func TestAnswerReplaces(t *testing.T) {161+ h := newHost(t, "A1", "rent", "A2", "Rent", "B2", "rent")162+ b := open(h, true)163+ if err := b.Answer(`{"find":"rent","replace":"lease","matchCase":true,"cell":"B2"}`); err != nil {164+ t.Fatal(err)165+ }166+ if h.s.Cell(addr("B2")).Input != "lease" || h.s.Cell(addr("A1")).Input != "rent" {167+ t.Fatalf("one replacement: A1 %q B2 %q", h.s.Cell(addr("A1")).Input, h.s.Cell(addr("B2")).Input)168+ }169+ if err := b.Answer(`{"find":"rent","replace":"hire","within":"A1:A2"}`); err != nil {170+ t.Fatal(err)171+ }172+ if h.s.Cell(addr("A1")).Input != "hire" || h.s.Cell(addr("A2")).Input != "hire" {173+ t.Fatalf("within a range: A1 %q A2 %q", h.s.Cell(addr("A1")).Input, h.s.Cell(addr("A2")).Input)174+ }175+ if last := h.answers[len(h.answers)-1]; last != `{"find":"rent","replace":"hire","within":"A1:A2"}` {176+ t.Errorf("recorded %s", last)177+ }178+ for text, want := range map[string]string{179+ `{"replace":"x"}`: "nothing to find",180+ `{"find":"x","within":"here"}`: `"within" is "here"`,181+ `{"find":"(","regex":true}`: "Invalid regular expression",182+ `{"find":"zzz","replace":"y","cell":"A1"}`: `A1 doesn't match "zzz"`,183+ } {184+ if err := b.Answer(text); err == nil || !strings.Contains(err.Error(), want) {185+ t.Errorf("Answer(%s) = %v, want %q", text, err, want)186+ }187+ }147188 }148189 149190 func TestMouse(t *testing.T) {
M internal/ui/format_test.go +2 -1
M internal/ui/help.go +10 -140
@@ -4,22 +4,19 @@ import (
44 "cmp"55 "runtime/debug"66 "slices"7- "strconv"87 "strings"98 109 tea "charm.land/bubbletea/v2"11- "github.com/charmbracelet/x/ansi"1210 1311 "github.com/FelineStateMachine/012/internal/sheet"14- "github.com/FelineStateMachine/012/internal/ui/overlay"1512 "github.com/FelineStateMachine/012/internal/ui/picker"16- "github.com/FelineStateMachine/012/internal/ui/theme"13+ "github.com/FelineStateMachine/012/internal/ui/shortcuts"1714 )1815 1916 func init() {2017 register(2118 &command{id: "help", macro: macroNever, title: "Keyboard shortcuts", desc: "Show every key, grouped by what it does", run: func(m *Model) tea.Cmd {22- m.openOverlay(&shortcuts{m: m})19+ m.openOverlay(shortcuts.New(m.host()))2320 return nil2421 }},2522 &command{id: "help.functions", macro: macroNever, title: "Function list", desc: "Search the functions formulas can use", run: func(m *Model) tea.Cmd {
@@ -29,8 +26,7 @@ func init() {
2926 return nil3027 }},3128 &command{id: "help.about", macro: macroNever, title: "About 012", desc: "Show the version", run: func(m *Model) tea.Cmd {32- m.openOverlay(&choiceBar{33- m: m,29+ m.ask(question{3430 msg: "012 " + version() + ": Lotus 1-2-3 looks, Google Sheets keys.",3531 choices: []choice{3632 {key: "esc", label: "Close", run: func(*Model) tea.Cmd { return nil }},
@@ -172,139 +168,13 @@ func keyHelpRows(vim bool) []helpRow {
172168 return rows173169 }174170 175-// shortcuts is the keyboard shortcuts view: a scrollable box over the176-// grid, in two columns when the screen is wide enough.177-type shortcuts struct {178- m *Model // the model it acts on179- top int180-}181-182-const shortcutsID = "shortcuts"183-184-func (s *shortcuts) Indicator() string { return "HELP" }185-186-// lines renders the rows, as one or two columns, and returns their width.187-func (s *shortcuts) lines(m *Model) ([]string, int) {188- rows := helpRows(m.prefs.vim)189- keyW, actW := 0, 0190- for _, r := range rows {191- keyW = max(keyW, ansi.StringWidth(m.th.Chips(r.keys)))192- actW = max(actW, ansi.StringWidth(r.action))193- }194- // On narrow screens actions give way (truncated) so the keys fit.195- room := min(m.width-4, 160)196- if 1+keyW+2+actW > room {197- actW = max(room-3-keyW, 16)198- }199- colW := 1 + keyW + 2 + actW200- render := func(rows []helpRow) []string {201- var out []string202- for i, r := range rows {203- switch {204- case r.heading != "" && i > 0:205- out = append(out, "", m.th.Title.Render(" "+r.heading))206- case r.heading != "":207- out = append(out, m.th.Title.Render(" "+r.heading))208- default:209- out = append(out, " "+theme.PadRight(ansi.Truncate(r.action, actW, "…"), actW)+" "+m.th.Chips(r.keys))210- }211- }212- return out213- }214- if 2*colW+3 > room {215- return render(rows), min(colW, room)216- }217- // Two columns, split at the group boundary nearest the middle.218- split, best := 0, len(rows)171+// Shortcuts are the rows of the shortcuts view (package shortcuts),172+// with the keys in use.173+func (h host) Shortcuts() []shortcuts.Row {174+ rows := helpRows(h.m.prefs.vim)175+ out := make([]shortcuts.Row, len(rows))219176 for i, r := range rows {220- if d := abs(len(rows) - 2*i); r.heading != "" && d < best {221- split, best = i, d222- }223- }224- left, right := render(rows[:split]), render(rows[split:])225- out := make([]string, max(len(left), len(right)))226- for i := range out {227- var l, r string228- if i < len(left) {229- l = left[i]230- }231- if i < len(right) {232- r = right[i]233- }234- out[i] = theme.PadRight(l, colW) + " " + r235- }236- return out, 2*colW + 3237-}238-239-func abs(x int) int { return max(x, -x) }240-241-// visible is how many lines fit between the menu bar and the status line.242-func (s *shortcuts) visible(m *Model, total int) int {243- return max(min(total, m.height-2-2-1), 1)244-}245-246-func (s *shortcuts) Layout() []overlay.Box {247- m := s.m248- lines, w := s.lines(m)249- inner := w + 1250- n := s.visible(m, len(lines)+1)251- s.top = clamp(s.top, 0, max(len(lines)+1-n, 0))252- lines = append([]string{""}, lines...) // breathing room under the title253- rows := make([]string, n)254- for i := range rows {255- if j := s.top + i; j < len(lines) {256- rows[i] = theme.Cells(m.th.MenuBar, lines[j], inner)257- } else {258- rows[i] = strings.Repeat(" ", inner)259- }177+ out[i] = shortcuts.Row{Heading: r.heading, Keys: r.keys, Action: r.action}260178 }261- footer := ""262- if n < len(lines) {263- footer = strconv.Itoa(s.top+1) + "-" + strconv.Itoa(s.top+n) + " of " + strconv.Itoa(len(lines))264- }265- b := m.th.Frame(inner, "Keyboard shortcuts", footer, rows)266- w, h := ansi.StringWidth(b[0]), len(b)267- return []overlay.Box{{ID: shortcutsID, X: (m.width - w) / 2, Y: max(menuLine+1, (m.height-1-h)/2), Lines: b}}268-}269-270-func (s *shortcuts) Key(k tea.KeyPressMsg) tea.Cmd {271- m := s.m272- lines, _ := s.lines(m)273- page := s.visible(m, len(lines)+1)274- switch k.String() {275- case "up", "k":276- s.top--277- case "down", "j":278- s.top++279- case "pgup":280- s.top -= page281- case "pgdown", "space":282- s.top += page283- case "home":284- s.top = 0285- case "end":286- s.top = len(lines)287- case "esc", "enter", "q", "f1", "ctrl+/":288- m.closeOverlay()289- }290- s.top = clamp(s.top, 0, max(len(lines)+1-page, 0))291- return nil292-}293-294-func (s *shortcuts) Mouse(e overlay.MouseEvent) tea.Cmd {295- m := s.m296- switch {297- case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:298- s.top = max(s.top-3, 0)299- case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:300- s.top += 3 // layout clamps301- case e.Kind == overlay.MousePress && e.Box != shortcutsID:302- m.closeOverlay()303- }304- return nil305-}306-307-func (s *shortcuts) Status() (string, string) {308- m := s.m309- return "", m.th.KeyHints("Up/Down", "scroll", "Esc", "close")179+ return out310180 }
M internal/ui/help_test.go +11 -10
@@ -6,6 +6,7 @@ import (
66 77 tea "charm.land/bubbletea/v2"88 9+ "github.com/FelineStateMachine/012/internal/ui/shortcuts"910 "github.com/FelineStateMachine/012/internal/ui/theme"1011 )1112
@@ -31,7 +32,7 @@ func TestShortcutsListEveryBoundCommand(t *testing.T) {
3132 func TestShortcutsOverlay(t *testing.T) {3233 m := newModel()3334 press(t, m, "<f1>")34- s, ok := m.overlay.(*shortcuts)35+ s, ok := m.overlay.(*shortcuts.View)3536 if !ok || !strings.HasSuffix(line(m, menuLine), "HELP") {3637 t.Fatalf("F1 opened %T", m.overlay)3738 }
@@ -46,25 +47,25 @@ func TestShortcutsOverlay(t *testing.T) {
4647 t.Errorf("no scroll position in footer:\n%s", scr)4748 }4849 press(t, m, "<down>", "<down>")49- if s.top != 2 {50- t.Errorf("down scrolled to %d", s.top)50+ if s.Top() != 2 {51+ t.Errorf("down scrolled to %d", s.Top())5152 }5253 send(m, tea.MouseWheelMsg{X: 40, Y: 10, Button: tea.MouseWheelUp})53- if m.View(); s.top != 0 {54- t.Errorf("wheel up scrolled to %d", s.top)54+ if m.View(); s.Top() != 0 {55+ t.Errorf("wheel up scrolled to %d", s.Top())5556 }5657 press(t, m, "<end>")57- bottom := s.top58+ bottom := s.Top()5859 press(t, m, "<down>")59- if bottom == 0 || s.top != bottom {60- t.Errorf("End scrolled to %d, then Down to %d", bottom, s.top)60+ if bottom == 0 || s.Top() != bottom {61+ t.Errorf("End scrolled to %d, then Down to %d", bottom, s.Top())6162 }6263 press(t, m, "<esc>")6364 if m.overlay != nil || m.mode != modeReady {6465 t.Error("Esc did not close the shortcuts")6566 }6667 press(t, m, "<ctrl+/>")67- if _, ok := m.overlay.(*shortcuts); !ok {68+ if _, ok := m.overlay.(*shortcuts.View); !ok {6869 t.Error("Ctrl+/ did not open the shortcuts")6970 }7071 leftClick(m, 0, m.height-1)
@@ -77,7 +78,7 @@ func TestShortcutsTwoColumnsWhenWide(t *testing.T) {
7778 m := newModel()7879 m.Update(tea.WindowSizeMsg{Width: 200, Height: 50})7980 press(t, m, "<f1>")80- lines, w := m.overlay.(*shortcuts).lines(m)81+ lines, w := m.overlay.(*shortcuts.View).Lines()8182 if w < 100 || len(lines) > len(helpRows(false)) {8283 t.Errorf("width %d, %d lines for %d rows", w, len(lines), len(helpRows(false)))8384 }
M internal/ui/hosts.go +30 -5
@@ -3,13 +3,19 @@ package ui
33 import (44 tea "charm.land/bubbletea/v2"55 6+ "github.com/FelineStateMachine/012/internal/locale"67 "github.com/FelineStateMachine/012/internal/sheet"78 "github.com/FelineStateMachine/012/internal/telemetry"9+ "github.com/FelineStateMachine/012/internal/ui/choicebar"810 "github.com/FelineStateMachine/012/internal/ui/cmdline"11+ "github.com/FelineStateMachine/012/internal/ui/filterpick"912 "github.com/FelineStateMachine/012/internal/ui/findbar"1013 "github.com/FelineStateMachine/012/internal/ui/lineedit"1114 "github.com/FelineStateMachine/012/internal/ui/picker"1215 "github.com/FelineStateMachine/012/internal/ui/rules"16+ "github.com/FelineStateMachine/012/internal/ui/shortcuts"17+ "github.com/FelineStateMachine/012/internal/ui/sortbar"18+ "github.com/FelineStateMachine/012/internal/ui/suggest"1319 "github.com/FelineStateMachine/012/internal/ui/theme"1420 "github.com/FelineStateMachine/012/internal/ui/themepicker"1521 )
@@ -28,6 +34,11 @@ var (
2834 _ findbar.Host = host{}2935 _ themepicker.Host = host{}3036 _ rules.Host = host{}37+ _ choicebar.Host = host{}38+ _ sortbar.Host = host{}39+ _ filterpick.Host = host{}40+ _ shortcuts.Host = host{}41+ _ suggest.Host = host{}3142 )3243 3344 // The in-package hosts the model implements itself.
@@ -35,6 +46,9 @@ var (
3546 _ menuHost = (*Model)(nil)3647 _ pivotHost = (*Model)(nil)3748 _ macrosHost = (*Model)(nil)49+ _ namesHost = (*Model)(nil)50+ _ promptHost = (*Model)(nil)51+ _ chartHost = (*Model)(nil)3852 )3953 4054 // Components that stay in package ui are handed interfaces of their own
@@ -53,8 +67,8 @@ func (m *Model) syncChanged() { m.changed = m.sheet.StateID() != m.saved }
5367 // pickValues opens a filter's values list titled title at screen column5468 // x; apply gets the criteria chosen, and cancel runs after Esc.5569 func (m *Model) pickValues(title string, x int, values []sheet.FilterValue, cond sheet.Condition, apply func(sheet.Criteria), cancel func()) {56- fp := m.openValuesPicker(title, x, values, cond, func(_ *Model, cr sheet.Criteria) { apply(cr) })57- fp.onCancel = func(*Model) { cancel() }70+ fp := m.openValuesPicker(title, x, values, cond, apply)71+ fp.OnCancel = cancel5872 }5973 6074 // pointRange asks for a range on the context line; done gets it, and
@@ -64,7 +78,7 @@ func (m *Model) pointRange(label string, done func(sheet.Rect), cancel func()) {
6478 done(r)6579 return nil6680 })67- m.prompt.onCancel = func(*Model) { cancel() }81+ m.prompt.onCancel = cancel6882 }6983 7084 // askText asks for text on the context line, starting from initial; done
@@ -74,7 +88,7 @@ func (m *Model) askText(label, initial string, done func(string), cancel func())
7488 done(text)7589 return nil7690 })77- m.prompt.onCancel = func(*Model) { cancel() }91+ m.prompt.onCancel = cancel7892 }7993 8094 // What every component with a text field needs.
@@ -83,6 +97,7 @@ func (h host) Theme() *theme.Theme { return &h.m.th }
8397 func (h host) Size() (width, height int) { return h.m.width, h.m.height }8498 func (h host) Line() *lineedit.Line { return &h.m.line }8599 func (h host) Close() { h.m.closeOverlay() }100+func (h host) Locale() *locale.Locale { return h.m.locale() }86101 87102 // The picker.88103
@@ -132,9 +147,19 @@ func (h host) Book() *sheet.Workbook { return h.m.book() }
132147 func (h host) Trace() *telemetry.Trace { return h.m.spans }133148 func (h host) At() (*sheet.Sheet, sheet.Addr) { return h.m.sheet, h.m.cur }134149 func (h host) Note(msg string) { h.m.note = msg }135-func (h host) Edited() { h.m.changed = true }136150 func (h host) Show(s *sheet.Sheet, a sheet.Addr) { h.m.showSheet(s); h.m.cur = a }137151 152+// Replace runs a replacement of the find bar, recorded as Find and153+// replace answered with what it did, after the selection it started154+// from.155+func (h host) Replace(do func() string) {156+ h.m.recordFlush()157+ if answer := do(); answer != "" {158+ h.m.changed = true159+ h.m.recordDialog("edit.replace", answer)160+ }161+}162+138163 // Leave closes the find bar, keeping it for Ctrl+F and find next.139164 func (h host) Leave() {140165 if f, ok := h.m.overlay.(*findbar.Bar); ok {
M internal/ui/keyboard.go +6 -2
M internal/ui/layout_test.go +2 -1
M internal/ui/layoutfmt.go +2 -1
M internal/ui/macro.go +4 -5
M internal/ui/macro_doc_test.go +24 -0
A internal/ui/macrodialog_test.go +319 -0
@@ -0,0 +1,319 @@
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+// Dialogs recorded as the commands that open them, answered with their13+// choices, and replayed from those answers.14+15+// salesModel is a table of sales with a header row, the fixture both a16+// recording and its replay start from.17+func salesModel() *Model { return withSales(newModel()) }18+19+// wideSales is salesModel on a screen wide enough for a chart beside the20+// table.21+func wideSales() *Model { return withSales(wideModel()) }22+23+func withSales(m *Model) *Model {24+ for i, row := range [][]string{25+ {"Region", "Units", "Price"},26+ {"North", "12", "3.5"},27+ {"South", "40", "2"},28+ {"East", "7", "9"},29+ {"West", "25", "4"},30+ } {31+ for j, v := range row {32+ m.sheet.Set(addr(string(rune('A'+j))+string(rune('1'+i))), v)33+ }34+ }35+ return m36+}37+38+// recordDo records what do does as a macro named name, from wherever m39+// is, and returns its script without the header.40+func recordDo(t *testing.T, m *Model, name string, do func()) string {41+ t.Helper()42+ send(m, nil)43+ run(m, m.runCommand("macro.record"))44+ do()45+ send(m, nil)46+ run(m, m.runCommand("macro.stop"))47+ m.line.Set(name)48+ press(t, m, "<enter>")49+ m.line.Set("")50+ press(t, m, "<enter>")51+ mc, ok := m.book().Macro(name)52+ if !ok {53+ t.Fatalf("no macro %s: %q", name, m.errMsg)54+ }55+ return body(mc.Source)56+}57+58+// replays runs macro name from the recording model on a fresh fixture59+// made by fresh, and checks it leaves the same workbook.60+func replays(t *testing.T, recorded *Model, name string, fresh func() *Model) *Model {61+ t.Helper()62+ r := fresh()63+ mc, _ := recorded.book().Macro(name)64+ if err := r.book().SaveMacro("", mc, "add"); err != nil {65+ t.Fatal(err)66+ }67+ r.trustHere()68+ mc, _ = r.book().Macro(name)69+ run(r, r.runMacro(mc))70+ if r.note != "Ran "+name {71+ t.Fatalf("running %s: note %q warn %q err %q", name, r.note, r.warn, r.errMsg)72+ }73+ if got, want := fileOf(t, r), fileOf(t, recorded); got != want {74+ t.Errorf("%s replayed differently:\n%s\nrecorded:\n%s", name, got, want)75+ }76+ return r77+}78+79+func TestRecordSortBar(t *testing.T) {80+ m := salesModel()81+ got := recordDo(t, m, "Sort", func() {82+ run(m, m.runCommand("data.sort_range"))83+ press(t, m, "<right>", "<space>", "<alt+a>", "<enter>")84+ })85+ want := `run("data.sort_range", answer={"by": [{"column": "B", "order": "desc"}, {"column": "A"}], "header": True})`86+ if got != want {87+ t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)88+ }89+ if input(m, "A2") != "South" {90+ t.Fatalf("not sorted: A2 %q", input(m, "A2"))91+ }92+ replays(t, m, "Sort", salesModel)93+}94+95+func TestScriptAnswersSortBar(t *testing.T) {96+ m := salesModel()97+ script(t, m, `run("data.sort_range", answer={"by": [{"column": "C"}], "header": False})`)98+ if input(m, "A1") != "South" || input(m, "A5") != "Region" {99+ t.Fatalf("sorted with the header: A1 %q A5 %q warn %q", input(m, "A1"), input(m, "A5"), m.warn)100+ }101+ if m.overlay != nil || m.mode != modeReady {102+ t.Fatalf("left mode %v, overlay %T", m.mode, m.overlay)103+ }104+ m = salesModel()105+ script(t, m, `run("data.sort_range", answer={"by": [{"column": "Z"}]})`)106+ if !strings.Contains(m.warn, `Sort range: column "Z" isn't in A1:C5`) || m.overlay != nil {107+ t.Fatalf("warn %q overlay %T", m.warn, m.overlay)108+ }109+}110+111+func TestNamesPickerChangesAreNoted(t *testing.T) {112+ m := salesModel()113+ m.sheet.DefineName("Units", sheet.Rect{From: addr("B2"), To: addr("B5")})114+ got := recordDo(t, m, "Names", func() {115+ run(m, m.runCommand("data.named_ranges"))116+ press(t, m, "<down>", "<ctrl+d>", "<esc>")117+ })118+ if !strings.HasPrefix(got, "# Not recorded: ") || !strings.Contains(got, "Units") {119+ t.Fatalf("recorded:\n%s", got)120+ }121+}122+123+func TestRecordFilterPicker(t *testing.T) {124+ m := salesModel()125+ got := recordDo(t, m, "Filter", func() {126+ run(m, m.runCommand("data.filter"))127+ press(t, m, "<alt+down>", "<down>", "<down>", "<space>", "<enter>")128+ // The button of another column, with a condition.129+ press(t, m, "<right>")130+ click(m, m.filterButtonX(2), headerLine, 0)131+ press(t, m, "<tab>")132+ for range int(sheet.CondGreater) {133+ press(t, m, "<down>")134+ }135+ press(t, m, "3", "<enter>")136+ })137+ want := `run("data.filter")138+run("data.filter_column", answer={"column": "A", "hidden": ["North"]})139+select("B1")140+run("data.filter_column", answer={"column": "C", "condition": "gt", "value": "3"})`141+ if got != want {142+ t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)143+ }144+ if m.sheet.HiddenRows() != 2 {145+ t.Fatalf("hidden %d", m.sheet.HiddenRows())146+ }147+ replays(t, m, "Filter", salesModel)148+}149+150+func TestScriptAnswersFilterPicker(t *testing.T) {151+ m := salesModel()152+ script(t, m, `run("data.filter_column", answer={"column": "B", "hidden": ["40"]})`)153+ if !strings.Contains(m.warn, "the sheet has no filter") {154+ t.Fatalf("warn %q", m.warn)155+ }156+ script(t, m, `run("data.filter")157+run("data.filter_column", answer={"column": "D", "hidden": ["40"]})`)158+ if !strings.Contains(m.warn, "column D isn't in the filter's range A1:C5") || m.overlay != nil {159+ t.Fatalf("warn %q overlay %T", m.warn, m.overlay)160+ }161+ script(t, m, `select("B2")162+run("data.filter_column", answer={"hidden": ["40", "7"]})`)163+ if m.sheet.HiddenRows() != 2 || m.note != "Ran S" {164+ t.Fatalf("hidden %d note %q warn %q", m.sheet.HiddenRows(), m.note, m.warn)165+ }166+}167+168+func TestRecordChartEditorAndDrags(t *testing.T) {169+ m := wideSales()170+ got := recordDo(t, m, "Chart", func() {171+ run(m, m.runCommand("insert.chart"))172+ press(t, m, "3", "t", "Units", "<enter>", "<enter>")173+ // Moved a step at a time, then dragged by the corner: one call.174+ press(t, m, "<down>", "<right>", "<right>")175+ c := m.sheet.Charts()[0]176+ x, y := m.chartScreen(c)177+ send(m, tea.MouseClickMsg{X: x + c.W - 1, Y: y + c.H - 1, Button: tea.MouseLeft})178+ send(m, tea.MouseMotionMsg{X: x + 29, Y: y + 11, Button: tea.MouseLeft})179+ send(m, tea.MouseReleaseMsg{X: x + 29, Y: y + 11, Button: tea.MouseLeft})180+ // Edited again: the header row off.181+ press(t, m, "<enter>", "h", "<enter>", "<esc>")182+ })183+ want := `run("insert.chart", answer={"type": "line", "data": "A1:C5", "at": "D1", "width": 48, "height": 16, "header": True, "labels": True, "title": "Units"})184+run("chart.edit", answer={"chart": 1, "at": "F2", "width": 30, "height": 12})185+run("chart.edit", answer={"chart": 1, "header": False})`186+ if got != want {187+ t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)188+ }189+ replays(t, m, "Chart", wideSales)190+}191+192+func TestScriptAnswersChartCommands(t *testing.T) {193+ m := wideSales()194+ script(t, m, `select("A1:B5")195+run("insert.chart", answer={"type": "pie", "title": "Units"})196+run("chart.edit", answer={"chart": 1, "at": "H2", "legend": "none"})`)197+ cs := m.sheet.Charts()198+ if len(cs) != 1 || cs[0].Type != sheet.ChartPie || cs[0].Title != "Units" || cs[0].At.String() != "H2" || cs[0].Legend != sheet.LegendNone || cs[0].Data.String() != "A1:B5" {199+ t.Fatalf("charts %+v, warn %q", cs, m.warn)200+ }201+ if m.overlay != nil || m.mode != modeReady {202+ t.Fatalf("left mode %v overlay %T", m.mode, m.overlay)203+ }204+ script(t, m, `run("chart.edit", answer={"chart": 2, "at": "A1"})`)205+ if !strings.Contains(m.warn, "there's no chart 2 on Sheet1: it has 1") {206+ t.Fatalf("warn %q", m.warn)207+ }208+ script(t, m, `run("chart.edit", answer={"type": "blimp"})`)209+ if !strings.Contains(m.warn, `unknown chart type "blimp"`) || m.overlay != nil {210+ t.Fatalf("warn %q overlay %T", m.warn, m.overlay)211+ }212+}213+214+func TestRecordFindAndReplace(t *testing.T) {215+ m := salesModel()216+ got := recordDo(t, m, "Replace", func() {217+ press(t, m, "<ctrl+h>", "th", "<tab>", "TH", "<enter>")218+ press(t, m, "<alt+c>", "<ctrl+enter>", "<esc>")219+ press(t, m, "X", "<enter>")220+ })221+ want := `select("A2")222+run("edit.replace", answer={"find": "th", "replace": "TH", "cell": "A2"})223+run("edit.replace", answer={"find": "th", "replace": "TH", "matchCase": True})224+enter("X")225+select("A4")`226+ if got != want {227+ t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)228+ }229+ if input(m, "A2") != "NorTH" || input(m, "A3") != "X" {230+ t.Fatalf("A2 %q A3 %q", input(m, "A2"), input(m, "A3"))231+ }232+ replays(t, m, "Replace", salesModel)233+}234+235+// salesRules is salesModel with two conditional formats on B2:B5 and a236+// dropdown on A2:A5.237+func salesRules() *Model {238+ m := salesModel()239+ for _, line := range []string{`{"ranges":"B2:B5","condition":"gt","values":["20"],"fill":"green"}`, `{"ranges":"B2:B5","condition":"lt","values":["10"],"fill":"red"}`} {240+ f, _ := sheet.ParseCondFormat(line)241+ m.sheet.AddCondFormat(f)242+ }243+ v, _ := sheet.ParseValidation(`{"ranges":"A2:A5","criteria":"list","items":["North","South","East","West"]}`)244+ m.sheet.AddValidation(v)245+ return m246+}247+248+func TestRecordRulesPanelEdits(t *testing.T) {249+ m := salesRules()250+ got := recordDo(t, m, "Rules", func() {251+ run(m, m.runCommand("format.conditional"))252+ press(t, m, "<down>", "<shift+down>") // green after red253+ press(t, m, "<enter>", "<down>", "<down>", "<down>", "<backspace>", "<backspace>", "30", "<enter>", "<esc>")254+ run(m, m.runCommand("data.validation"))255+ press(t, m, "<down>", "<delete>", "<esc>")256+ })257+ want := `run("format.conditional_move", answer={"rule": 1, "to": 2})258+run("format.conditional_set", answer={"rule": 2, "ranges": "B2:B5", "condition": "gt", "values": ["30"], "fill": "green"})259+run("data.validation_remove", answer=1)`260+ if got != want {261+ t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)262+ }263+ replays(t, m, "Rules", salesRules)264+}265+266+func TestScriptsChangeRules(t *testing.T) {267+ m := salesRules()268+ script(t, m, `run("format.conditional_set", answer={"rule": 1, "ranges": "C2:C5", "condition": "empty", "fill": "yellow"})269+run("data.validation_set", answer={"rule": 1, "ranges": "A2:A9", "criteria": "list", "items": ["N", "S"]})270+run("format.conditional_move", answer={"rule": 2, "to": 1})`)271+ fs, vs := m.sheet.CondFormats(), m.sheet.Validations()272+ if len(fs) != 2 || fs[1].Ranges[0].String() != "C2:C5" || vs[0].Ranges[0].String() != "A2:A9" || m.note != "Ran S" {273+ t.Fatalf("formats %+v validations %+v warn %q", fs, vs, m.warn)274+ }275+ for src, want := range map[string]string{276+ `run("format.conditional_remove", answer=3)`: "there's no rule 3: the sheet has 2",277+ `run("format.conditional_move", answer={"rule": 1})`: `give "to", a place from 1 to 2`,278+ `run("data.validation_set", answer={"ranges": "A1"})`: "give the rule's number",279+ `run("format.conditional_set", answer={"rule": 1, "ranges": "?"})`: `invalid ranges "?"`,280+ } {281+ script(t, m, src)282+ if !strings.Contains(m.warn, want) {283+ t.Errorf("%s: warn %q, want %q", src, m.warn, want)284+ }285+ }286+}287+288+func TestRecordPivotEditor(t *testing.T) {289+ m := wideSales()290+ got := recordDo(t, m, "Pivot", func() {291+ run(m, m.runCommand("data.pivot"))292+ press(t, m, "<space>", "reg", "<enter>")293+ press(t, m, "<down>", "<down>", "<space>", "units", "<enter>", "<enter>")294+ // Edited again: the grand total row off. Unchanged, it records295+ // nothing.296+ run(m, m.runCommand("data.pivot_edit"))297+ press(t, m, "<enter>")298+ run(m, m.runCommand("data.pivot_edit"))299+ press(t, m, "<end>", "<up>", "<space>", "<enter>")300+ })301+ want := `run("data.pivot", answer={"source": "Sheet1!A1:C5", "rows": [{"column": "A"}], "values": [{"column": "B", "summarize": "sum"}], "rowTotals": True, "columnTotals": True})302+run("data.pivot_edit", answer={"source": "Sheet1!A1:C5", "rows": [{"column": "A"}], "values": [{"column": "B", "summarize": "sum"}], "rowTotals": False, "columnTotals": True})`303+ if got != want {304+ t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)305+ }306+ replays(t, m, "Pivot", wideSales)307+}308+309+func TestScriptAnswersPivotCommands(t *testing.T) {310+ m := wideSales()311+ script(t, m, `run("data.pivot_edit", answer={"source": "Sheet1!A1:C5"})`)312+ if !strings.Contains(m.warn, "Sheet1 has no pivot table") {313+ t.Fatalf("warn %q", m.warn)314+ }315+ script(t, m, `run("data.pivot", answer={"source": "Sheet1!A1:C5", "rows": [{"column": "A"}], "values": [{"column": "C", "summarize": "max"}]})`)316+ if m.sheet.Name() != "Pivot Table 1" || shows(m, "B1") != "MAX of Price" || m.overlay != nil {317+ t.Fatalf("on %s, B1 %q, overlay %T, warn %q", m.sheet.Name(), shows(m, "B1"), m.overlay, m.warn)318+ }319+}
M internal/ui/macrohostnav.go +30 -9
@@ -3,11 +3,12 @@ package ui
33 import (44 "errors"55 "fmt"6- "slices"6+ "log/slog"77 "strconv"88 "strings"99 1010 "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/choicebar"1112 "github.com/FelineStateMachine/012/internal/ui/picker"1213 )1314
@@ -288,6 +289,8 @@ func (h scriptHost) Run(id string, answer *string) error {
288289 return fmt.Errorf("no command %q; the palette (Ctrl+K) lists them, and docs/reference/macro-api.md how to find ids", id)289290 case c.macro == macroNever:290291 return fmt.Errorf("%s (%s) can't run in a macro", c.title, id)292+ case c.answer != nil && answer != nil:293+ return h.answered(c, *answer)291294 case !c.available(m):292295 return fmt.Errorf("%s can't run now", c.title)293296 }
@@ -295,6 +298,23 @@ func (h scriptHost) Run(id string, answer *string) error {
295298 return h.answer(c, answer)296299 }297300 301+// answered runs a command that opens a dialog with the dialog's302+// choices, as a recording writes them.303+func (h scriptHost) answered(c *command, text string) error {304+ m := h.m305+ span := m.spans.Start("command", slog.String("id", c.id)) // as runCommand times commands306+ cmd, err := c.answer(m, text)307+ span.End()308+ m.macros.cmds = append(m.macros.cmds, cmd)309+ if err != nil {310+ if m.overlay != nil {311+ m.closeOverlay()312+ }313+ return fmt.Errorf("%s: %w", c.title, err)314+ }315+ return h.answer(c, nil)316+}317+298318 // answer answers the question a command left open, or backs out of it299319 // and reports that it needed one.300320 func (h scriptHost) answer(c *command, answer *string) error {
@@ -332,17 +352,18 @@ func (h scriptHost) choose(c *command, answer *string) error {
332352 if p, ok := m.overlay.(*picker.Picker); ok && p.Answers {333353 return h.pickAnswer(c, p, answer)334354 }335- bar, ok := m.overlay.(*choiceBar)336- if !ok {355+ bar, ok := m.overlay.(*choicebar.Bar)356+ switch {357+ case !ok && c.answer != nil:358+ m.closeOverlay()359+ return fmt.Errorf("%s opens a dialog: give run(%q, answer={...}) with its choices, as a recording writes them", c.title, c.id)360+ case !ok:337361 m.closeOverlay()338362 return fmt.Errorf("%s opens a dialog; it can't run in a macro", c.title)339363 }340364 if answer != nil {341- i := slices.IndexFunc(bar.choices, func(ch choice) bool {342- return strings.EqualFold(ch.key, *answer) || strings.EqualFold(ch.label, *answer)343- })344- if i >= 0 {345- m.macros.cmds = append(m.macros.cmds, bar.choose(m, bar.choices[i]))365+ if i := bar.Find(*answer); i >= 0 {366+ m.macros.cmds = append(m.macros.cmds, bar.Choose(i))346367 if m.mode == modeError {347368 return h.failure()348369 }
@@ -350,7 +371,7 @@ func (h scriptHost) choose(c *command, answer *string) error {
350371 }351372 }352373 m.closeOverlay()353- return fmt.Errorf("%s asks %q: give run(%q, answer=...) with one of its keys", c.title, bar.msg, c.id)374+ return fmt.Errorf("%s asks %q: give run(%q, answer=...) with one of its keys", c.title, bar.Msg, c.id)354375 }355376 356377 // pickAnswer answers a picker that asks a command's question with the
M internal/ui/macrorec.go +30 -7
@@ -8,6 +8,7 @@ import (
88 99 "github.com/FelineStateMachine/012/internal/macro"1010 "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/choicebar"1112 "github.com/FelineStateMachine/012/internal/ui/picker"1213 )1314
@@ -20,6 +21,10 @@ import (
2021 // them, with the answer to the question they ask (a width, a name, a2122 // confirmation) once it's given;2223 // - entries, when they're stored (enter), and pastes of text;24+// - dialogs (the sort bar, the filter picker, find and replace, the25+// chart and pivot editors, the rules panel) once they've acted, as26+// the command that opens them answered with the choices made27+// (recordDialog), and charts moved or resized;2328 // - the fill handle, column borders dragged or fitted, and tabs dragged;2429 // - the selection, not the keys or clicks that made it: just before2530 // something is recorded, the selection it acts on is, as select() with
@@ -29,10 +34,9 @@ import (
2934 //3035 // With relative references, Ctrl+arrows, Home, Ctrl+Home and Ctrl+End are3136 // recorded as jump(), since where they land depends on the data. Anything32-// else that changes the workbook (a dialog such as sorting by several33-// columns, the filter picker, dragging a chart, undo) becomes a comment34-// saying it wasn't recorded, so the script never silently differs from35-// what was done.37+// else that changes the workbook (undo, the named ranges picker) becomes38+// a comment saying it wasn't recorded, so the script never silently39+// differs from what was done.3640 3741 // recorder is a recording in progress.3842 type recorder struct {
@@ -52,8 +56,14 @@ type selState struct {
5256 whole wholeKind5357 }5458 59+// selState is the selection now; the far corner counts only while a60+// range is selected.5561 func (g *grid) selState() selState {56- return selState{g.sheet, g.cur, g.ext, g.selecting, g.whole}62+ ext := g.ext63+ if !g.selecting {64+ ext = g.cur65+ }66+ return selState{g.sheet, g.cur, ext, g.selecting, g.whole}5767 }5868 5969 func (s selState) rect() sheet.Rect {
@@ -131,7 +141,7 @@ func (m *Model) recordingCommand(c *command) tea.Cmd {
131141 r.depth++132142 cmd := c.run(m)133143 r.depth--134- _, choosing := m.overlay.(*choiceBar)144+ _, choosing := m.overlay.(*choicebar.Bar)135145 if p, ok := m.overlay.(*picker.Picker); ok && p.Answers {136146 choosing = true137147 }
@@ -139,7 +149,7 @@ func (m *Model) recordingCommand(c *command) tea.Cmd {
139149 case m.mode == modePrompt || choosing:140150 r.pending = c.id // recorded once answered141151 case m.overlay != nil:142- // A dialog or Picker: what it does isn't recorded (see observe).152+ // A dialog records what it does when it's done (recordDialog).143153 case m.mode != modeError:144154 r.add(m, macro.Call("run", c.id))145155 }
@@ -160,6 +170,19 @@ func (m *Model) recordAnswer(answer string, cancelled bool) {
160170 }161171 }162172 173+// recordDialog records what a dialog did once it has, as its command174+// answered with the dialog's choices (see command.answer), which a175+// replay makes again. The selection it acted on was recorded when its176+// command ran, or by recordFlush when something else opened it: what the177+// command did to the selection since (showing a new sheet) is its own.178+func (m *Model) recordDialog(id, answer string) {179+ r := m.rec180+ if r == nil || r.depth > 0 || m.mode == modeError {181+ return182+ }183+ r.add(m, macro.Call("run", id).With("answer", macro.JSON(answer)))184+}185+163186 // recordEntry records an entry stored in the active cell, or in every164187 // selected cell with fill (Ctrl+Enter).165188 func (m *Model) recordEntry(input string, fill bool) {
M internal/ui/macroscript_test.go +6 -5
M internal/ui/model.go +4 -4
M internal/ui/mouse.go +1 -0
M internal/ui/names.go +39 -11
@@ -7,6 +7,7 @@ import (
77 88 "github.com/FelineStateMachine/012/internal/sheet"99 "github.com/FelineStateMachine/012/internal/ui/picker"10+ "github.com/FelineStateMachine/012/internal/ui/theme"1011 )1112 1213 // Named ranges, as Sheets' Data > Named ranges: a picker lists them with
@@ -28,9 +29,30 @@ func init() {
2829 )2930 }3031 32+// namesHost is what the named ranges picker acts on. The model33+// implements it. The picker stays in package ui: it is a few keys over a34+// picker, and what they do (naming, renaming and pointing at a range on35+// the context line, going to one) are the model's prompts and36+// selection, which open the picker again.37+type namesHost interface {38+ styles() *theme.Theme39+ sheetShown() *sheet.Sheet40+ closeOverlay()41+ // namesItems are the picker's rows: "Add a range" for sel, then the42+ // named ranges.43+ namesItems(sel sheet.Rect) []picker.Item44+ // editName asks for a new name and range for n, then opens the45+ // picker again with sel.46+ editName(n sheet.Name, sel sheet.Rect)47+ // nameChanged follows a named range changed from the picker: the48+ // file is modified, and a macro being recorded notes what it can't49+ // replay.50+ nameChanged()51+}52+3153 // namesPicker is the picker of named ranges with its extra keys.3254 type namesPicker struct {33- m *Model // the model it acts on55+ m namesHost // the model, through what the picker needs of it3456 *picker.Picker3557 sel sheet.Rect // the selection when the picker opened, for "Add a range"3658 msg string // feedback on the last action, e.g. a deletion
@@ -38,14 +60,19 @@ type namesPicker struct {
3860 3961 // openNames opens the picker; sel is what "Add a range" names.4062 func (m *Model) openNames(sel sheet.Rect) {41- p := m.newPicker("Named ranges", "Type a name", 60, namesItems(m, sel))63+ p := m.newPicker("Named ranges", "Type a name", 60, m.namesItems(sel))4264 p.Action = "go to"4365 m.clearSelection()4466 m.openOverlay(&namesPicker{m: m, Picker: p, sel: sel})4567 }4668 69+func (m *Model) nameChanged() {70+ m.syncChanged()71+ m.noteUnrecorded()72+}73+4774 // namesItems lists "Add a range" and then every named range.48-func namesItems(m *Model, sel sheet.Rect) []picker.Item {75+func (m *Model) namesItems(sel sheet.Rect) []picker.Item {4976 add := "+ Add a range"5077 items := []picker.Item{{5178 Title: add, Name: len(add), Detail: sel.String(), Desc: "Name " + sel.String() + ", to use the name in formulas",
@@ -84,11 +111,11 @@ func namesItems(m *Model, sel sheet.Rect) []picker.Item {
84111 }85112 86113 // current is the named range highlighted in the picker, if any.87-func (p *namesPicker) current(m *Model) (sheet.Name, bool) {114+func (p *namesPicker) current(m namesHost) (sheet.Name, bool) {88115 if p.Picker.Sel >= len(p.Shown()) {89116 return sheet.Name{}, false90117 }91- return m.sheet.LookupName(p.Shown()[p.Picker.Sel].Item.Title)118+ return m.sheetShown().LookupName(p.Shown()[p.Picker.Sel].Item.Title)92119 }93120 94121 func (p *namesPicker) Key(k tea.KeyPressMsg) tea.Cmd {
@@ -103,10 +130,10 @@ func (p *namesPicker) Key(k tea.KeyPressMsg) tea.Cmd {
103130 return nil104131 case "ctrl+d":105132 if n, ok := p.current(m); ok {106- m.sheet.DeleteName(n.Name)107- m.changed = true133+ m.sheetShown().DeleteName(n.Name)134+ m.nameChanged()108135 p.msg = "Deleted " + n.Name + "; Ctrl+Z brings it back"109- p.Items = namesItems(m, p.sel)136+ p.Items = m.namesItems(p.sel)110137 sel := p.Picker.Sel111138 p.Changed()112139 p.Picker.Sel = max(min(sel, len(p.Shown())-1), 0)
@@ -118,9 +145,9 @@ func (p *namesPicker) Key(k tea.KeyPressMsg) tea.Cmd {
118145 119146 func (p *namesPicker) Status() (string, string) {120147 m := p.m121- keys := m.th.KeyHints("Enter", "go to", "F2", "edit", "Ctrl+D", "delete", "Esc", "close")148+ keys := m.styles().KeyHints("Enter", "go to", "F2", "edit", "Ctrl+D", "delete", "Esc", "close")122149 if _, ok := p.current(m); !ok {123- keys = m.th.KeyHints("Enter", "add", "Esc", "close")150+ keys = m.styles().KeyHints("Enter", "add", "Esc", "close")124151 }125152 if p.msg != "" {126153 return p.msg, keys
@@ -139,6 +166,7 @@ func (m *Model) defineName(r sheet.Rect, back bool) {
139166 }140167 m.changed = true141168 if back {169+ m.noteUnrecorded() // named from the picker, not as the command142170 m.openNames(r)143171 return nil144172 }
@@ -169,7 +197,7 @@ func (m *Model) editName(n sheet.Name, sel sheet.Rect) {
169197 m.fail(err.Error())170198 return nil171199 }172- m.changed = true200+ m.nameChanged()173201 m.openNames(sel)174202 return nil175203 })
M internal/ui/overlay.go +2 -2
M internal/ui/overlay/overlay.go +1 -0
M internal/ui/panel.go +4 -3
@@ -16,6 +16,7 @@ import (
1616 "github.com/FelineStateMachine/012/internal/sheet"1717 "github.com/FelineStateMachine/012/internal/telemetry"1818 "github.com/FelineStateMachine/012/internal/ui/overlay"19+ "github.com/FelineStateMachine/012/internal/ui/suggest"1920 "github.com/FelineStateMachine/012/internal/ui/tabstrip"2021 "github.com/FelineStateMachine/012/internal/ui/theme"2122 )
@@ -252,11 +253,11 @@ func (m *Model) contextLineText() string {
252253 case m.mode == modePoint:253254 prefix := []rune(m.entry.prefix)254255 var ok bool255- if left, right, ok = signatureLine(&m.th, m.width, m.storedFormula(prefix), len(prefix), m.locale().ArgSep(), m.th.KeyHints("Shift+arrows", "range", "Esc", "back")); !ok {256+ if left, right, ok = suggest.SignatureLine(&m.th, m.width, m.storedFormula(prefix), len(prefix), m.locale().ArgSep(), m.th.KeyHints("Shift+arrows", "range", "Esc", "back")); !ok {256257 left = m.th.KeyHints("Arrows", "pick a cell", "Shift+arrows", "pick a range", "Enter", "accept", "Esc", "back")257258 }258259 case (m.mode == modeEnter || m.mode == modeEdit) && m.line.IsFormula() && m.inFunction():259- left, right, _ = signatureLine(&m.th, m.width, m.storedFormula(m.line.Buf), m.line.Pos, m.locale().ArgSep(), m.th.KeyHints("Enter", "accept", "Esc", "cancel"))260+ left, right, _ = suggest.SignatureLine(&m.th, m.width, m.storedFormula(m.line.Buf), m.line.Pos, m.locale().ArgSep(), m.th.KeyHints("Enter", "accept", "Esc", "cancel"))260261 case m.mode == modeEnter && m.line.IsFormula():261262 left = m.th.KeyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "pick cells after an operator", "Esc", "cancel")262263 case m.mode == modeEnter:
@@ -320,7 +321,7 @@ func (m *Model) statusLayout() (string, []tabstrip.Span) {
320321 // floatingStatus is what the highlighted item of the open overlay or the321322 // formula suggestions does, and the keys that apply.322323 func (m *Model) floatingStatus() (string, bool) {323- desc, keys, floating := m.entry.assist.status(m)324+ desc, keys, floating := m.entry.assist.Status(m.host())324325 if m.overlay != nil {325326 desc, keys = m.overlay.Status()326327 floating = true
M internal/ui/pivot.go +17 -1
M internal/ui/pivot_test.go +2 -1
M internal/ui/pivotactions.go +2 -1
M internal/ui/pivoteditor.go +39 -7
@@ -23,10 +23,11 @@ import (
2323 // it. Enter keeps the changes; Esc undoes them, removing a pivot just2424 // created.2525 type pivotEditor struct {26- m pivotHost // the model, through what the editor needs of it27- start int // the workbook's state before editing, to undo back to28- back *sheet.Sheet // for a new pivot, the sheet it summarizes, shown again on Esc29- msg string // why the last change was refused, for the status line26+ m pivotHost // the model, through what the editor needs of it27+ start int // the workbook's state before editing, to undo back to28+ back *sheet.Sheet // for a new pivot, the sheet it summarizes, shown again on Esc29+ before string // the pivot when the editor opened, as a macro answers it30+ msg string // why the last change was refused, for the status line3031 overlay.List3132 }3233
@@ -55,6 +56,9 @@ type pivotHost interface {
5556 pointRange(label string, done func(sheet.Rect), cancel func())5657 // askText asks for text, such as a value's name.5758 askText(label, initial string, done func(string), cancel func())59+ // recordDialog records the pivot made, as the command id answered60+ // with its definition.61+ recordDialog(id, answer string)5862 }5963 6064 // Sections of the editor, in order.
@@ -97,11 +101,13 @@ const pivotEditorID = "pivot"
97101 98102 // openPivotEditor edits the shown sheet's pivot. start is the state Esc99103 // returns to; back, for a pivot just created, the sheet to show then.100-func (m *Model) openPivotEditor(start int, back *sheet.Sheet) {104+func (m *Model) openPivotEditor(start int, back *sheet.Sheet) *pivotEditor {101105 e := &pivotEditor{m: m, start: start, back: back}106+ e.before = e.pivot(m).JSON()102107 e.Sel = 2 // the Rows section103108 m.clearSelection()104109 m.openOverlay(e)110+ return e105111 }106112 107113 func (e *pivotEditor) Indicator() string { return "PIVOT" }
@@ -183,7 +189,7 @@ func (e *pivotEditor) Key(k tea.KeyPressMsg) tea.Cmd {
183189 it := e.current(m)184190 switch key := k.String(); key {185191 case "enter":186- m.closeOverlay()192+ e.keep(m)187193 case "esc":188194 e.cancel(m)189195 case "up", "ctrl+p", "shift+tab":
@@ -225,6 +231,32 @@ func (e *pivotEditor) cancel(m pivotHost) {
225231 m.syncChanged()226232 }227233 234+// keep closes the editor keeping the changes, and records the pivot235+// made: a new one as Data > Pivot table, a changed one as Edit pivot236+// table, answered with its definition.237+func (e *pivotEditor) keep(m pivotHost) {238+ m.closeOverlay()239+ id, now := "data.pivot_edit", e.pivot(m).JSON()240+ if e.back != nil {241+ id = "data.pivot"242+ } else if now == e.before {243+ return244+ }245+ m.recordDialog(id, now)246+}247+248+// answer makes the pivot a macro's answer defines, as a line of the file249+// writes it, and closes the editor.250+func (e *pivotEditor) answer(m pivotHost, text string) error {251+ p, err := sheet.ParsePivot(text)252+ if err == nil {253+ err = m.sheetShown().SetPivot(p, "")254+ }255+ m.syncChanged()256+ m.closeOverlay()257+ return err258+}259+228260 // reopen returns to the editor after a picker or prompt.229261 func (e *pivotEditor) reopen() { e.m.openOverlay(e) }230262
@@ -232,7 +264,7 @@ func (e *pivotEditor) Mouse(ev overlay.MouseEvent) tea.Cmd {
232264 m := e.m233265 if ev.Box != pivotEditorID {234266 if ev.Kind == overlay.MousePress {235- m.closeOverlay() // a click elsewhere keeps the changes267+ e.keep(m) // a click elsewhere keeps the changes236268 }237269 return nil238270 }
M internal/ui/prefs.go +1 -1
M internal/ui/prompt.go +86 -47
@@ -8,6 +8,8 @@ import (
88 tea "charm.land/bubbletea/v2"99 1010 "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/lineedit"12+ "github.com/FelineStateMachine/012/internal/ui/theme"1113 )1214 1315 // Prompts are questions on the context line, answered by typing (a file
@@ -34,18 +36,49 @@ type prompt struct {
3436 secret bool // the answer is shown masked, e.g. an API key3537 breaks bool // Shift+Enter and Alt+Enter type a line break, shown as noteBreak3638 typing bool // range prompts: the user is typing instead of pointing37- onText func(m *Model, text string) tea.Cmd38- onRange func(m *Model, r sheet.Rect) tea.Cmd39- onCancel func(m *Model)39+ onText func(text string) tea.Cmd40+ onRange func(r sheet.Rect) tea.Cmd41+ onCancel func()4042 // resize previews a size prompt's answer, from 1 to maxSize.41- resize func(m *Model, n int)43+ resize func(n int)4244 maxSize int4345 files []string // the file list shown by File Open4446 }4547 48+// promptHost is what a prompt acts on. The model implements it. Prompts49+// stay in package ui because a prompt is one of the model's modes rather50+// than an overlay: a range is pointed at with the grid's own movement51+// and drawn by the grid, and the answer goes to a callback over the52+// model.53+type promptHost interface {54+ styles() *theme.Theme55+ // editLine is the shared edit line the answer is typed in.56+ editLine() *lineedit.Line57+ // pointer is the range being pointed at, which the grid draws.58+ pointer() *pointer59+ // pointMoveKey moves or stretches the pointer as the grid's keys60+ // do, and reports whether key was one of them.61+ pointMoveKey(key string) bool62+ closePrompt()63+ fail(msg string)64+ // recordAnswer tells a macro recording the answer to the question65+ // of the command that asked.66+ recordAnswer(answer string, cancelled bool)67+ // cancelQuit stops Quit waiting on the save a cancelled prompt was68+ // part of: cancelling Save as cancels Save and quit.69+ cancelQuit()70+}71+72+func (m *Model) editLine() *lineedit.Line { return &m.line }73+func (m *Model) pointer() *pointer { return &m.point }74+func (m *Model) cancelQuit() { m.quitAfterSave = false }75+76+// pointing reports whether a range prompt is taking the arrows.77+func (p *prompt) pointing() bool { return p.kind == promptRange && !p.typing }78+4679 // pointing reports whether a range prompt is taking the arrows.4780 func (m *Model) pointing() bool {48- return m.mode == modePrompt && m.prompt.kind == promptRange && !m.prompt.typing81+ return m.mode == modePrompt && m.prompt.pointing()4982 }5083 5184 func (m *Model) openPrompt(p *prompt, initial string) {
@@ -58,13 +91,15 @@ func (m *Model) openPrompt(p *prompt, initial string) {
5891 5992 // openText asks for text, starting from initial, which typing replaces.6093 func (m *Model) openText(label, initial string, onText func(*Model, string) tea.Cmd) {61- m.openPrompt(&prompt{kind: promptText, label: label, indicator: "EDIT", fresh: true, onText: onText}, initial)94+ done := func(text string) tea.Cmd { return onText(m, text) }95+ m.openPrompt(&prompt{kind: promptText, label: label, indicator: "EDIT", fresh: true, onText: done}, initial)6296 }6397 6498 // openRange asks for a range, starting from the current selection.6599 func (m *Model) openRange(label string, onRange func(*Model, sheet.Rect) tea.Cmd) {66100 r := m.selection()67- m.openPrompt(&prompt{kind: promptRange, label: label, indicator: "POINT", onRange: onRange}, "")101+ done := func(r sheet.Rect) tea.Cmd { return onRange(m, r) }102+ m.openPrompt(&prompt{kind: promptRange, label: label, indicator: "POINT", onRange: done}, "")68103 m.point = pointer{anchor: r.From, at: r.To, anchored: r.From != r.To}69104 }70105
@@ -74,7 +109,7 @@ func (m *Model) closePrompt() {
74109 }75110 76111 // key handles a key while the prompt is open.77-func (p *prompt) key(m *Model, k tea.KeyPressMsg) tea.Cmd {112+func (p *prompt) key(m promptHost, k tea.KeyPressMsg) tea.Cmd {78113 switch key := k.String(); {79114 case key == "esc":80115 p.cancel(m)
@@ -82,8 +117,8 @@ func (p *prompt) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
82117 return p.accept(m)83118 case p.breaks && (key == "shift+enter" || key == "alt+enter"):84119 p.fresh = false85- m.line.Insert(noteBreak)86- case m.pointing():120+ m.editLine().Insert(noteBreak)121+ case p.pointing():87122 p.pointKey(m, k)88123 case p.kind == promptWidth && (key == "left" || key == "right"):89124 p.stepWidth(m, key)
@@ -94,31 +129,31 @@ func (p *prompt) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
94129 }95130 96131 // cancel backs out: of a range being stretched, or of the prompt.97-func (p *prompt) cancel(m *Model) {98- if m.pointing() && m.point.anchored {99- m.point.anchored = false132+func (p *prompt) cancel(m promptHost) {133+ if pt := m.pointer(); p.pointing() && pt.anchored {134+ pt.anchored = false100135 return101136 }102- m.quitAfterSave = false // cancelling Save as cancels Save and quit137+ m.cancelQuit()103138 // Closed first, so onCancel can return to what opened the prompt, as104139 // the chart editor does.105140 m.closePrompt()106141 if p.onCancel != nil {107- p.onCancel(m)142+ p.onCancel()108143 }109144 m.recordAnswer("", true)110145 }111146 112147 // accept closes the prompt and acts on the answer.113-func (p *prompt) accept(m *Model) tea.Cmd {114- text := strings.TrimSpace(m.line.Text())148+func (p *prompt) accept(m promptHost) tea.Cmd {149+ text := strings.TrimSpace(m.editLine().Text())115150 m.closePrompt()116151 if p.kind != promptRange {117- cmd := p.onText(m, text)152+ cmd := p.onText(text)118153 m.recordAnswer(text, false)119154 return cmd120155 }121- r := m.point.rect()156+ r := m.pointer().rect()122157 if p.typing {123158 var ok bool124159 if r, ok = sheet.ParseRange(text); !ok {
@@ -126,78 +161,82 @@ func (p *prompt) accept(m *Model) tea.Cmd {
126161 return nil127162 }128163 }129- cmd := p.onRange(m, r)164+ cmd := p.onRange(r)130165 m.recordAnswer(r.String(), false)131166 return cmd132167 }133168 134169 // pointKey moves or stretches the range being pointed at; typing switches135170 // to typing the range instead.136-func (p *prompt) pointKey(m *Model, k tea.KeyPressMsg) {171+func (p *prompt) pointKey(m promptHost, k tea.KeyPressMsg) {137172 key := k.String()138- switch {173+ switch pt := m.pointer(); {139174 case m.pointMoveKey(key):140175 case key == ":":141- m.point.anchor, m.point.anchored = m.point.at, true176+ pt.anchor, pt.anchored = pt.at, true142177 case typed(k) != "":143178 p.typing = true144- m.line.Insert(typed(k))179+ m.editLine().Insert(typed(k))145180 }146181 }147182 148183 // stepWidth changes a width by one, previewing it live.149-func (p *prompt) stepWidth(m *Model, key string) {150- w, _ := strconv.Atoi(m.line.Text())184+func (p *prompt) stepWidth(m promptHost, key string) {185+ line := m.editLine()186+ w, _ := strconv.Atoi(line.Text())151187 if key == "left" {152188 w--153189 } else {154190 w++155191 }156192 w = clamp(w, 1, p.maxSize)157- m.line.Clear()193+ line.Clear()158194 p.fresh = false159- m.line.Insert(strconv.Itoa(w))160- p.resize(m, w)195+ line.Insert(strconv.Itoa(w))196+ p.resize(w)161197 }162198 163199 // typeKey edits the answer; the first key typed replaces the default.164-func (p *prompt) typeKey(m *Model, k tea.KeyPressMsg) {200+func (p *prompt) typeKey(m promptHost, k tea.KeyPressMsg) {201+ line := m.editLine()165202 if p.fresh && typed(k) != "" {166- m.line.Clear()203+ line.Clear()167204 }168205 p.fresh = false169206 if p.kind == promptWidth && !isDigits(typed(k)) {170207 return171208 }172- m.line.Key(k)209+ line.Key(k)173210 }174211 175212 // paste types pasted text into the answer.176-func (p *prompt) paste(m *Model, text string) {213+func (p *prompt) paste(m promptHost, text string) {214+ line := m.editLine()177215 if p.fresh {178- m.line.Clear()216+ line.Clear()179217 p.fresh = false180218 }181- m.line.Insert(text)219+ line.Insert(text)182220 }183221 184222 // line is the prompt as the context line shows it, e.g. "Save as:185223 // budget.012", and the keys or choices that go with it.186-func (p *prompt) line(m *Model) (left, right string) {224+func (p *prompt) line(m promptHost) (left, right string) {225+ th, text := m.styles(), m.editLine().Text()187226 switch {188- case m.pointing():189- return p.prefix() + m.th.Selection.Render(m.point.text()),190- m.th.KeyHints("Arrows", "move", "Shift+arrows", "extend", "Enter", "apply", "Esc", "cancel")227+ case p.pointing():228+ return p.prefix() + th.Selection.Render(m.pointer().text()),229+ th.KeyHints("Arrows", "move", "Shift+arrows", "extend", "Enter", "apply", "Esc", "cancel")191230 case len(p.files) > 0:192- return p.prefix() + m.line.Text(), m.th.Muted.Render(strings.Join(p.files, " "))231+ return p.prefix() + text, th.Muted.Render(strings.Join(p.files, " "))193232 case p.kind == promptWidth:194- return p.prefix() + m.line.Text(), m.th.KeyHints("Left/Right", "adjust", "Enter", "apply", "Esc", "cancel")233+ return p.prefix() + text, th.KeyHints("Left/Right", "adjust", "Enter", "apply", "Esc", "cancel")195234 case p.secret:196- return p.prefix() + mask(len(m.line.Buf)), m.th.KeyHints("Enter", "save", "Esc", "cancel")235+ return p.prefix() + mask(len(m.editLine().Buf)), th.KeyHints("Enter", "save", "Esc", "cancel")197236 case p.breaks:198- return p.prefix() + m.line.Text(), m.th.KeyHints("Enter", "save", "Shift+Enter", "new line", "Esc", "cancel")237+ return p.prefix() + text, th.KeyHints("Enter", "save", "Shift+Enter", "new line", "Esc", "cancel")199238 }200- return p.prefix() + m.line.Text(), m.th.KeyHints("Enter", "apply", "Esc", "cancel")239+ return p.prefix() + text, th.KeyHints("Enter", "apply", "Esc", "cancel")201240 }202241 203242 func (p *prompt) prefix() string {
@@ -208,11 +247,11 @@ func (p *prompt) prefix() string {
208247 func mask(n int) string { return strings.Repeat("•", n) }209248 210249 // head is the answer before the caret, as shown.211-func (p *prompt) head(m *Model) string {250+func (p *prompt) head(m promptHost) string {212251 if p.secret {213- return mask(m.line.Pos)252+ return mask(m.editLine().Pos)214253 }215- return m.line.Head()254+ return m.editLine().Head()216255 }217256 218257 func isDigits(s string) bool {
M internal/ui/protect.go +2 -2
M internal/ui/protect_test.go +4 -3
M internal/ui/recovery.go +1 -2
M internal/ui/rowheights.go +4 -4
A internal/ui/rulemacro.go +147 -0
@@ -0,0 +1,147 @@
1+package ui2+3+import (4+ "encoding/json"5+ "fmt"6+ "strconv"7+ "strings"8+9+ tea "charm.land/bubbletea/v2"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// Rules in macros: what the rules panel does is recorded as the commands15+// that do it from a line of the file, and those commands are how scripts16+// change rules. A rule is numbered from 1 in the panel's order.17+//18+// run("format.conditional_add", answer={"ranges": "B2:B9", ...}) a rule added19+// run("format.conditional_set", answer={"rule": 2, "ranges": ...}) rule 2 replaced20+// run("format.conditional_remove", answer=2) rule 2 removed21+// run("format.conditional_move", answer={"rule": 2, "to": 1}) rule 2 moved first22+//23+// and the same for data validation, whose rules have no order.24+25+func init() {26+ hasFormats := func(m *Model) bool { return len(m.sheet.CondFormats()) > 0 }27+ hasValidations := func(m *Model) bool { return len(m.sheet.Validations()) > 0 }28+ register(29+ &command{id: "format.conditional_set", title: "Replace conditional format rule", enabled: hasFormats,30+ desc: `Replace a rule with one written as a line of the file, numbered: {"rule":2,"ranges":"B2:B9",...}`,31+ run: func(m *Model) tea.Cmd {32+ m.askRule(func(m *Model, text string) error {33+ i, err := ruleNumber(text, len(m.sheet.CondFormats()))34+ if err != nil {35+ return err36+ }37+ f, err := sheet.ParseCondFormat(text)38+ if err != nil {39+ return err40+ }41+ return m.host().SaveFormat(i, f)42+ })43+ return nil44+ }},45+ &command{id: "format.conditional_remove", title: "Remove conditional format rule", enabled: hasFormats,46+ desc: "Remove a rule by its number in the list, counting from 1",47+ run: func(m *Model) tea.Cmd {48+ m.askRule(func(m *Model, text string) error {49+ i, err := ruleNumber(text, len(m.sheet.CondFormats()))50+ if err == nil {51+ m.sheet.DeleteCondFormat(i)52+ m.changed = true53+ }54+ return err55+ })56+ return nil57+ }},58+ &command{id: "format.conditional_move", title: "Move conditional format rule", enabled: hasFormats,59+ desc: `Move a rule to another place in the list, where the first rule that matches wins: {"rule":2,"to":1}`,60+ run: func(m *Model) tea.Cmd {61+ m.askRule(func(m *Model, text string) error {62+ n := len(m.sheet.CondFormats())63+ i, err := ruleNumber(text, n)64+ if err != nil {65+ return err66+ }67+ var a struct {68+ To int `json:"to"`69+ }70+ if json.Unmarshal([]byte(text), &a); a.To < 1 || a.To > n {71+ return fmt.Errorf(`give "to", a place from 1 to %d`, n)72+ }73+ m.sheet.MoveCondFormat(i, a.To-1)74+ m.changed = true75+ return nil76+ })77+ return nil78+ }},79+ &command{id: "data.validation_set", title: "Replace data validation rule", enabled: hasValidations,80+ desc: `Replace a rule with one written as a line of the file, numbered: {"rule":2,"ranges":"D2:D9",...}`,81+ run: func(m *Model) tea.Cmd {82+ m.askRule(func(m *Model, text string) error {83+ i, err := ruleNumber(text, len(m.sheet.Validations()))84+ if err != nil {85+ return err86+ }87+ v, err := sheet.ParseValidation(text)88+ if err != nil {89+ return err90+ }91+ return m.host().SaveValidation(i, v)92+ })93+ return nil94+ }},95+ &command{id: "data.validation_remove", title: "Remove data validation rule", enabled: hasValidations,96+ desc: "Remove a rule by its number in the list, counting from 1",97+ run: func(m *Model) tea.Cmd {98+ m.askRule(func(m *Model, text string) error {99+ i, err := ruleNumber(text, len(m.sheet.Validations()))100+ if err == nil {101+ m.sheet.DeleteValidation(i)102+ m.changed = true103+ }104+ return err105+ })106+ return nil107+ }},108+ )109+}110+111+// askRule asks for a rule's number, or a rule, on the context line, and112+// hands it to do.113+func (m *Model) askRule(do func(m *Model, text string) error) {114+ m.openText("Rule:", "", func(m *Model, text string) tea.Cmd {115+ m.failOn(do(m, text))116+ return nil117+ })118+}119+120+// ruleNumber reads which of n rules an answer names, counting from 1:121+// the number itself, or "rule" in a line of the file.122+func ruleNumber(text string, n int) (int, error) {123+ i, err := strconv.Atoi(strings.TrimSpace(text))124+ if err != nil {125+ var a struct {126+ Rule int `json:"rule"`127+ }128+ if json.Unmarshal([]byte(text), &a) != nil || a.Rule == 0 {129+ return 0, fmt.Errorf(`give the rule's number, from 1 to %d: {"rule":1,...}`, n)130+ }131+ i = a.Rule132+ }133+ if i < 1 || i > n {134+ return 0, fmt.Errorf("there's no rule %d: the sheet has %d", i, n)135+ }136+ return i - 1, nil137+}138+139+// numbered is a rule's line with its number first, as the commands that140+// replace or move a rule take it.141+func numbered(i int, line string) string {142+ rest := strings.TrimPrefix(line, "{")143+ if rest != "}" {144+ rest = "," + rest145+ }146+ return `{"rule":` + strconv.Itoa(i+1) + rest147+}
M internal/ui/rules.go +13 -12
@@ -351,20 +351,21 @@ func (h host) SaveFormat(i int, f sheet.CondFormat) error {
351351 if i < 0 {352352 m.recordRule("format.conditional_add", f.JSON())353353 } else {354- m.noteUnrecorded()354+ m.recordRule("format.conditional_set", numbered(i, f.JSON()))355355 }356356 }357357 return err358358 }359359 360-// Reworked follows a rule removed or moved in the panel.361-func (h host) Reworked() {360+// Reworked follows a rule removed or moved in the panel, recorded as the361+// command id that does the same, answered with answer.362+func (h host) Reworked(id, answer string) {362363 h.m.changed = true363- h.m.noteUnrecorded()364+ h.m.recordRule(id, answer)364365 }365366 366-// noteUnrecorded notes in a recording the last undo step, a change to367-// rules the panel made that no command replays, as dialogs are noted.367+// noteUnrecorded notes in a recording the last undo step, a change no368+// command replays, as the named ranges picker makes.368369 func (m *Model) noteUnrecorded() {369370 if r := m.rec; r != nil && r.depth == 0 && r.pending == "" {370371 r.flush(m)
@@ -392,19 +393,19 @@ func (h host) SaveValidation(i int, v sheet.Validation) error {
392393 if i < 0 {393394 m.recordRule("data.validation_add", v.JSON())394395 } else {395- m.noteUnrecorded()396+ m.recordRule("data.validation_set", numbered(i, v.JSON()))396397 }397398 }398399 return err399400 }400401 401-// recordRule records a rule added from the panel as the command that402-// adds it, answered with its line. A command that adds one itself is403-// recorded where it runs.404-func (m *Model) recordRule(id, line string) {402+// recordRule records a change the panel made to rules as the command403+// that makes it, answered with a rule's line or number (rulemacro.go). A404+// command that changes one itself is recorded where it runs.405+func (m *Model) recordRule(id, answer string) {405406 if m.rec == nil || m.rec.depth > 0 || m.rec.pending != "" {406407 return407408 }408409 m.rec.flush(m)409- m.rec.add(m, macro.Call("run", id).With("answer", line))410+ m.rec.add(m, macro.Call("run", id).With("answer", macro.JSON(answer)))410411 }
M internal/ui/rules/condfmt.go +4 -2
M internal/ui/rules/editor.go +10 -6
@@ -35,8 +35,9 @@ type Host interface {
3535 SaveFormat(i int, f sheet.CondFormat) error3636 SaveValidation(i int, v sheet.Validation) error3737 // Reworked follows a rule removed or moved: the file is modified,38- // and a macro being recorded notes what it can't replay.39- Reworked()38+ // and a macro being recorded records the command id that does the39+ // same, answered with answer.40+ Reworked(id, answer string)4041 // Slot is the terminal's color for an ANSI slot, for color scales.4142 Slot(i int) color.Color4243 }
@@ -56,6 +57,9 @@ type kind interface {
5657 // says how rules combine, for the status line.5758 ordered() bool5859 listHint() string60+ // command is the id of the command that does action ("remove",61+ // "move") to a rule, as a macro records it.62+ command(action string) string5963 }6064 6165 // form is a rule being added or edited.
@@ -156,9 +160,9 @@ func (e *Editor) Key(k tea.KeyPressMsg) tea.Cmd {
156160 case "a", "+":157161 e.openForm(-1)158162 case "delete", "backspace", "x", "-":159- if e.list.Sel > 0 {160- e.k.remove(e.h.Sheet(), e.list.Sel-1)161- e.h.Reworked()163+ if i := e.list.Sel - 1; i >= 0 {164+ e.k.remove(e.h.Sheet(), i)165+ e.h.Reworked(e.k.command("remove"), strconv.Itoa(i+1))162166 e.list.Sel = min(e.list.Sel, e.k.count(e.h.Sheet()))163167 }164168 case "shift+up", "shift+down":
@@ -179,7 +183,7 @@ func (e *Editor) reorder(down bool) {
179183 }180184 if e.k.move(e.h.Sheet(), i, to) {181185 e.list.Sel = to + 1182- e.h.Reworked()186+ e.h.Reworked(e.k.command("move"), `{"rule":`+strconv.Itoa(i+1)+`,"to":`+strconv.Itoa(to+1)+`}`)183187 }184188 }185189
M internal/ui/rules/editor_test.go +22 -14
@@ -16,13 +16,14 @@ import (
1616 1717 // fakeHost is a sheet and an edit line, recording what the panel asks.1818 type fakeHost struct {19- th theme.Theme20- line lineedit.Line21- s *sheet.Sheet22- sel sheet.Rect23- closed bool24- edits int25- saves []string19+ th theme.Theme20+ line lineedit.Line21+ s *sheet.Sheet22+ sel sheet.Rect23+ closed bool24+ edits int25+ reworked []string26+ saves []string2627 }2728 2829 func newHost() *fakeHost {
@@ -33,13 +34,16 @@ func newHost() *fakeHost {
3334 return &fakeHost{th: theme.New(true), s: s, sel: sheet.Rect{To: sheet.Addr{Row: 2}}}3435 }3536 36-func (h *fakeHost) Theme() *theme.Theme { return &h.th }37-func (h *fakeHost) Size() (int, int) { return 100, 30 }38-func (h *fakeHost) Line() *lineedit.Line { return &h.line }39-func (h *fakeHost) Close() { h.closed = true }40-func (h *fakeHost) Sheet() *sheet.Sheet { return h.s }41-func (h *fakeHost) Selection() sheet.Rect { return h.sel }42-func (h *fakeHost) Reworked() { h.edits++ }37+func (h *fakeHost) Theme() *theme.Theme { return &h.th }38+func (h *fakeHost) Size() (int, int) { return 100, 30 }39+func (h *fakeHost) Line() *lineedit.Line { return &h.line }40+func (h *fakeHost) Close() { h.closed = true }41+func (h *fakeHost) Sheet() *sheet.Sheet { return h.s }42+func (h *fakeHost) Selection() sheet.Rect { return h.sel }43+func (h *fakeHost) Reworked(id, answer string) {44+ h.edits++45+ h.reworked = append(h.reworked, id+" "+answer)46+}4347 func (h *fakeHost) Slot(i int) color.Color { return color.Gray{Y: uint8(i * 16)} }4448 func (h *fakeHost) SaveFormat(i int, f sheet.CondFormat) error {4549 h.saves = append(h.saves, f.JSON())
@@ -184,6 +188,10 @@ func TestRemoveAndReorder(t *testing.T) {
184188 if fs := h.s.CondFormats(); len(fs) != 1 || fs[0].Args[0] != "2" || e.list.Sel != 1 {185189 t.Errorf("remove: %+v sel %d", fs, e.list.Sel)186190 }191+ want := `format.conditional_move {"rule":1,"to":2}` + "\n" + "format.conditional_remove 2"192+ if got := strings.Join(h.reworked, "\n"); got != want {193+ t.Errorf("as a macro records them:\n%s\nwant:\n%s", got, want)194+ }187195 }188196 189197 func TestValidationForm(t *testing.T) {
M internal/ui/rules/validation.go +2 -0
M internal/ui/rules_test.go +4 -4
@@ -160,8 +160,8 @@ func TestRuleMacros(t *testing.T) {
160160 m := newModel()161161 press(t, m, "<shift+down>")162162 src := recordRules(t, m)163- for _, want := range []string{`run("format.conditional_add", answer="{\"ranges\":\"A1:A2\",\"condition\":\"not_empty\",\"fill\":\"green\"}")`,164- `# Not recorded: remove conditional format A1:A2`,163+ for _, want := range []string{`run("format.conditional_add", answer={"ranges": "A1:A2", "condition": "not_empty", "fill": "green"})`,164+ `run("format.conditional_remove", answer=1)`,165165 `run("insert.checkbox")`, `run("data.validation_add", answer="{\"ranges\":\"C1:C2\",\"criteria\":\"list\",\"items\":[\"a\",\"b\"]}")`} {166166 if !strings.Contains(src, want) {167167 t.Errorf("script lacks %s:\n%s", want, src)
@@ -169,7 +169,7 @@ func TestRuleMacros(t *testing.T) {
169169 }170170 fresh := newModel()171171 script(t, fresh, src)172- if len(fresh.sheet.CondFormats()) != 2 || len(fresh.sheet.Validations()) != 2 { // the removal is only noted172+ if len(fresh.sheet.CondFormats()) != 1 || len(fresh.sheet.Validations()) != 2 {173173 t.Errorf("replay: %+v %+v %q", fresh.sheet.CondFormats(), fresh.sheet.Validations(), fresh.warn)174174 }175175 }
@@ -182,7 +182,7 @@ func recordRules(t *testing.T, m *Model) string {
182182 run(m, m.runCommand("macro.record"))183183 run(m, m.runCommand("format.conditional"))184184 press(t, m, "<enter>", "<enter>") // add the rule the form starts with185- press(t, m, "<down>", "<delete>", "<esc>") // removed, and noted185+ press(t, m, "<down>", "<delete>", "<esc>") // removed186186 run(m, m.runCommand("format.conditional"))187187 press(t, m, "<enter>", "<enter>", "<esc>", "<right>")188188 run(m, m.runCommand("insert.checkbox"))
M internal/ui/saveas_test.go +2 -1
M internal/ui/serve_test.go +2 -1
A internal/ui/shortcuts/shortcuts.go +179 -0
@@ -0,0 +1,179 @@
1+// Package shortcuts is the keyboard shortcuts view: a scrollable box2+// over the grid listing every key by what it does, in two columns when3+// the screen is wide enough. The rows come from the host, which builds4+// them from the key bindings and the command registry. It knows the UI5+// only through Host.6+package shortcuts7+8+import (9+ "strconv"10+ "strings"11+12+ tea "charm.land/bubbletea/v2"13+ "github.com/charmbracelet/x/ansi"14+15+ "github.com/FelineStateMachine/012/internal/ui/overlay"16+ "github.com/FelineStateMachine/012/internal/ui/theme"17+)18+19+// Host is what the shortcuts view needs of the UI.20+type Host interface {21+ Theme() *theme.Theme22+ Size() (width, height int)23+ // Close closes the open overlay.24+ Close()25+ // Shortcuts are the rows to list, in order.26+ Shortcuts() []Row27+}28+29+// Row is a line of the view: a group heading, or keys and what they do.30+type Row struct {31+ Heading string32+ Keys []string // as shown, e.g. "Ctrl+G"33+ Action string34+}35+36+// View is the open shortcuts view.37+type View struct {38+ h Host39+ top int40+}41+42+// ID identifies the view's box in mouse events.43+const ID = "shortcuts"44+45+// New returns the view scrolled to the top.46+func New(h Host) *View { return &View{h: h} }47+48+// Top is the first line shown.49+func (s *View) Top() int { return s.top }50+51+func (s *View) Indicator() string { return "HELP" }52+53+// Lines renders the rows, as one or two columns, and returns their54+// width.55+func (s *View) Lines() ([]string, int) {56+ th := s.h.Theme()57+ width, _ := s.h.Size()58+ rows := s.h.Shortcuts()59+ keyW, actW := 0, 060+ for _, r := range rows {61+ keyW = max(keyW, ansi.StringWidth(th.Chips(r.Keys)))62+ actW = max(actW, ansi.StringWidth(r.Action))63+ }64+ // On narrow screens actions give way (truncated) so the keys fit.65+ room := min(width-4, 160)66+ if 1+keyW+2+actW > room {67+ actW = max(room-3-keyW, 16)68+ }69+ colW := 1 + keyW + 2 + actW70+ render := func(rows []Row) []string {71+ var out []string72+ for i, r := range rows {73+ switch {74+ case r.Heading != "" && i > 0:75+ out = append(out, "", th.Title.Render(" "+r.Heading))76+ case r.Heading != "":77+ out = append(out, th.Title.Render(" "+r.Heading))78+ default:79+ out = append(out, " "+theme.PadRight(ansi.Truncate(r.Action, actW, "…"), actW)+" "+th.Chips(r.Keys))80+ }81+ }82+ return out83+ }84+ if 2*colW+3 > room {85+ return render(rows), min(colW, room)86+ }87+ // Two columns, split at the group boundary nearest the middle.88+ split, best := 0, len(rows)89+ for i, r := range rows {90+ if d := abs(len(rows) - 2*i); r.Heading != "" && d < best {91+ split, best = i, d92+ }93+ }94+ left, right := render(rows[:split]), render(rows[split:])95+ out := make([]string, max(len(left), len(right)))96+ for i := range out {97+ var l, r string98+ if i < len(left) {99+ l = left[i]100+ }101+ if i < len(right) {102+ r = right[i]103+ }104+ out[i] = theme.PadRight(l, colW) + " " + r105+ }106+ return out, 2*colW + 3107+}108+109+func abs(x int) int { return max(x, -x) }110+111+// visible is how many lines fit between the menu bar and the status line.112+func (s *View) visible(total int) int {113+ _, height := s.h.Size()114+ return max(min(total, height-2-2-1), 1)115+}116+117+func (s *View) Layout() []overlay.Box {118+ th := s.h.Theme()119+ width, height := s.h.Size()120+ lines, w := s.Lines()121+ inner := w + 1122+ n := s.visible(len(lines) + 1)123+ s.top = min(max(s.top, 0), max(len(lines)+1-n, 0))124+ lines = append([]string{""}, lines...) // breathing room under the title125+ rows := make([]string, n)126+ for i := range rows {127+ if j := s.top + i; j < len(lines) {128+ rows[i] = theme.Cells(th.MenuBar, lines[j], inner)129+ } else {130+ rows[i] = strings.Repeat(" ", inner)131+ }132+ }133+ footer := ""134+ if n < len(lines) {135+ footer = strconv.Itoa(s.top+1) + "-" + strconv.Itoa(s.top+n) + " of " + strconv.Itoa(len(lines))136+ }137+ b := th.Frame(inner, "Keyboard shortcuts", footer, rows)138+ bw, bh := ansi.StringWidth(b[0]), len(b)139+ return []overlay.Box{{ID: ID, X: (width - bw) / 2, Y: max(overlay.MenuLine+1, (height-1-bh)/2), Lines: b}}140+}141+142+func (s *View) Key(k tea.KeyPressMsg) tea.Cmd {143+ lines, _ := s.Lines()144+ page := s.visible(len(lines) + 1)145+ switch k.String() {146+ case "up", "k":147+ s.top--148+ case "down", "j":149+ s.top++150+ case "pgup":151+ s.top -= page152+ case "pgdown", "space":153+ s.top += page154+ case "home":155+ s.top = 0156+ case "end":157+ s.top = len(lines)158+ case "esc", "enter", "q", "f1", "ctrl+/":159+ s.h.Close()160+ }161+ s.top = min(max(s.top, 0), max(len(lines)+1-page, 0))162+ return nil163+}164+165+func (s *View) Mouse(e overlay.MouseEvent) tea.Cmd {166+ switch {167+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:168+ s.top = max(s.top-3, 0)169+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:170+ s.top += 3 // layout clamps171+ case e.Kind == overlay.MousePress && e.Box != ID:172+ s.h.Close()173+ }174+ return nil175+}176+177+func (s *View) Status() (string, string) {178+ return "", s.h.Theme().KeyHints("Up/Down", "scroll", "Esc", "close")179+}
A internal/ui/shortcuts/shortcuts_test.go +82 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/sort.go +19 -197

Not shown: this page already shows 6000 lines of diff.

A internal/ui/sortbar/sortbar.go +304 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/sortbar/sortbar_test.go +128 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/suggest/sheets.go +69 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/suggest/suggest.go +304 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/suggest/suggest_test.go +141 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/tabs.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/transfer.go +1 -2

Not shown: this page already shows 6000 lines of diff.

M internal/ui/transfer_test.go +2 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/undocost.go +2 -2

Not shown: this page already shows 6000 lines of diff.

M internal/ui/undocost_test.go +2 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/vim_test.go +3 -1

Not shown: this page already shows 6000 lines of diff.