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-a261c5f69e4010b5c

Dami committed c6a7d87
Notebook regions and linked regions side by side, both kept; they are
made one engine next.
commit
c6a7d87a33722d7e5ed59e390173930f7ee4ccb6
parent
70d38c3cd6278efacf1cf389ae0312c92dba5bbe
parent
09c14191fae91d4b47d6efc1a1d9bfe0b70ba371
author
Dami <dami.etoile@gmail.com>
81 files changed, +4305 -86
MREADME.md6+
MROADMAP.md10+-
Mcmd/012/main.go9+
Acmd/012/nu_test.go32++
Ademos/notebook.tape32++
Mdocs/contributing/architecture.md42+++-
Mdocs/contributing/ux.md1+
Mdocs/files/README.md4+
Mdocs/files/format.md30++
Adocs/media/notebook.gifbin
Mdocs/reference/config.md49+++
Mdocs/reference/keys.md10+
Mdocs/terminal/nushell.md128++++++++-
Mdocs/terminal/ssh.md16+-
Me2e/keys_test.go3+-
Ae2e/notebook_test.go58++++
Ae2e/screennotebook_test.go62++++
Ae2e/testdata/nu/nu21+
Me2e/testdata/screens/help-light.html2+-
Me2e/testdata/screens/help-narrow.html2+-
Me2e/testdata/screens/help-wide.html2+-
Me2e/testdata/screens/help.html2+-
Me2e/testdata/screens/macro-menu-light.html26+-
Me2e/testdata/screens/macro-menu.html26+-
Ae2e/testdata/screens/notebook-completion-high-contrast.html30++
Ae2e/testdata/screens/notebook-completion-light.html30++
Ae2e/testdata/screens/notebook-completion.html30++
Ae2e/testdata/screens/notebook-failed-high-contrast.html30++
Ae2e/testdata/screens/notebook-failed-light.html30++
Ae2e/testdata/screens/notebook-failed.html30++
Ae2e/testdata/screens/notebook-regions-high-contrast.html30++
Ae2e/testdata/screens/notebook-regions-light.html30++
Ae2e/testdata/screens/notebook-regions.html30++
Me2e/testdata/screens/palette-narrow.html2+-
Me2e/testdata/screens/palette-search-light.html2+-
Me2e/testdata/screens/palette-search.html2+-
Me2e/testdata/screens/palette-wide.html16+-
Me2e/testdata/screens/palette.html2+-
Minternal/config/registry.go16+-
Minternal/fileio/nuon_test.go44+++
Minternal/fileio/nuonwrite.go7+-
Minternal/fileio/snapshot.go8+-
Ainternal/nushell/nushell.go256+++++++++++++++++
Ainternal/nushell/nushell_test.go145+++++++++
Minternal/sheet/file.go31++-
Minternal/sheet/fileread.go1+
Minternal/sheet/filestream_test.go7+
Minternal/sheet/history.go14+-
Minternal/sheet/historyrestore.go5+
Minternal/sheet/historysize.go3+
Minternal/sheet/names.go8+-
Minternal/sheet/ops.go1+
Minternal/sheet/recalc.go4+-
Ainternal/sheet/region.go458++++++++++++++++++++++++++++++
Ainternal/sheet/region_test.go189++++++++++++
Ainternal/sheet/regiondata.go66++++
Ainternal/sheet/regionfile.go109+++++++
Ainternal/sheet/regiongraph.go149++++++++++
Ainternal/sheet/regionwrite.go422++++++++++++++++++++++++++++
Minternal/sheet/sheet.go13+
Ainternal/sheet/shellhistory.go35++
Minternal/sheet/workbook.go7+
Minternal/ui/file.go5+
Minternal/ui/gridlines.go17+
Minternal/ui/hosts.go16+
Minternal/ui/menubar.go5+-
Minternal/ui/model.go16+-
Minternal/ui/model_test.go2+-
Minternal/ui/monochrome_test.go20+
Ainternal/ui/notebook.go388+++++++++++++++++++++++++
Ainternal/ui/notebook_test.go260+++++++++++++++++
Ainternal/ui/nuprompt/nuprompt.go331++++++++++++++++++++++
Ainternal/ui/nuprompt/nuprompt_test.go100+++++++
Ainternal/ui/nurun.go325+++++++++++++++++++++
Minternal/ui/overlay/overlay.go10+
Minternal/ui/panel.go9+
Minternal/ui/pivot.go4+
Minternal/ui/sort.go3+
Minternal/ui/theme/scheme.go2+-
Minternal/ui/theme/theme.go10+-
Minternal/ui/view.go3+
M README.md +6 -0
M ROADMAP.md +3 -7
@@ -26,16 +26,11 @@ rises in measured steps; see [Bounds of support](docs/contributing/limits.md#wha
2626 2727 ### 4. Nushell notebook2828 29-One binary: `012 nu`, `012 -` and `012 --pipe` are modes of 012, and a30-notebook sheet lives in a workbook beside ordinary sheets. `012 nu` runs31-`nu` as a separate process; without it, it says so and the rest of 01232-is unchanged. Written like a REPL, kept like a reactive notebook: each33-command's result is a named, live region (`r1`) that later commands read34-as `$r1`, and refreshing a region re-runs what depends on it.29+What's left of making 012 and nushell one tool, on top of the notebook30+([Notebooks](docs/terminal/nushell.md#notebooks)).3531 3632 | Item | Result | Size |3733 |---|---|---|38-| `012 nu` and the notebook sheet: a nushell prompt in the formula bar (history, Tab completion), results stacking as named live regions built on spills, `$r1` references with a dependency graph so refresh cascades, Freeze to values, other sheets reading `Shell!r1`, commands saved and never run on open, trust for files from elsewhere, timeouts and an output cap, off in `012 serve` unless configured | A reactive notebook of pipelines inside a spreadsheet | L |3934 | Nushell's own completions in the prompt; `nu_plugin_012` only if `--pipe` proves clumsy from inside nu | | S to M |4035 4136 ### 5. Toward multiplayer
@@ -111,6 +106,7 @@ stream above rather than a design of their own.
111106 - Shift+Enter, Ctrl+I and keys held to preview, with the kitty keyboard protocol: [Keys and mouse](docs/reference/keys.md#keys-the-terminal-has-to-tell-apart)112107 - Sixel chart images on terminals without kitty graphics, drawn after the frame and redrawn as the screen moves: [Charts](docs/sheets/charts.md)113108 - 012 as a stage in a pipeline: `012 -` reads a table from standard input, `012 --pipe` sends the sheet or selection on, with nushell's types kept through NUON: [Nushell and pipelines](docs/terminal/nushell.md)109+- `012 nu` and notebook sheets: nushell pipelines at a prompt on the formula bar become live, named regions that read each other and refresh in dependency order, saved as commands and never run on open: [Notebooks](docs/terminal/nushell.md#notebooks)114110 115111 **Files**116112
M cmd/012/main.go +9 -0
A cmd/012/nu_test.go +32 -0
A demos/notebook.tape +32 -0
M docs/contributing/architecture.md +41 -1
@@ -24,6 +24,7 @@ internal/chart chart layout, text rendering and kitty image encoding
2424 internal/jev the API key's resolution, answer cache and TypeSafe client2525 internal/keyring the OS credential store the API key lives in2626 internal/macro macros: the Starlark scripting API, recorded actions as scripts, step limits27+internal/nushell a notebook's commands: nu run as a process, tables in as NUON files, a table back2728 internal/telemetry opt-in JSON log and OTLP export of spans, events and frame stats2829 internal/serve the SSH server: auth, host key, a Model per session (charm.land/wish/v2)2930 internal/confine resolving typed file names, confined to a directory when served
@@ -36,6 +37,7 @@ internal/ui the Bubble Tea model: modes, menus, overlays, rendering
3637 lineedit the one-line editor every text field shares3738 picker the searchable list behind the palette and every picker3839 cmdline the : command line and its completions40+ nuprompt the nushell prompt on the formula bar, its history and completions3941 findbar find and replace, a bar on the context line4042 themepicker File > Settings > Theme, previewing as it moves4143 rules the conditional formatting and data validation panel
@@ -224,6 +226,26 @@ style.
224226 are replaced whole on every change, so undo steps keep them; a region225227 undo puts back, or one moved by inserted lines, is emptied and marked226228 stale, and the UI reads its source again.229+- **Regions.** A region (`region.go`) is a block of cells whose values230+ come from outside the engine: an anchor, its label line, and a231+ definition naming what fills it (a notebook's nushell command, opaque232+ to the engine), with the data it shows (`RegionData`) handed over by233+ the UI (`ShowRegion`). Definitions and data are the sheet's234+ `regionState`, replaced whole on every change so undo steps keep them;235+ what isn't undone (a label's status, the cells written) is kept236+ beside it. After a change touching regions, their cells are written237+ again (`regionwrite.go`) as spilled cells are, derived and outside the238+ undo history, only those that differ, and cells no region covers any239+ more are cleared; a table that would overwrite other contents shows240+ only its label, saying where. On a notebook sheet a table that grows241+ or shrinks inserts or deletes rows under it inside the same step, so242+ the rows below move as they would by hand. Formulas name a region's243+ table as `nu.name`, resolved when evaluated as named ranges are; the244+ regions' `$name` references make a dependency graph245+ (`regiongraph.go`) giving refresh and run orders, and cycles are246+ refused. The file keeps the definitions (`regionfile.go`). Anything247+ that fills cells from outside (a file followed as it grows) is a248+ region with another kind of command.227249 - **Rules.** A sheet's conditional formats and data validation228250 (`rules.go`, `condfmt.go`, `validation.go`) are lists of rules on229251 ranges, replaced whole on every change so undo steps keep them as they
@@ -413,6 +435,7 @@ draw. The components:
413435 | mouse | `mouseState` | drags, hover, double clicks, the fill handle (`mouse.go`, `fill.go`) |414436 | import | `transfer.Transfer`, `pipeState` | the import in progress, its progress display and cancelling (package `transfer`); choosing and placing imports (`transfer.go`, `importplace.go`); standard input read as a sheet, and what a pipeline gets on quitting (`pipe.go`) |415437 | linked files | `followState` | a source (`live.Source`) for each linked region, polled one poll at a time on a command every `live.Interval` and reconciled with the workbook after every update, so undo, opening a file and unlinking need nothing of their own; trust in files outside a workbook's folder (`follow.go`); Data > Linked file and Import's Follow the file (`linked.go`) |438+| notebooks | `nuprompt.Prompt`, `shellState` | the prompt (package `nuprompt`); the commands running one at a time in the background, their queue and what they said (`nurun.go`); the notebook's commands, keys, lines and trust (`notebook.go`) |416439 | macros | `recorder`, `macroState` | a recording in progress (`macrorec.go`); a macro running, trust in the file's macros (`macrorun.go`); what scripts act on (`macrohost.go`, `macrohostnav.go`); Data > Macros and the manager (`macro.go`, `macromanage.go`) |417440 | others | `clipboard`, `trace`, `chartState`, `jevRunner`, `terminal`, `session` | what Ctrl+V pastes, a trace being shown, chart commands' target, JEV questions in flight, what the terminal supports and the chart images sent to it, what outlasts the file open (the `:` history, whether keys can be held: `keyboard.go`) |418441
@@ -439,6 +462,7 @@ methods off `ui.Model`'s exported API:
439462 | `sortbar` | `sortbar.Host` | theme, size, the sheet, close, sort (recorded as the bar's command) (5) |440463 | `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 |441464 | `cmdline` | `cmdline.Host` | theme, size, the edit line, close, the commands to complete, run a line, fail, the session's history (8) |465+| `nuprompt` | `nuprompt.Host` | theme, size, the edit line, close, run a line, the words to complete, the workbook's history, what's running, stop it, what the last run said (10) |442466 | `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) |443467 | `themepicker` | `themepicker.Host` | a picker's host, and the current theme, the themes directory, preview, keep, whether keys can be held (10) |444468 | `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) |
@@ -484,7 +508,7 @@ hints) are what every overlay is drawn with.
484508 485509 **Packages.** `theme`, `rowtext`, `formula`, `overlay` and `lineedit`486510 depend on nothing in `ui`, so they can be tested and measured alone. The487-components in `picker`, `cmdline`, `themepicker`, `findbar`, `rules`,511+components in `picker`, `cmdline`, `nuprompt`, `themepicker`, `findbar`, `rules`,488512 `sortbar`, `filterpick`, `choicebar`, `shortcuts`, `suggest`, `tabstrip`489513 and `transfer` build on them and reach the model only through their490514 hosts, with unit tests of their own against fake hosts. A component moves out of package
@@ -571,6 +595,22 @@ were made or trusted on (an id from `cmd/012`); a macro from elsewhere
571595 asks once before it runs. Only the local app lets scripts be edited in572596 the user's editor (`AllowEditor`), since that starts a program.573597 598+## Notebooks599+600+`internal/nushell` runs a notebook's command: a `Job` holds the command601+and the NUON of the tables it reads, and the script `nu -c` runs binds602+each table from a file named by an environment variable603+(`let r1 = (open --raw $env.NU012_TABLE_0 | from nuon)`), feeds the604+selection as `$in` on standard input, and ends in `to nuon`, so no data605+is ever spliced into the command. What nu prints is read as it's606+written by the NUON importer, capped at max-cells. `Runner` is the607+seam: tests hand the UI a fake, the e2e screens a stand-in `nu`608+script. The UI runs one command at a time as a `tea.Cmd` with a609+context Esc cancels and a timeout, and shows each table as an undo610+step when it arrives; the queue a refresh makes comes from the611+engine's graph. Trust is the macros': a file's commands run only once612+they were made or trusted on this computer, or the user agrees.613+574614 ## Charts575615 576616 `internal/chart` draws a chart in two layers that share one layout: text
M docs/contributing/ux.md +1 -0
M docs/files/README.md +4 -0
M docs/files/format.md +30 -0
@@ -90,6 +90,9 @@ save), so they raise no version:
9090 | `locale` | the workbook | The [locale](../sheets/locale.md) it's typed and shown in (`"de-DE"`), when File > Settings > Locale chose one; without it, the file follows the reader's `locale` setting. Cells are stored the same way in every locale: `input` is always as typed in en-US (`1,234.5`, `9/26/2026`, `=ROUND(A1,2)`) |9191 | `linkOrigin` | the workbook | The computer its linked files were linked or trusted on: see [Following files](following.md#files-from-elsewhere) |9292 | `macros`, `macroOrigin` | the workbook | Macros as Starlark scripts, and the computer they were made or trusted on: see [Macro scripting API](../reference/macro-api.md#in-the-file). Opening a file never runs them |93+| `macros`, `macroOrigin` | the workbook | Macros as Starlark scripts, and the computer they were made or trusted on (macros' and shell regions' both): see [Macro scripting API](../reference/macro-api.md#in-the-file). Opening a file never runs them |94+| `notebook`, `regions` | a sheet | A [notebook sheet](../terminal/nushell.md#notebooks) and its shell regions: see [Regions](#regions). Older builds open the sheet without them |95+| `shellHistory` | the workbook | The lines typed at its [notebook prompt](../terminal/nushell.md#the-prompt), oldest first, for Up and Down |9396 9497 ## Column and row formats9598
@@ -181,6 +184,33 @@ what it reads, never its rows:
181184 - `table` or `query` say what to read from a SQLite database.182185 - `window` keeps the last rows under the header, when set.183186 187+## Regions188+189+A [notebook sheet](../terminal/nushell.md#notebooks) has `"notebook":190+true` and its regions in a `regions` list after its cells, charts and191+rules, one per line, in run order. The file keeps each region's192+command, never its table: reopening shows the regions as not run, and193+nothing runs until you run it.194+195+```json196+ "notebook": true,197+ "regions": [198+ {"name":"r1","command":"ls","at":"A1","rows":12,"cols":4},199+ {"name":"big","command":"$r1 | where size > 1kb","at":"A16","rows":3,"cols":4,"reads":["r1"],"sort":[{"column":3,"desc":true}]}200+ ]201+```202+203+- `name` is what commands read it as (`$r1`) and formulas name it by204+ (`nu.r1`); `command` is the nushell pipeline.205+- `at` is its label line's cell; `rows` and `cols` the size of its table206+ when saved, header row included, which it keeps until it runs.207+- `reads` are the regions its command reads, `input` the range it read208+ as `$in` (`Sheet1!A1:C9`), and `sort` the columns its table is sorted209+ by, counting from 1 at its first column, with `desc` for Z to A.210+211+The table's cells are saved only for their formatting and notes, as a212+spill's are.213+184214 ## Conditional formats and data validation185215 186216 A sheet's rules are two lists after its cells and charts, one rule per
A docs/media/notebook.gif +0 -0

Binary file.

M docs/reference/config.md +49 -0
@@ -74,6 +74,9 @@ start.
7474 | [`keymap`](#keymap) | `default` | `O12_KEYMAP` |7575 | [`locale`](#locale) | `en-US` | `O12_LOCALE` |7676 | [`max-cells`](#max-cells) | `10000000` | `O12_MAX_CELLS` |77+| [`shell`](#shell) | `ask` | `O12_SHELL` |78+| [`nu-timeout`](#nu-timeout) | `30s` | `O12_NU_TIMEOUT` |79+| [`nu-config`](#nu-config) | `false` | `O12_NU_CONFIG` |7780 | [`jev-api-key-command`](#jev-api-key-command) | | |7881 | [`jev-credential-store`](#jev-credential-store) | `true` | `O12_JEV_CREDENTIAL_STORE` |7982 | [`jev-base-url`](#jev-base-url) | | `TYPESAFE_BASE_URL` |
@@ -86,6 +89,7 @@ start.
8689 | [`serve-host-key`](#serve-host-key) | | |8790 | [`serve-idle-timeout`](#serve-idle-timeout) | `30m` | |8891 | [`serve-max-sessions`](#serve-max-sessions) | `8` | |92+| [`serve-shell`](#serve-shell) | `false` | |8993 | [`config-file`](#config-file) | | |9094 9195 ### Appearance
@@ -160,6 +164,41 @@ The most cells an import keeps, and a paste or fill writes at once. Numbers and
160164 | Environment | `O12_MAX_CELLS` |161165 | Applies | File > Settings > Reload config |162166 167+### Nushell notebooks168+169+#### `shell`170+171+Whether notebook sheets run nushell commands (docs/terminal/nushell.md). `ask` runs what you type and asks once before running the commands of a file made on another computer; `on` never asks; `off` runs none. Opening a file never runs its commands.172+173+| | |174+|---|---|175+| Type | one of `off`, `ask`, `on` |176+| Default | `ask` |177+| Environment | `O12_SHELL` |178+| Applies | File > Settings > Reload config |179+180+#### `nu-timeout`181+182+Stop a notebook's command that runs longer than this; 0 lets it run until Esc stops it.183+184+| | |185+|---|---|186+| Type | duration |187+| Default | `30s` |188+| Environment | `O12_NU_TIMEOUT` |189+| Applies | File > Settings > Reload config |190+191+#### `nu-config`192+193+Run notebook commands with your nushell config files (config.nu, env.nu) rather than `nu --no-config-file`, for your own commands and aliases.194+195+| | |196+|---|---|197+| Type | true or false |198+| Default | `false` |199+| Environment | `O12_NU_CONFIG` |200+| Applies | File > Settings > Reload config |201+163202 ### JEV functions164203 165204 #### `jev-api-key-command`
@@ -294,6 +333,16 @@ How many 012 serve sessions may run at once; more are turned away.
294333 | Default | `8` |295334 | Applies | restart 012 |296335 336+#### `serve-shell`337+338+Let 012 serve sessions run notebooks' nushell commands, as the user 012 serve runs as, following the shell option. Off, served notebooks show their regions but run nothing.339+340+| | |341+|---|---|342+| Type | true or false |343+| Default | `false` |344+| Applies | restart 012 |345+297346 ### Config files298347 299348 #### `config-file`
M docs/reference/keys.md +10 -0
M docs/terminal/nushell.md +125 -3
@@ -21,6 +21,128 @@ Windows), not standard input or output, so the pipeline's data never
2121 mixes with what 012 draws, and nothing but the table reaches standard2222 output.2323 24+The other way round, a [notebook](#notebooks) runs nushell from inside25+012: each pipeline's table becomes a live part of the sheet.26+27+## Notebooks28+29+![Running ls and a pipeline reading it at the nu❯ prompt, then refreshing the first and watching the second follow](../media/notebook.gif)30+31+`012 nu` opens a notebook: a sheet where you type nushell pipelines and32+each one's table lands in the grid, named, typed and kept live. Written33+like a REPL, kept like a spreadsheet.34+35+```sh36+012 nu # a new notebook37+012 nu work.012 # open one, or make it38+```39+40+In any workbook, `!` (or **Data > Shell**, or the palette) opens the41+same prompt, writing into the workbook's notebook sheet, or a new one42+named Shell 1 at the first command. Commands run `nu` as a separate43+process, so nushell must be installed; without it, the prompt says so44+and the rest of 012 works as before.45+46+### The prompt47+48+The formula bar becomes `nu❯`, and the mode indicator says `NU`:49+50+| Key | Does |51+|---|---|52+| Enter | Run the line; the prompt stays open for the next |53+| Tab, Shift+Tab | Complete the word at the caret: a region (`$r1`) or one of nu's commands, from the box under the context line; again for the next |54+| Up, Down | The workbook's earlier lines, those starting with what's typed; they're saved with the file |55+| Esc | Stop the command running; otherwise, back to the grid |56+57+A name and `=` before the pipeline name the region; without one it's58+`r1`, `r2` and so on:59+60+```nu61+sizes = ls | select name size62+```63+64+Typing a name that exists gives that region a new command and runs it65+again, as does F2 on its label.66+67+### Regions68+69+Each command's table is a region: a label line with its name and70+command, dimmed and underlined as wide as the table, then the table,71+its first row the column names. Regions stack down the sheet in the72+order they ran, a row between them; when a table grows or shrinks, the73+rows below it move, as if you'd inserted or deleted rows.74+75+A region is still cells of the grid. Its values are76+[typed](#types) (file sizes in the Size format, durations, dates),77+formulas read them, and you can format them, filter them78+(**Data > Create a filter**), sort them (the sort is kept, so the table79+stays sorted when it runs again) and chart them. Like an80+[array's spill](../formulas/arrays.md#spilled-cells), they can't be81+typed over: change the command, or **Data > Shell regions > Freeze82+region** turns the table into plain values and stops running it. Copies83+paste values; downloads and XLSX hold the values; undo takes back a run84+(the table it replaced comes back) or the region itself.85+86+The context line says what's under the pointer: on a label, the keys87+that refresh and edit it, and why it failed if it did; on its table,88+which region the cell is part of. A label's end says what it's doing:89+`Running…`, `waiting` for a region it reads, `failed`, `stopped`,90+`not run`, or `no rows`.91+92+### Tables between regions93+94+`$r1` in a command is region r1's table, handed to nu as NUON in a file95+nu reads, never as text spliced into the command. `$in` is the range96+selected when the prompt was opened, on a sheet that isn't the97+notebook, read again each time the region runs:98+99+```nu100+big = $r1 | where size > 1kb | sort-by size --reverse101+$in | group-by region | transpose region rows102+```103+104+A region reading another depends on it. Refreshing a region (Enter on105+its label, **Data > Shell regions > Refresh region**) runs it again and then every region106+that reads it, directly or through others, each after what it reads.107+F9 (**Data > Shell regions > Run all regions**) runs them all. A command108+reading itself, directly or through others, is refused.109+110+Other sheets read a region by name with `nu.` in front, as a named range111+of its table, header included: `=SUM(nu.big)`, `=VLOOKUP("go.mod",112+nu.r1, 3, FALSE)`. Formulas follow the table as it grows, shrinks or113+moves, and show `#REF!` while it has none.114+115+### Running116+117+Commands run in the background, one at a time: the screen stays live,118+and Esc stops the one running and those waiting after it. Each runs as119+`nu --no-config-file -c` (with your own config files if `nu-config` is120+on), stops after `nu-timeout` (30 seconds unless set), and keeps at121+most `max-cells` cells, whole rows, saying how many it left out. When a122+command fails, its label says so and the context line gives nushell's123+message.124+125+### Saving and trust126+127+The file keeps each region's command, name, place and what it reads,128+not its table ([format](../files/format.md#regions)). Opening a notebook129+never runs anything: its regions say `not run`, and F9 runs them all,130+each after what it reads.131+132+Like [macros](../sheets/macros.md#macros-from-other-computers), a133+file's commands run as you, with your files, so a notebook saved on134+another computer asks once before they run:135+136+```137+Run this file's shell commands? Enter Run Esc Cancel138+```139+140+What you type at the prompt runs without asking. The `shell` option141+decides: `ask` (the default) as above, `on` never asks, and `off` runs142+nothing. [012 serve](ssh.md#notebooks) runs no commands unless the143+server's `serve-shell` is on. See [Configuration](../reference/config.md#nushell-notebooks)144+for the options.145+24146 ## Reading standard input25147 26148 `012 -` reads a table from standard input into a new sheet named
@@ -55,9 +177,9 @@ standard output:
55177 without asking. They're in the menus and the palette only with56178 `--pipe`.57179 58-The sheet is its used range from A1: every row, including rows a filter59-hides. A selection is sent as selected, and its first row names the60-columns. Quitting without sending writes nothing and exits with status180+The sheet is its used range from A1. A selection is sent as selected,181+and its first row names the columns. Either way, rows a filter hides182+stay behind, as Sheets copies a filtered range. Quitting without sending writes nothing and exits with status61183 1, so the pipeline stops rather than carrying on with nothing.62184 63185 The table goes out in the format it came in (NUON, JSON, CSV or TSV);
M docs/terminal/ssh.md +14 -2
@@ -86,8 +86,9 @@ the authorized keys. Everything else is closed.
8686 - **Host key.** An ed25519 key generated on first run, written `0600`8787 in a `0700` directory. 012 refuses to start if the key is readable by8888 others.89-- **What a session can do.** Run 012, nothing else; no command is ever90- executed. A shell request starts 012 on a new sheet. An exec request89+- **What a session can do.** Run 012, nothing else; no command is90+ executed, unless the server's config turns on notebooks' nushell91+ commands (see [Notebooks](#notebooks)). A shell request starts 012 on a new sheet. An exec request9192 is accepted only with a terminal (`ssh -t`) and only when it is one9293 word, which is taken as a file name and never run: it resolves inside9394 the served directory like a name typed in File > Open, and anything
@@ -148,6 +149,17 @@ server. A file's macros ask for trust once per session, since the
148149 session isn't the server's own computer, and scripts have no file,149150 network or clock access in any case.150151 152+## Notebooks153+154+A session opens [notebooks](nushell.md#notebooks) and shows their155+regions, but runs none of their commands: a command is a program on the156+server, with the server's user and every file it can reach, well157+beyond the served directory. `serve-shell = on` in the server's config158+lets sessions run them, as the user 012 serve runs as, following the159+`shell` option as the local app does; a file's commands still ask once160+per session before they run. Only turn it on when everyone holding an161+authorized key may run programs on the server.162+151163 ## Unsaved work152164 153165 A session that ends without the user quitting, because it was idle for
M e2e/keys_test.go +2 -1
A e2e/notebook_test.go +58 -0
@@ -0,0 +1,58 @@
1+package e2e2+3+import (4+ "os"5+ "os/exec"6+ "path/filepath"7+ "strings"8+ "testing"9+)10+11+// Notebooks with the real nu: a region, one reading it, saving, and the12+// file reopened with its regions not run until F9.13+14+func needNu(t *testing.T) {15+ t.Helper()16+ if _, err := exec.LookPath("nu"); err != nil {17+ t.Skip("nu isn't installed")18+ }19+}20+21+func TestNotebookWithNu(t *testing.T) {22+ needNu(t)23+ dir := t.TempDir()24+ s := startWith(t, options{dir: dir, startsOn: "nu❯"}, "nu", "book.012")25+ s.keys("[[name size]; [a 2kb] [b 10b] [c 5mb]]", "<enter>")26+ s.waitFor("r1: 3 rows")27+ s.waitFor("5.0 MB")28+ s.keys("big = $r1 | where size > 1kb | sort-by size --reverse", "<enter>")29+ s.waitFor("big: 2 rows")30+ s.keys("nope-not-a-command", "<enter>")31+ s.waitFor("r2 failed:")32+ s.keys("<esc>", "<ctrl+s>")33+ s.eventually("saved notebook", func() bool {34+ data, err := os.ReadFile(filepath.Join(dir, "book.012"))35+ return err == nil && strings.Contains(string(data), `"reads":["r1"]`)36+ })37+ s.keys("<ctrl+q>")38+ s.waitExit()39+40+ s = startWith(t, options{dir: dir, startsOn: "nu❯"}, "nu", "book.012")41+ s.waitFor("r1 [[name size]; [a 2kb] [b 10b] [c 5mb]] not run")42+ s.keys("<esc>", "<f9>")43+ s.waitFor("big $r1 | where size > 1kb | sort-by size --reverse\n")44+ s.waitFor("5.0 MB")45+}46+47+// ! in an ordinary workbook makes a Shell 1 sheet, and $in is the48+// selection.49+func TestShellFromWorkbook(t *testing.T) {50+ needNu(t)51+ s := start(t, "")52+ s.keys("n", "<enter>", "3", "<enter>", "4", "<enter>", "<up>", "<up>", "<up>", "<shift+down>", "<shift+down>", "<shift+down>")53+ s.waitFor(" A1:A4 ")54+ s.keys("!", "$in | math sum", "<enter>")55+ s.waitFor("r1: 1 row")56+ s.waitFor("Shell 1")57+ s.waitFor(" 7")58+}
A e2e/screennotebook_test.go +62 -0
@@ -0,0 +1,62 @@
1+package e2e2+3+import (4+ "os"5+ "path/filepath"6+)7+8+// Golden screens for notebooks: two regions, the second reading the9+// first; the prompt completing a command; a command that failed. Each is10+// recorded on a light terminal and in the high-contrast theme too. nu is11+// testdata/nu/nu, which answers what the screens type with fixed tables.12+13+// fakeNu puts the stand-in nu first on the PATH.14+var fakeNu = func() []string {15+ dir, err := filepath.Abs(filepath.Join("testdata", "nu"))16+ if err != nil {17+ panic(err)18+ }19+ return []string{"PATH=" + dir + string(os.PathListSeparator) + os.Getenv("PATH")}20+}()21+22+// twoRegions runs ls and a region reading it, then goes back to the grid.23+func twoRegions(s *session) {24+ s.keys("ls", "<enter>")25+ s.waitFor("r1: 5 rows")26+ s.keys("big = $r1 | where size > 1kb", "<enter>")27+ s.waitFor("big: 3 rows")28+ s.keys("<esc>")29+ s.waitFor("READY")30+}31+32+var notebookScreens = []screen{33+ {name: "notebook-regions", setup: func(s *session) {34+ twoRegions(s)35+ s.keys("<down>", "<down>", "<right>", "<right>")36+ s.waitFor("Region big")37+ }},38+ {name: "notebook-completion", setup: func(s *session) {39+ s.keys("ls", "<enter>")40+ s.waitFor("r1: 5 rows")41+ s.keys("$r1 | so")42+ s.waitFor("sort-by")43+ }},44+ {name: "notebook-failed", setup: func(s *session) {45+ s.keys("ls", "<enter>")46+ s.waitFor("r1: 5 rows")47+ s.keys("lss -a", "<enter>")48+ s.waitFor("r2 failed: Command `lss` not found")49+ }},50+}51+52+func init() {53+ for _, sc := range notebookScreens {54+ sc.args, sc.opts.startsOn, sc.opts.env = []string{"nu"}, "nu❯", fakeNu55+ light, hc := sc, sc56+ light.name += "-light"57+ light.opts.light = true58+ hc.name += "-high-contrast"59+ hc.opts.config = highContrastConfig60+ screens = append(screens, sc, light, hc)61+ }62+}
A e2e/testdata/nu/nu +21 -0
M e2e/testdata/screens/help-light.html +1 -1
@@ -25,6 +25,6 @@
2525 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Deselect <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>2626 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select all <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+A</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>2727 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select columns <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Space</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>28-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">1-25</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">98</span> <span style="color:var(--vt-palette-8)">─┘</span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">1-25</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">100</span> <span style="color:var(--vt-palette-8)">─┘</span>2929 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>3030 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Up/Down</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">scroll</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/help-narrow.html +1 -1
@@ -11,6 +11,6 @@
1111 <span style="color:var(--vt-palette-8)">│</span> Last used cell <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+End</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>1212 <span style="color:var(--vt-palette-8)">│</span> Go to <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+G</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F5</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>1313 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">│</span>14-<span style="color:var(--vt-palette-8)">└────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-11</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">98</span> <span style="color:var(--vt-palette-8)">─┘</span>14+<span style="color:var(--vt-palette-8)">└───────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-11</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">100</span> <span style="color:var(--vt-palette-8)">─┘</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">scroll</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/help-wide.html +1 -1
@@ -40,6 +40,6 @@
4040 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">36</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Right border <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Shift+2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>4141 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">37</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Menus</span> <span style="font-weight:bold">and</span> <span style="font-weight:bold">search</span> Clear borders <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Shift+6</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>4242 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">38</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Open a menu by its underlined letter <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+letter</span><span style="background:var(--vt-palette-8)"> </span> Clear formatting <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+\</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>43-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">39</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-40</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">49</span> <span style="color:var(--vt-palette-8)">─┘</span>43+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">39</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-40</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">51</span> <span style="color:var(--vt-palette-8)">─┘</span>4444 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">40</span><span style="background:var(--vt-palette-8)"> </span>4545 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">scroll</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/help.html +1 -1
@@ -25,6 +25,6 @@
2525 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Deselect <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>2626 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select all <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+A</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>2727 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select columns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Space</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>28-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-25</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">98</span> <span style="color:var(--vt-palette-8)">─┘</span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-25</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">100</span> <span style="color:var(--vt-palette-8)">─┘</span>2929 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>3030 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">scroll</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/macro-menu-light.html +13 -13
@@ -11,19 +11,19 @@
1111 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">7</span><span style="background:var(--vt-palette-6)"> </span> Total <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">2158.4</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Edit</span> <span style="color:var(--vt-palette-8)">pivot</span> <span style="color:var(--vt-palette-8)">table</span> <span style="color:var(--vt-palette-8)">│</span>1212 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Frequency table (column stats) <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Alt+Shift+F</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>1313 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├────────────────────────────────────────────────┤</span>14-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Linked file › <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>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> Named ranges <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> Define named range <span style="color:var(--vt-palette-8)">│</span>18-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Protect sheets and ranges <span style="color:var(--vt-palette-8)">│</span>19-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├────────────────────────────────────────────────┤</span>20-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Data validation <span style="color:var(--vt-palette-8)">┌──────────────────────────────────────────┐</span>21-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Remove</span> <span style="color:var(--vt-palette-8)">data</span> <span style="color:var(--vt-palette-8)">validati│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Record</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">macro</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>22-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────│</span> Record macro with relative references <span style="color:var(--vt-palette-8)">│</span>23-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Trace precedents <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Stop</span> <span style="color:var(--vt-palette-8)">and</span> <span style="color:var(--vt-palette-8)">save</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color:var(--vt-palette-8)">│</span>24-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Trace dependents <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Discard</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color:var(--vt-palette-8)">│</span>25-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────├──────────────────────────────────────────┤</span>26-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Macros</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Run</span> <span style="color:var(--vt-palette-8)">macro</span> <span style="color:var(--vt-palette-8)">│</span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Named ranges <span style="color:var(--vt-palette-8)">│</span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Define named range <span style="color:var(--vt-palette-8)">│</span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Protect sheets and ranges <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>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Data validation <span style="color:var(--vt-palette-8)">│</span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Remove</span> <span style="color:var(--vt-palette-8)">data</span> <span style="color:var(--vt-palette-8)">validation</span> <span style="color:var(--vt-palette-8)">│</span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────┌──────────────────────────────────────────┐</span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Trace precedents <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Record</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">macro</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Trace dependents <span style="color:var(--vt-palette-8)">│</span> Record macro with relative references <span style="color:var(--vt-palette-8)">│</span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────│</span> <span style="color:var(--vt-palette-8)">Stop</span> <span style="color:var(--vt-palette-8)">and</span> <span style="color:var(--vt-palette-8)">save</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color:var(--vt-palette-8)">│</span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Macros</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Discard</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color:var(--vt-palette-8)">│</span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Shell <span style="color:var(--vt-palette-8)">├──────────────────────────────────────────┤</span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Shell regions <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Run</span> <span style="color:var(--vt-palette-8)">macro</span> <span style="color:var(--vt-palette-8)">│</span>2727 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────│</span> Manage macros <span style="color:var(--vt-palette-8)">│</span>2828 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span> <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> Write a macro <span style="color:var(--vt-palette-8)">│</span>2929 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────└──────────────────────────────────────────┘</span>
M e2e/testdata/screens/macro-menu.html +13 -13
@@ -11,19 +11,19 @@
1111 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">7</span><span style="background:var(--vt-palette-6)"> </span> Total <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">2158.4</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Edit</span> <span style="color:var(--vt-palette-8)">pivot</span> <span style="color:var(--vt-palette-8)">table</span> <span style="color:var(--vt-palette-8)">│</span>1212 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Frequency table (column stats) <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Shift+F</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>1313 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├────────────────────────────────────────────────┤</span>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> Linked file › <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>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> Named ranges <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> Define named range <span style="color:var(--vt-palette-8)">│</span>18-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Protect sheets and ranges <span style="color:var(--vt-palette-8)">│</span>19-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├────────────────────────────────────────────────┤</span>20-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Data validation <span style="color:var(--vt-palette-8)">┌──────────────────────────────────────────┐</span>21-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Remove</span> <span style="color:var(--vt-palette-8)">data</span> <span style="color:var(--vt-palette-8)">validati│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Record</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">macro</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>22-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────│</span> Record macro with relative references <span style="color:var(--vt-palette-8)">│</span>23-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Trace precedents <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Stop</span> <span style="color:var(--vt-palette-8)">and</span> <span style="color:var(--vt-palette-8)">save</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color:var(--vt-palette-8)">│</span>24-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Trace dependents <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Discard</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color:var(--vt-palette-8)">│</span>25-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────├──────────────────────────────────────────┤</span>26-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Macros</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Run</span> <span style="color:var(--vt-palette-8)">macro</span> <span style="color:var(--vt-palette-8)">│</span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Named ranges <span style="color:var(--vt-palette-8)">│</span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Define named range <span style="color:var(--vt-palette-8)">│</span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Protect sheets and ranges <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>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Data validation <span style="color:var(--vt-palette-8)">│</span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Remove</span> <span style="color:var(--vt-palette-8)">data</span> <span style="color:var(--vt-palette-8)">validation</span> <span style="color:var(--vt-palette-8)">│</span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────┌──────────────────────────────────────────┐</span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Trace precedents <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Record</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">macro</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Trace dependents <span style="color:var(--vt-palette-8)">│</span> Record macro with relative references <span style="color:var(--vt-palette-8)">│</span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────│</span> <span style="color:var(--vt-palette-8)">Stop</span> <span style="color:var(--vt-palette-8)">and</span> <span style="color:var(--vt-palette-8)">save</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color:var(--vt-palette-8)">│</span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Macros</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Discard</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color:var(--vt-palette-8)">│</span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Shell <span style="color:var(--vt-palette-8)">├──────────────────────────────────────────┤</span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Shell regions <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Run</span> <span style="color:var(--vt-palette-8)">macro</span> <span style="color:var(--vt-palette-8)">│</span>2727 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────│</span> Manage macros <span style="color:var(--vt-palette-8)">│</span>2828 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span> <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> Write a macro <span style="color:var(--vt-palette-8)">│</span>2929 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────└──────────────────────────────────────────┘</span>
A e2e/testdata/screens/notebook-completion-high-contrast.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">F</span><span style="color:#ffffff;background:#1f1f1f">ile</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">E</span><span style="color:#ffffff;background:#1f1f1f">dit</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">V</span><span style="color:#ffffff;background:#1f1f1f">iew</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">I</span><span style="color:#ffffff;background:#1f1f1f">nsert</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">F</span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">o</span><span style="color:#ffffff;background:#1f1f1f">rmat</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">D</span><span style="color:#ffffff;background:#1f1f1f">ata</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">H</span><span style="color:#ffffff;background:#1f1f1f">elp</span><span style="background:#1f1f1f"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">NU</span><span style="background:#40e0ff"> </span>2+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">A1</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">nu❯</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">$r1</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">|</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">so</span><span style="background:#000000"> </span>3+<span style="color:#959595;background:#000000">r1:</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">5</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">rows</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Esc</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">back</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">to</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">the</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">grid</span>4+<span style="background:#3a3a3a"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">A</span><span style="background:#40e0ff"> </span><span style="color:#757575;background:#000000">┌─</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">Completions</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">────────────────┐</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">E</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">G</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">H</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">I</span><span style="background:#3a3a3a"> </span>5+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">1</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">r1</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">l</span><span style="color:#757575;background:#000000">│</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">sort-by</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">command</span><span style="background:#40e0ff"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span>6+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">2</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">name</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">└─────────────────────</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">1</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">of</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">1</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">─┘</span><span style="background:#000000"> </span>7+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">3</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">012</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">4.2</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>8+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">4</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">CLAUDE.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">1.0</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>9+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">5</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">README.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">9.8</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>10+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">6</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">demos</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">dir</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">3.1</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>11+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">7</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">go.mod</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">812</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">B</span><span style="background:#000000"> </span>12+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">8</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>13+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">9</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>14+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">10</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>15+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">11</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>16+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">12</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>17+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">13</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>18+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">14</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>19+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">15</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>20+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">16</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>21+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">17</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>22+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">18</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>23+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">19</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>24+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">20</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>25+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">21</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>26+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">22</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>27+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">23</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>28+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">24</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>29+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">25</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>30+<span style="color:#ffffff;background:#1f1f1f">command</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Tab</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">complete</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+N</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">next</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Enter</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">run</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Esc</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">grid</span></pre>\ No newline at end of file
A e2e/testdata/screens/notebook-completion-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">NU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A1</span><span style="background:var(--vt-palette-7)"> </span> <span style="font-weight:bold">nu❯</span> $r1 | so3+<span style="color:var(--vt-palette-0)">r1:</span> <span style="color:var(--vt-palette-0)">5</span> <span style="color:var(--vt-palette-0)">rows</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">back</span> <span style="color:var(--vt-palette-0)">to</span> <span style="color:var(--vt-palette-0)">the</span> <span style="color:var(--vt-palette-0)">grid</span>4+<span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Completions</span> <span style="color:var(--vt-palette-8)">────────────────┐</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">r1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">l</span><span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">sort-by</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">command</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> name <span style="color:var(--vt-palette-8)">└─────────────────────</span> <span style="color:var(--vt-palette-0)">1</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">1</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> 012 file 4.2 kB8+<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> CLAUDE.md file 1.0 kB9+<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> README.md file 9.8 kB10+<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> demos dir 3.1 kB11+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span> go.mod file 812 B12+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30+command <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Tab</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">complete</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+N</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">next</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">run</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">grid</span></pre>\ No newline at end of file
A e2e/testdata/screens/notebook-completion.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">NU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A1</span><span style="background:var(--vt-palette-8)"> </span> <span style="font-weight:bold">nu❯</span> $r1 | so3+<span style="color:var(--vt-palette-8)">r1:</span> <span style="color:var(--vt-palette-8)">5</span> <span style="color:var(--vt-palette-8)">rows</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">back</span> <span style="color:var(--vt-palette-8)">to</span> <span style="color:var(--vt-palette-8)">the</span> <span style="color:var(--vt-palette-8)">grid</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Completions</span> <span style="color:var(--vt-palette-8)">────────────────┐</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">r1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">l</span><span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">sort-by</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">command</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> name <span style="color:var(--vt-palette-8)">└─────────────────────</span> <span style="color:var(--vt-palette-8)">1</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">1</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> 012 file 4.2 kB8+<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> CLAUDE.md file 1.0 kB9+<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> README.md file 9.8 kB10+<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> demos dir 3.1 kB11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span> go.mod file 812 B12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+command <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Tab</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">complete</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+N</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">next</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)">grid</span></pre>\ No newline at end of file
A e2e/testdata/screens/notebook-failed-high-contrast.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">F</span><span style="color:#ffffff;background:#1f1f1f">ile</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">E</span><span style="color:#ffffff;background:#1f1f1f">dit</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">V</span><span style="color:#ffffff;background:#1f1f1f">iew</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">I</span><span style="color:#ffffff;background:#1f1f1f">nsert</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">F</span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">o</span><span style="color:#ffffff;background:#1f1f1f">rmat</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">D</span><span style="color:#ffffff;background:#1f1f1f">ata</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">H</span><span style="color:#ffffff;background:#1f1f1f">elp</span><span style="background:#1f1f1f"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">NU</span><span style="background:#40e0ff"> </span>2+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">A9</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">nu❯</span><span style="background:#000000"> </span>3+<span style="color:#ffe040;background:#000000">r2</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">failed:</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">Command</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">`lss`</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">not</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">found</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Esc</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">back</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">to</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">the</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">grid</span>4+<span style="background:#3a3a3a"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">A</span><span style="background:#40e0ff"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">B</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">C</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">D</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">E</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">G</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">H</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">I</span><span style="background:#3a3a3a"> </span>5+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">1</span><span style="background:#3a3a3a"> </span><span style="background:#000000;text-decoration:underline"> </span><span style="color:#959595;background:#000000;text-decoration:underline">r1</span><span style="background:#000000;text-decoration:underline"> </span><span style="color:#959595;background:#000000;text-decoration:underline">ls</span><span style="background:#000000;text-decoration:underline"> </span><span style="background:#000000"> </span>6+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">2</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">name</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">type</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">size</span><span style="background:#000000"> </span>7+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">3</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">012</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">4.2</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>8+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">4</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">CLAUDE.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">1.0</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>9+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">5</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">README.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">9.8</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>10+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">6</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">demos</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">dir</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">3.1</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>11+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">7</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">go.mod</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">812</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">B</span><span style="background:#000000"> </span>12+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">8</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>13+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">9</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">r2</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">lss</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">-</span><span style="color:#959595;background:#000000;text-decoration:underline">a</span><span style="background:#000000;text-decoration:underline"> </span><span style="color:#959595;background:#000000;text-decoration:underline">failed</span><span style="background:#000000"> </span>14+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">10</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>15+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">11</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>16+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">12</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>17+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">13</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>18+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">14</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>19+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">15</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>20+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">16</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>21+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">17</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>22+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">18</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>23+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">19</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>24+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">20</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>25+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">21</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>26+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">22</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>27+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">23</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>28+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">24</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>29+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">25</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>30+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">Shell</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">1</span><span style="background:#40e0ff"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">+</span><span style="background:#1f1f1f"> </span><span style="color:#757575;background:#1f1f1f">│</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">untitled</span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">modified</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+K</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">search</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+/</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">shortcuts</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F10</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/notebook-failed-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">NU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A9</span><span style="background:var(--vt-palette-7)"> </span> <span style="font-weight:bold">nu❯</span>3+<span style="color:var(--vt-palette-3)">r2</span> <span style="color:var(--vt-palette-3)">failed:</span> <span style="color:var(--vt-palette-3)">Command</span> <span style="color:var(--vt-palette-3)">`lss`</span> <span style="color:var(--vt-palette-3)">not</span> <span style="color:var(--vt-palette-3)">found</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">back</span> <span style="color:var(--vt-palette-0)">to</span> <span style="color:var(--vt-palette-0)">the</span> <span style="color:var(--vt-palette-0)">grid</span>4+<span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-0);text-decoration:underline">r1</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-0);text-decoration:underline">ls</span><span style="text-decoration:underline"> </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> name type size7+<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> 012 file 4.2 kB8+<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> CLAUDE.md file 1.0 kB9+<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> README.md file 9.8 kB10+<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> demos dir 3.1 kB11+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span> go.mod file 812 B12+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span>13+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">9</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">r2</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">lss</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">-</span><span style="color:var(--vt-palette-0);text-decoration:underline">a</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-0);text-decoration:underline">failed</span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Shell</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-0)">modified</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+K</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">search</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+/</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">shortcuts</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/notebook-failed.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">NU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A9</span><span style="background:var(--vt-palette-8)"> </span> <span style="font-weight:bold">nu❯</span>3+<span style="color:var(--vt-palette-3)">r2</span> <span style="color:var(--vt-palette-3)">failed:</span> <span style="color:var(--vt-palette-3)">Command</span> <span style="color:var(--vt-palette-3)">`lss`</span> <span style="color:var(--vt-palette-3)">not</span> <span style="color:var(--vt-palette-3)">found</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">back</span> <span style="color:var(--vt-palette-8)">to</span> <span style="color:var(--vt-palette-8)">the</span> <span style="color:var(--vt-palette-8)">grid</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-8);text-decoration:underline">r1</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-8);text-decoration:underline">ls</span><span style="text-decoration:underline"> </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> name type size7+<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> 012 file 4.2 kB8+<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> CLAUDE.md file 1.0 kB9+<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> README.md file 9.8 kB10+<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> demos dir 3.1 kB11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span> go.mod file 812 B12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">9</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">r2</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">lss</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">-</span><span style="color:var(--vt-palette-8);text-decoration:underline">a</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-8);text-decoration:underline">failed</span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Shell</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/notebook-regions-high-contrast.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">F</span><span style="color:#ffffff;background:#1f1f1f">ile</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">E</span><span style="color:#ffffff;background:#1f1f1f">dit</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">V</span><span style="color:#ffffff;background:#1f1f1f">iew</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">I</span><span style="color:#ffffff;background:#1f1f1f">nsert</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">F</span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">o</span><span style="color:#ffffff;background:#1f1f1f">rmat</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">D</span><span style="color:#ffffff;background:#1f1f1f">ata</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">H</span><span style="color:#ffffff;background:#1f1f1f">elp</span><span style="background:#1f1f1f"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">READY</span><span style="background:#40e0ff"> </span>2+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">C11</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">nu❯</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">$r1</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">|</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">where</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">size</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">&gt;</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">1kb</span><span style="background:#000000"> </span>3+<span style="color:#959595;background:#000000">Region</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">big</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">of</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">$r1</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">|</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">where</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">size</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">&gt;</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">1kb</span><span style="background:#000000"> </span>4+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">A</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">B</span><span style="background:#3a3a3a"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">C</span><span style="background:#40e0ff"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">D</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">E</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">G</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">H</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">I</span><span style="background:#3a3a3a"> </span>5+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">1</span><span style="background:#3a3a3a"> </span><span style="background:#000000;text-decoration:underline"> </span><span style="color:#959595;background:#000000;text-decoration:underline">r1</span><span style="background:#000000;text-decoration:underline"> </span><span style="color:#959595;background:#000000;text-decoration:underline">ls</span><span style="background:#000000;text-decoration:underline"> </span><span style="background:#000000"> </span>6+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">2</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">name</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">type</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">size</span><span style="background:#000000"> </span>7+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">3</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">012</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">4.2</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>8+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">4</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">CLAUDE.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">1.0</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>9+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">5</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">README.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">9.8</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>10+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">6</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">demos</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">dir</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">3.1</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>11+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">7</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">go.mod</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">812</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">B</span><span style="background:#000000"> </span>12+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">8</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>13+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">9</span><span style="background:#3a3a3a"> </span><span style="background:#000000;text-decoration:underline"> </span><span style="color:#959595;background:#000000;text-decoration:underline">big</span><span style="background:#000000;text-decoration:underline"> </span><span style="color:#959595;background:#000000;text-decoration:underline">$r1</span><span style="background:#000000;text-decoration:underline"> </span><span style="color:#959595;background:#000000;text-decoration:underline">|</span><span style="background:#000000;text-decoration:underline"> </span><span style="color:#959595;background:#000000;text-decoration:underline">where</span><span style="background:#000000;text-decoration:underline"> </span><span style="color:#959595;background:#000000;text-decoration:underline">size</span><span style="background:#000000;text-decoration:underline"> </span><span style="color:#959595;background:#000000;text-decoration:underline">&gt;</span><span style="background:#000000;text-decoration:underline"> </span><span style="color:#959595;background:#000000;text-decoration:underline">1kb</span><span style="background:#000000;text-decoration:underline"> </span><span style="background:#000000"> </span>14+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">10</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">name</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">type</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">size</span><span style="background:#000000"> </span>15+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">11</span><span style="background:#40e0ff"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">012</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">4.2</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">kB</span><span style="background:#40e0ff"> </span><span style="background:#000000"> </span>16+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">12</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">README.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">9.8</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>17+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">13</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">demos</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">dir</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">3.1</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>18+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">14</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>19+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">15</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>20+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">16</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>21+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">17</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>22+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">18</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>23+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">19</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>24+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">20</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>25+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">21</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>26+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">22</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>27+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">23</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>28+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">24</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>29+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">25</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>30+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">Shell</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">1</span><span style="background:#40e0ff"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">+</span><span style="background:#1f1f1f"> </span><span style="color:#757575;background:#1f1f1f">│</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">untitled</span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">modified</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+K</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">search</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+/</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">shortcuts</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F10</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/notebook-regions-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C11</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">nu❯</span> <span style="color:var(--vt-palette-0)">$r1</span> <span style="color:var(--vt-palette-0)">|</span> <span style="color:var(--vt-palette-0)">where</span> <span style="color:var(--vt-palette-0)">size</span> <span style="color:var(--vt-palette-0)">&gt;</span> <span style="color:var(--vt-palette-0)">1kb</span>3+<span style="color:var(--vt-palette-0)">Region</span> <span style="color:var(--vt-palette-0)">big</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">$r1</span> <span style="color:var(--vt-palette-0)">|</span> <span style="color:var(--vt-palette-0)">where</span> <span style="color:var(--vt-palette-0)">size</span> <span style="color:var(--vt-palette-0)">&gt;</span> <span style="color:var(--vt-palette-0)">1kb</span>4+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B</span><span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">C</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-0);text-decoration:underline">r1</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-0);text-decoration:underline">ls</span><span style="text-decoration:underline"> </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> name type size7+<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> 012 file 4.2 kB8+<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> CLAUDE.md file 1.0 kB9+<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> README.md file 9.8 kB10+<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> demos dir 3.1 kB11+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span> go.mod file 812 B12+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-0);text-decoration:underline">big</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-0);text-decoration:underline">$r1</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-0);text-decoration:underline">|</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-0);text-decoration:underline">where</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-0);text-decoration:underline">size</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-0);text-decoration:underline">&gt;</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-0);text-decoration:underline">1kb</span><span style="text-decoration:underline"> </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> name type size15+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">11</span><span style="background:var(--vt-palette-6)"> </span> 012 file <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">4.2</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">kB</span><span style="background:var(--vt-palette-6)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span> README.md file 9.8 kB17+<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> demos dir 3.1 kB18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Shell</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-0)">modified</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+K</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">search</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+/</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">shortcuts</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/notebook-regions.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">nu❯</span> <span style="color:var(--vt-palette-8)">$r1</span> <span style="color:var(--vt-palette-8)">|</span> <span style="color:var(--vt-palette-8)">where</span> <span style="color:var(--vt-palette-8)">size</span> <span style="color:var(--vt-palette-8)">&gt;</span> <span style="color:var(--vt-palette-8)">1kb</span>3+<span style="color:var(--vt-palette-8)">Region</span> <span style="color:var(--vt-palette-8)">big</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">$r1</span> <span style="color:var(--vt-palette-8)">|</span> <span style="color:var(--vt-palette-8)">where</span> <span style="color:var(--vt-palette-8)">size</span> <span style="color:var(--vt-palette-8)">&gt;</span> <span style="color:var(--vt-palette-8)">1kb</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">C</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-8);text-decoration:underline">r1</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-8);text-decoration:underline">ls</span><span style="text-decoration:underline"> </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> name type size7+<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> 012 file 4.2 kB8+<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> CLAUDE.md file 1.0 kB9+<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> README.md file 9.8 kB10+<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> demos dir 3.1 kB11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span> go.mod file 812 B12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-8);text-decoration:underline">big</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-8);text-decoration:underline">$r1</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-8);text-decoration:underline">|</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-8);text-decoration:underline">where</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-8);text-decoration:underline">size</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-8);text-decoration:underline">&gt;</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-8);text-decoration:underline">1kb</span><span style="text-decoration:underline"> </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> name type size15+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">11</span><span style="background:var(--vt-palette-6)"> </span> 012 file <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">4.2</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">kB</span><span style="background:var(--vt-palette-6)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> README.md file 9.8 kB17+<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> demos dir 3.1 kB18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Shell</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file
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)">44</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">180</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)">48</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">182</span> <span style="color:var(--vt-palette-8)">─┘</span>1616 Save the sheet u… <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/palette-search-light.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> 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>1515 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> 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>1616 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> 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)">30</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">180</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)">32</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">182</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>1919 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>2020 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>
M e2e/testdata/screens/palette-search.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> 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>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> 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>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> 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)">30</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">180</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)">32</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">182</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 +8 -8
@@ -7,14 +7,14 @@
77 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent 1450 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Select</span> <span style="color:var(--vt-palette-8)">chart</span> <span style="color:var(--vt-palette-8)">│</span>88 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Groceries 612.4 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">Sel</span>ect columns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Space</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>99 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Transit 96 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Move</span> <span style="color:var(--vt-palette-8)">sheet</span> <span style="color:var(--vt-palette-8)">left</span> <span style="color:var(--vt-palette-8)">Edit</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Sheet</span> <span style="color:var(--vt-palette-8)">│</span>10-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span> <span style="color:var(--vt-palette-8)">│</span> De<span style="color:var(--vt-palette-3);font-weight:bold">sel</span>ect <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>11-<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">7</span><span style="background:var(--vt-palette-6)"> </span> Total <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">2158.4</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt columns <span style="color:var(--vt-palette-3);font-weight:bold">l</span>eft <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>12-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>av<span style="color:var(--vt-palette-3);font-weight:bold">e</span> <span style="color:var(--vt-palette-8)">Fi</span><span style="color:var(--vt-palette-3);font-weight:bold">l</span><span style="color:var(--vt-palette-8)">e</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+S</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>13-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>av<span style="color:var(--vt-palette-3);font-weight:bold">e</span> as <span style="color:var(--vt-palette-8)">Fi</span><span style="color:var(--vt-palette-3);font-weight:bold">l</span><span style="color:var(--vt-palette-8)">e</span> <span style="color:var(--vt-palette-8)">│</span>14-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt rows be<span style="color:var(--vt-palette-3);font-weight:bold">l</span>ow <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>15-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Re<span style="color:var(--vt-palette-3);font-weight:bold">se</span>t co<span style="color:var(--vt-palette-3);font-weight:bold">l</span>umn width <span style="color:var(--vt-palette-8)">Format</span> <span style="color:var(--vt-palette-8)">│</span>16-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt co<span style="color:var(--vt-palette-3);font-weight:bold">l</span>umns right <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">25</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">180</span> <span style="color:var(--vt-palette-8)">─┘</span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>h<span style="color:var(--vt-palette-3);font-weight:bold">el</span>l <span style="color:var(--vt-palette-8)">Data</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">!</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>11+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">7</span><span style="background:var(--vt-palette-6)"> </span> Total <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">2158.4</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">│</span> De<span style="color:var(--vt-palette-3);font-weight:bold">sel</span>ect <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>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> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt columns <span style="color:var(--vt-palette-3);font-weight:bold">l</span>eft <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>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)">Stop</span> <span style="color:var(--vt-palette-8)">shell</span> <span style="color:var(--vt-palette-8)">command</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Shell</span> <span style="color:var(--vt-palette-8)">regio…</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> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>av<span style="color:var(--vt-palette-3);font-weight:bold">e</span> <span style="color:var(--vt-palette-8)">Fi</span><span style="color:var(--vt-palette-3);font-weight:bold">l</span><span style="color:var(--vt-palette-8)">e</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+S</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>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> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>av<span style="color:var(--vt-palette-3);font-weight:bold">e</span> as <span style="color:var(--vt-palette-8)">Fi</span><span style="color:var(--vt-palette-3);font-weight:bold">l</span><span style="color:var(--vt-palette-8)">e</span> <span style="color:var(--vt-palette-8)">│</span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt 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>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)">31</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">182</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> Download as JSON <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>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> Download as NUON <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>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> 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>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)">180</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">180</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)">182</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">182</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 internal/config/registry.go +15 -1
@@ -75,11 +75,12 @@ const (
7575 GroupTelemetry = "Telemetry"7676 GroupServe = "012 serve"7777 GroupData = "Data"78+ GroupShell = "Nushell notebooks"7879 GroupFiles = "Config files"7980 )8081 8182 // Groups is the order groups are listed in.82-var Groups = []string{GroupAppearance, GroupData, GroupJEV, GroupTelemetry, GroupServe, GroupFiles}83+var Groups = []string{GroupAppearance, GroupData, GroupShell, GroupJEV, GroupTelemetry, GroupServe, GroupFiles}8384 8485 // Options is every setting. Add an option here and read it with8586 // Config.String, Bool or List; parsing, `012 config`, docs/reference/config.md
@@ -117,6 +118,16 @@ var Options = []Option{
117118 "up to the budget and say how many they left out; larger pastes and fills are refused. " +118119 "The grid itself is 1,048,576 rows by 16,384 columns (A to XFD) whatever this is."},119120 121+ {Name: "shell", Kind: Enum, Group: GroupShell, Default: "ask", Values: []string{"off", "ask", "on"}, Env: []string{"O12_SHELL"}, Live: true,122+ Desc: "Whether notebook sheets run nushell commands (docs/terminal/nushell.md). `ask` runs what you type " +123+ "and asks once before running the commands of a file made on another computer; `on` never asks; " +124+ "`off` runs none. Opening a file never runs its commands."},125+ {Name: "nu-timeout", Kind: Duration, Group: GroupShell, Default: "30s", Env: []string{"O12_NU_TIMEOUT"}, Live: true,126+ Desc: "Stop a notebook's command that runs longer than this; 0 lets it run until Esc stops it."},127+ {Name: "nu-config", Kind: Bool, Group: GroupShell, Default: "false", Env: []string{"O12_NU_CONFIG"}, Live: true,128+ Desc: "Run notebook commands with your nushell config files (config.nu, env.nu) rather than " +129+ "`nu --no-config-file`, for your own commands and aliases."},130+120131 {Name: "jev-api-key-command", Kind: Command, Group: GroupJEV,121132 Desc: "A command that prints the TypeSafe API key, used when TYPESAFE_API_KEY isn't set and the " +122133 "credential store has no key, e.g. `op read op://Private/TypeSafe/credential` or " +
@@ -153,6 +164,9 @@ var Options = []Option{
153164 Desc: "End a 012 serve session that has had no input for this long; 0 never does."},154165 {Name: "serve-max-sessions", Kind: Int, Group: GroupServe, Default: "8",155166 Desc: "How many 012 serve sessions may run at once; more are turned away."},167+ {Name: "serve-shell", Kind: Bool, Group: GroupServe, Default: "false",168+ Desc: "Let 012 serve sessions run notebooks' nushell commands, as the user 012 serve runs as, " +169+ "following the shell option. Off, served notebooks show their regions but run nothing."},156170 157171 {Name: "config-file", Kind: Path, Group: GroupFiles, Repeat: true,158172 Desc: "Read another config file after this one, relative to this file's directory. " +
M internal/fileio/nuon_test.go +44 -0
@@ -156,6 +156,50 @@ func TestEncodeKeepsTypes(t *testing.T) {
156156 }157157 }158158 159+// Text formats send the rows a filter shows, as Sheets copies a filtered160+// range.161+func TestEncodeFiltered(t *testing.T) {162+ s := build(t, map[string]string{"A1": "name", "B1": "n", "A2": "a", "B2": "1", "A3": "b", "B3": "2", "A4": "c", "B4": "3"})163+ s.CreateFilter(sheet.Rect{To: addr(t, "B4")})164+ s.FilterColumn(0, sheet.Criteria{Hidden: []string{"b"}})165+ if got := encode(t, s, NUON); got != "[[name, n]; [a, 1],\n[c, 3]]\n" {166+ t.Errorf("NUON %q", got)167+ }168+ if got := encode(t, s, CSV); got != "name,n\na,1\nc,3\n" {169+ t.Errorf("CSV %q", got)170+ }171+ if got := encode(t, s, JSON); strings.Contains(got, `"b"`) {172+ t.Errorf("JSON %q", got)173+ }174+}175+176+// A notebook's regions download as their values, labels included.177+func TestExportRegion(t *testing.T) {178+ s := sheet.New()179+ s.MakeNotebook()180+ if err := s.AddRegion(sheet.Region{Name: "r1", Command: "ls"}); err != nil {181+ t.Fatal(err)182+ }183+ d := &sheet.RegionData{Rows: 2, Cols: 2, Values: []sheet.Value{{Kind: sheet.Text, Str: "name"}, {Kind: sheet.Text, Str: "n"}, {Kind: sheet.Text, Str: "a"}, {Kind: sheet.Number, Num: 1}}}184+ if err := s.ShowRegion("r1", d); err != nil {185+ t.Fatal(err)186+ }187+ if got := encode(t, s, CSV); got != "r1 ls,\nname,n\na,1\n" {188+ t.Errorf("CSV %q", got)189+ }190+ path := filepath.Join(t.TempDir(), "nb.xlsx")191+ if _, err := Export(context.Background(), path, XLSX, SnapBook(s), ExportOptions{}); err != nil {192+ t.Fatal(err)193+ }194+ res, err := Import(context.Background(), path, Options{})195+ if err != nil {196+ t.Fatal(err)197+ }198+ if c := res.Sheet.Cell(addr(t, "B3")); c == nil || c.IsFormula() || c.Value.Num != 1 {199+ t.Errorf("B3 read back as %+v", c)200+ }201+}202+159203 // TestNUONRoundTrip reads a table and writes it back as the same NUON.160204 func TestNUONRoundTrip(t *testing.T) {161205 inZone(t, mountain)
M internal/fileio/nuonwrite.go +5 -2
M internal/fileio/snapshot.go +6 -2
A internal/nushell/nushell.go +256 -0
@@ -0,0 +1,256 @@
1+// Package nushell runs a notebook's commands: each command is a nushell2+// pipeline run by `nu` as a separate process, reading other regions'3+// tables as variables ($r1) and the selection as $in, and writing a4+// table back as NUON, which becomes the region's cells. Tables reach nu5+// as NUON in files it opens, never as text spliced into the command.6+// The package knows neither the terminal nor the workbook's regions:7+// the UI hands it a Job and gets back a table.8+package nushell9+10+import (11+ "bufio"12+ "bytes"13+ "context"14+ "errors"15+ "fmt"16+ "io"17+ "os"18+ "os/exec"19+ "slices"20+ "strconv"21+ "strings"22+ "time"23+24+ "github.com/charmbracelet/x/ansi"25+26+ "github.com/FelineStateMachine/012/internal/fileio"27+ "github.com/FelineStateMachine/012/internal/sheet"28+)29+30+// Job is a command to run.31+type Job struct {32+ Command string33+ // Tables are the regions the command reads, by name, as NUON: each34+ // becomes the variable of its name.35+ Tables map[string][]byte36+ // Input is the table the command reads as $in, as NUON, or nil.37+ Input []byte38+ // Config runs nu with the user's config files rather than without.39+ Config bool40+}41+42+// Runner runs a job's script, writing what it prints to stdout. A test43+// substitutes a fake for the real nu.44+type Runner interface {45+ Run(ctx context.Context, job Job, script string, stdout io.Writer) error46+}47+48+// ErrMissing is what running a command says when nu isn't installed.49+var ErrMissing = errors.New("nu isn't installed or isn't on your PATH: see docs/terminal/nushell.md")50+51+// Error is a command that failed: nu's message, and all it wrote to52+// standard error.53+type Error struct {54+ Msg string55+ Stderr string56+}57+58+func (e *Error) Error() string { return e.Msg }59+60+// Script is what nu runs for job: the tables bound to their names, then61+// the command, its output as NUON. The files are named by environment62+// variables (NU012_TABLE_0 and so on) the runner sets, so no data and no63+// path is written into the script.64+func Script(job Job, names []string) string {65+ var b strings.Builder66+ for i, name := range names {67+ fmt.Fprintf(&b, "let %s = (open --raw $env.NU012_TABLE_%d | from nuon)\n", name, i)68+ }69+ if job.Input != nil {70+ b.WriteString("$in | from nuon | ")71+ }72+ b.WriteString("do {\n" + job.Command + "\n} | to nuon\n")73+ return b.String()74+}75+76+// Exec runs job and reads what it prints as a table, keeping at most77+// maxCells cells (0 for the max-cells setting), within timeout.78+func Exec(ctx context.Context, r Runner, job Job, timeout time.Duration, maxCells int) (*sheet.RegionData, error) {79+ if timeout > 0 {80+ var cancel context.CancelFunc81+ ctx, cancel = context.WithTimeoutCause(ctx, timeout, fmt.Errorf("stopped after %s", timeout))82+ defer cancel()83+ }84+ names := sortedNames(job.Tables)85+ pr, pw := io.Pipe()86+ done := make(chan error, 1)87+ go func() {88+ err := r.Run(ctx, job, Script(job, names), pw)89+ pw.CloseWithError(err)90+ done <- err91+ }()92+ data, readErr := readTable(ctx, pr, maxCells)93+ pr.CloseWithError(errors.New("done reading")) // the command stops if it's still writing94+ runErr := <-done95+ switch {96+ case ctx.Err() != nil:97+ return nil, context.Cause(ctx)98+ case runErr != nil:99+ return nil, runErr100+ case readErr != nil:101+ return nil, fmt.Errorf("nu's output isn't NUON: %w", readErr)102+ }103+ return data, nil104+}105+106+// readTable reads NUON as a region's table. A value that isn't a table,107+// record or list (a number, a string, null) is read as a list of one,108+// a table of one column named value.109+func readTable(ctx context.Context, r io.Reader, maxCells int) (*sheet.RegionData, error) {110+ br := bufio.NewReader(r)111+ first, err := firstByte(br)112+ if err != nil {113+ return nil, err114+ }115+ in := io.Reader(br)116+ if first != '[' && first != '{' {117+ in = io.MultiReader(strings.NewReader("["), br, strings.NewReader("]"))118+ }119+ res, err := fileio.ImportReader(ctx, "nu", in, fileio.Options{MaxCells: maxCells})120+ if err != nil {121+ return nil, err122+ }123+ d := sheet.RegionDataOf(res.Sheet)124+ d.Note = strings.Join(res.Notes, "; ")125+ return d, nil126+}127+128+// firstByte is the first byte past spaces, without taking it; 0 when129+// there's nothing else.130+func firstByte(br *bufio.Reader) (byte, error) {131+ for {132+ c, err := br.ReadByte()133+ switch {134+ case errors.Is(err, io.EOF):135+ return 0, nil136+ case err != nil:137+ return 0, err138+ case c == ' ' || c == '\t' || c == '\n' || c == '\r':139+ continue140+ }141+ return c, br.UnreadByte()142+ }143+}144+145+func sortedNames(tables map[string][]byte) []string {146+ names := make([]string, 0, len(tables))147+ for name := range tables {148+ names = append(names, name)149+ }150+ slices.Sort(names)151+ return names152+}153+154+// Commands lists nu's command names, for completing them.155+func Commands(ctx context.Context, r Runner, timeout time.Duration) ([]string, error) {156+ d, err := Exec(ctx, r, Job{Command: "help commands | get name"}, timeout, -1)157+ if err != nil {158+ return nil, err159+ }160+ var out []string161+ for row := 1; row < d.Rows; row++ {162+ if v, _ := d.At(row, 0); v.Kind == sheet.Text {163+ out = append(out, v.Str)164+ }165+ }166+ return out, nil167+}168+169+// Nu is the real runner: `nu -c` in a process of its own.170+type Nu struct {171+ // Path is the program; "" finds nu on the PATH.172+ Path string173+}174+175+// Run implements Runner.176+func (n Nu) Run(ctx context.Context, job Job, script string, stdout io.Writer) error {177+ path := n.Path178+ if path == "" {179+ var err error180+ if path, err = exec.LookPath("nu"); err != nil {181+ return ErrMissing182+ }183+ }184+ dir, err := os.MkdirTemp("", "012-nu-")185+ if err != nil {186+ return err187+ }188+ defer os.RemoveAll(dir)189+ env := append(os.Environ(), "NO_COLOR=1")190+ for i, name := range sortedNames(job.Tables) {191+ file := dir + string(os.PathSeparator) + strconv.Itoa(i) + ".nuon"192+ if err := os.WriteFile(file, job.Tables[name], 0o600); err != nil {193+ return err194+ }195+ env = append(env, "NU012_TABLE_"+strconv.Itoa(i)+"="+file)196+ }197+ args := []string{"-c", script}198+ if !job.Config {199+ args = append([]string{"--no-config-file"}, args...)200+ }201+ if job.Input != nil {202+ args = append([]string{"--stdin"}, args...)203+ }204+ cmd := exec.CommandContext(ctx, path, args...)205+ cmd.Env, cmd.Stdout = env, stdout206+ cmd.WaitDelay = 2 * time.Second207+ if job.Input != nil {208+ cmd.Stdin = bytes.NewReader(job.Input)209+ }210+ var stderr bytes.Buffer211+ cmd.Stderr = &limited{w: &stderr, n: 64 << 10}212+ if err := cmd.Run(); err != nil {213+ if ctx.Err() != nil {214+ return context.Cause(ctx)215+ }216+ text := ansi.Strip(stderr.String())217+ return &Error{Msg: Message(text, err), Stderr: text}218+ }219+ return nil220+}221+222+// Message is the line of nu's standard error that says what went wrong:223+// the one marked ×, or the first, or how the process ended.224+func Message(stderr string, err error) string {225+ first := ""226+ for line := range strings.SplitSeq(stderr, "\n") {227+ line = strings.TrimSpace(line)228+ if rest, ok := strings.CutPrefix(line, "×"); ok {229+ return strings.TrimSpace(rest)230+ }231+ if first == "" && line != "" {232+ first = line233+ }234+ }235+ if first != "" {236+ return first237+ }238+ return err.Error()239+}240+241+// limited writes at most n bytes to w, dropping the rest.242+type limited struct {243+ w io.Writer244+ n int245+}246+247+func (l *limited) Write(p []byte) (int, error) {248+ if l.n > 0 {249+ k := min(len(p), l.n)250+ l.n -= k251+ if _, err := l.w.Write(p[:k]); err != nil {252+ return 0, err253+ }254+ }255+ return len(p), nil256+}
A internal/nushell/nushell_test.go +145 -0
@@ -0,0 +1,145 @@
1+package nushell2+3+import (4+ "context"5+ "errors"6+ "io"7+ "os/exec"8+ "strings"9+ "testing"10+ "time"11+12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// fake prints out, or fails with err, and keeps the script it was given.16+type fake struct {17+ out string18+ err error19+ script string20+ job Job21+}22+23+func (f *fake) Run(ctx context.Context, job Job, script string, stdout io.Writer) error {24+ f.script, f.job = script, job25+ if f.err != nil {26+ return f.err27+ }28+ _, err := io.WriteString(stdout, f.out)29+ return err30+}31+32+func cells(d *sheet.RegionData) [][]string {33+ var out [][]string34+ for r := range d.Rows {35+ var row []string36+ for c := range d.Cols {37+ v, f := d.At(r, c)38+ row = append(row, sheet.FormatText(v, f))39+ }40+ out = append(out, row)41+ }42+ return out43+}44+45+func TestScript(t *testing.T) {46+ got := Script(Job{Command: "$r1 | where size > 1kb", Tables: map[string][]byte{"r1": nil}, Input: []byte("[]")}, []string{"big", "r1"})47+ want := "let big = (open --raw $env.NU012_TABLE_0 | from nuon)\n" +48+ "let r1 = (open --raw $env.NU012_TABLE_1 | from nuon)\n" +49+ "$in | from nuon | do {\n$r1 | where size > 1kb\n} | to nuon\n"50+ if got != want {51+ t.Errorf("script:\n%s\nwant:\n%s", got, want)52+ }53+}54+55+func TestExecReadsTables(t *testing.T) {56+ for _, c := range []struct {57+ out string58+ want string59+ }{60+ {"[[name, size]; [a, 1kb], [b, 2b]]", "name size|a 1.0 kB|b 2 B"},61+ {"{a: 1, b: true}", "a b|1 TRUE"},62+ {"3", "value|3"},63+ {`"hello"`, "value|hello"},64+ {"null", "value"},65+ {"[1, 2]", "value|1|2"},66+ {"", ""},67+ } {68+ d, err := Exec(context.Background(), &fake{out: c.out}, Job{Command: "x"}, time.Second, 0)69+ if err != nil {70+ t.Errorf("%q: %v", c.out, err)71+ continue72+ }73+ var rows []string74+ for _, r := range cells(d) {75+ rows = append(rows, strings.TrimSpace(strings.Join(r, " ")))76+ }77+ if got := strings.Join(rows, "|"); got != c.want {78+ t.Errorf("%q read as %q, want %q", c.out, got, c.want)79+ }80+ }81+}82+83+func TestExecFails(t *testing.T) {84+ _, err := Exec(context.Background(), &fake{err: &Error{Msg: "Cannot find column 'x'"}}, Job{Command: "x"}, time.Second, 0)85+ var e *Error86+ if !errors.As(err, &e) || e.Msg != "Cannot find column 'x'" {87+ t.Errorf("error %v", err)88+ }89+ _, err = Exec(context.Background(), &fake{out: "[[a]; [1"}, Job{Command: "x"}, time.Second, 0)90+ if err == nil || !strings.Contains(err.Error(), "isn't NUON") {91+ t.Errorf("bad output: %v", err)92+ }93+ // Past max-cells, whole rows are kept and the note says so.94+ d, err := Exec(context.Background(), &fake{out: "[[a, b]; [1, 2], [3, 4], [5, 6]]"}, Job{Command: "x"}, time.Second, 4)95+ if err != nil || d.Rows != 2 || d.Note == "" {96+ t.Errorf("capped: %+v %v", d, err)97+ }98+}99+100+func TestMessage(t *testing.T) {101+ stderr := "Error: nu::shell::column_not_found\n\n × Cannot find column 'xyz'\n ╭─[source:1:6]\n"102+ if got := Message(stderr, errors.New("exit status 1")); got != "Cannot find column 'xyz'" {103+ t.Errorf("message %q", got)104+ }105+ if got := Message("", errors.New("exit status 1")); got != "exit status 1" {106+ t.Errorf("message %q", got)107+ }108+}109+110+// The real nu, when it's installed: tables go in as variables and $in,111+// and come back typed.112+func TestNu(t *testing.T) {113+ if _, err := exec.LookPath("nu"); err != nil {114+ t.Skip("nu isn't installed")115+ }116+ job := Job{117+ Command: "$in | append ($r1 | where size > 1kb)",118+ Tables: map[string][]byte{"r1": []byte("[[name, size]; [a, 2kb], [b, 10b]]")},119+ Input: []byte("[[name, size]; [c, 5mb]]"),120+ }121+ d, err := Exec(context.Background(), Nu{}, job, 30*time.Second, 0)122+ if err != nil {123+ t.Fatal(err)124+ }125+ got := cells(d)126+ if len(got) != 3 || got[1][0] != "c" || got[1][1] != "5.0 MB" || got[2][0] != "a" || got[2][1] != "2.0 kB" {127+ t.Errorf("table %v", got)128+ }129+ _, err = Exec(context.Background(), Nu{}, Job{Command: "ls | get nosuchcolumn"}, 30*time.Second, 0)130+ var e *Error131+ if !errors.As(err, &e) || !strings.Contains(e.Msg, "nosuchcolumn") {132+ t.Errorf("failure: %v", err)133+ }134+ _, err = Exec(context.Background(), Nu{}, Job{Command: "sleep 10sec"}, 200*time.Millisecond, 0)135+ if err == nil || !strings.Contains(err.Error(), "stopped after") {136+ t.Errorf("timeout: %v", err)137+ }138+ names, err := Commands(context.Background(), Nu{}, 30*time.Second)139+ if err != nil || len(names) < 100 {140+ t.Errorf("commands: %d %v", len(names), err)141+ }142+ if _, err := Exec(context.Background(), Nu{Path: "/nonexistent/nu"}, Job{Command: "1"}, time.Second, 0); err == nil {143+ t.Error("a missing nu ran")144+ }145+}
M internal/sheet/file.go +23 -8
@@ -56,13 +56,15 @@ type fileFormat struct {
5656 Locale string `json:"locale,omitempty"`5757 // Macros and where they were made need no version bump either:5858 // earlier builds ignore them, and the sheets read the same.59- MacroOrigin string `json:"macroOrigin,omitempty"`60- Macros []fileMacro `json:"macros,omitempty"`59+ MacroOrigin string `json:"macroOrigin,omitempty"`6160 // LinkOrigin, like MacroOrigin, is where the linked regions were6261 // made or trusted (linkfile.go).63- LinkOrigin string `json:"linkOrigin,omitempty"`64- fileSheet // versions 1 to 3: the only sheet65- Sheets []fileSheet `json:"sheets,omitempty"` // version 462+ LinkOrigin string `json:"linkOrigin,omitempty"`63+ // ShellHistory needs no version either: see shellhistory.go.64+ ShellHistory []string `json:"shellHistory,omitempty"`65+ Macros []fileMacro `json:"macros,omitempty"`66+ fileSheet // versions 1 to 3: the only sheet67+ Sheets []fileSheet `json:"sheets,omitempty"` // version 46668 }6769 6870 // fileSheet is one sheet of a file.
@@ -83,6 +85,9 @@ type fileSheet struct {
8385 Validations []fileValidation `json:"validations,omitempty"`8486 // Links need no version: see linkfile.go.8587 Links []fileLink `json:"links,omitempty"`88+ // A notebook and its regions need no version: see regionfile.go.89+ Notebook bool `json:"notebook,omitempty"`90+ Regions []fileRegion `json:"regions,omitempty"`8691 }8792 8893 // Write saves the workbook the sheet belongs to; see Workbook.Write.
@@ -164,15 +169,19 @@ func (w *Workbook) headLines() string {
164169 if w.locale != nil {165170 lines = append(lines, `"locale": `+jsonString(w.locale.Tag))166171 }172+ if w.macroOrigin != "" && (len(w.macros) > 0 || len(w.allRegions()) > 0) {173+ lines = append(lines, `"macroOrigin": `+jsonString(w.macroOrigin))174+ }167175 if len(w.macros) > 0 {168- if w.macroOrigin != "" {169- lines = append(lines, `"macroOrigin": `+jsonString(w.macroOrigin))170- }171176 lines = append(lines, w.macrosLines())172177 }173178 if w.linkOrigin != "" && len(w.LinkedRegions()) > 0 {174179 lines = append(lines, `"linkOrigin": `+jsonString(w.linkOrigin))175180 }181+ if len(w.shellHistory) > 0 {182+ raw, _ := json.Marshal(w.shellHistory)183+ lines = append(lines, `"shellHistory": `+string(raw))184+ }176185 return strings.Join(lines, ",\n ")177186 }178187
@@ -274,6 +283,9 @@ func (s *Sheet) writeObjects(b *bufio.Writer, indent string) error {
274283 fmt.Fprintf(b, ",\n%s\"pivot\": %s", indent, raw)275284 }276285 s.writeRules(b, indent)286+ if err := s.writeRegionDefs(b, indent); err != nil {287+ return err288+ }277289 return s.writeLinks(b, indent)278290 }279291
@@ -365,5 +377,8 @@ func (s *Sheet) read(f fileSheet) error {
365377 if err := s.readLinks(f.Links); err != nil {366378 return err367379 }380+ if err := s.readRegions(f.Notebook, f.Regions); err != nil {381+ return err382+ }368383 return s.readView(f.fileView)369384 }
M internal/sheet/fileread.go +1 -0
M internal/sheet/filestream_test.go +7 -0
@@ -64,6 +64,7 @@ func readWhole(data []byte) (w *Workbook, ambiguous bool, err error) {
6464 return nil, false, err6565 }6666 w.macroOrigin, w.linkOrigin = f.MacroOrigin, f.LinkOrigin67+ w.readShellHistory(f.ShellHistory)6768 w.RecalcAll()6869 return w, ambiguous, nil6970 }
@@ -236,6 +237,12 @@ func streamSeeds(t testing.TB) [][]byte {
236237 []byte(`{"version": 2, "linkOrigin": "m", "cells": {"C1": "x"}, "links": [{"at": "A1", "path": "logs/app.csv", "window": 3}, {"at": "E5", "path": "/tmp/t.db", "format": "SQLite", "query": "select 1"}]}`),237238 []byte(`{"version": 4, "sheets": [{"name": "Log", "cells": {}, "links": [{"at": "B2", "path": "a.nuon"}]}, {"name": "S", "cells": {"A1": "=SUM(Log!B:B)"}}]}`),238239 []byte(`{"version": 2, "cells": {}, "links": [{"at": "A1", "path": "a"}, {"at": "A1", "path": "b"}, {"at": "", "window": -2}]}`),240+ []byte(`{"version": 2, "macroOrigin": "m1", "shellHistory": ["ls", "$r1 | first"], "notebook": true, "cells": {"A3": {"bold": true}}, "regions": [241+ {"name": "r1", "command": "ls", "at": "A1", "rows": 3, "cols": 2},242+ {"name": "big", "command": "$r1 | where size > 1kb", "at": "A6", "rows": 2, "cols": 2, "reads": ["r1"], "input": "Sheet1!D1:E4", "sort": [{"column": 2, "desc": true}]}]}`),243+ []byte(`{"version": 2, "regions": [{"name": "r1", "command": "ls", "at": "A1"}, {"name": "R1", "command": "x", "at": "A5"}]}`),244+ []byte(`{"version": 2, "regions": [{"name": "in", "command": "ls", "at": "A1"}, {"name": "r2", "at": "ZZZZ1", "rows": -1}]}`),245+ []byte(`{"version": 2, "regions": [{"name": "a", "command": "$b", "at": "A1", "reads": ["b"]}, {"name": "b", "command": "$a", "at": "A3", "reads": ["a"], "sort": [{"column": 0}]}]}`),239246 []byte(`null`), []byte(`[]`), []byte(`{"version": 2, "cells": {"A1": "1",}}`), []byte(`{"version": 2, "cells": {"A1"`),240247 }241248 }
M internal/sheet/history.go +11 -3
@@ -61,6 +61,8 @@ type step struct {
6161 rules map[*Sheet]rulesState6262 // links holds each touched sheet's linked regions before the step.6363 links map[*Sheet][]*linked64+ // regions holds each touched sheet's regions before the step.65+ regions map[*Sheet]regionState6466 // sheets is the sheet list before the step, when it changed.6567 sheets *sheetList6668 // settings are the workbook's settings before the step, when they
@@ -78,7 +80,8 @@ type colKey struct {
7880 7981 func newStep(label string, s *Sheet, focus Rect) *step {8082 return &step{label: label, sheet: s, focus: focus, cells: map[*Sheet]*image{}, widths: map[colKey]int{}, lines: map[lineKey]lineFmt{},81- names: map[string]*Name{}, views: map[*Sheet]*viewState{}, charts: map[*Sheet][]Chart{}, pivots: map[*Sheet]*Pivot{}, rules: map[*Sheet]rulesState{}, links: map[*Sheet][]*linked{}}83+ names: map[string]*Name{}, views: map[*Sheet]*viewState{}, charts: map[*Sheet][]Chart{}, pivots: map[*Sheet]*Pivot{}, rules: map[*Sheet]rulesState{},84+ regions: map[*Sheet]regionState{}, links: map[*Sheet][]*linked{}}8285 }8386 8487 func (st *step) empty() bool {
@@ -89,7 +92,7 @@ func (st *step) empty() bool {
8992 // and row heights.9093 func (st *step) widthOnly() bool {9194 return len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && len(st.links) == 0 && st.sheets == nil && st.settings == nil &&92- st.macros == nil95+ st.macros == nil && len(st.regions) == 09396 }9497 9598 func (c *Cell) clone() *Cell {
@@ -234,6 +237,11 @@ func (w *Workbook) dropUnchanged(st *step) {
234237 delete(st.links, s)235238 }236239 }240+ for s, r := range st.regions {241+ if sameRegions(r, s.regions) {242+ delete(st.regions, s)243+ }244+ }237245 if st.sheets != nil && st.sheets.equal(w.sheetList()) {238246 st.sheets = nil239247 }
@@ -388,6 +396,6 @@ func (w *Workbook) swap(undo bool) (Change, bool) {
388396 w.pushUndo(inv)389397 }390398 h.mergeWidths = false391- macrosOnly := st.macros != nil && len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && len(st.links) == 0 && st.sheets == nil399+ macrosOnly := st.macros != nil && len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && len(st.links) == 0 && st.sheets == nil && len(st.regions) == 0392400 return Change{Label: st.label, Focus: st.focus, Sheet: st.sheet, Tabs: st.sheets != nil, Macros: macrosOnly}, true393401 }
M internal/sheet/historyrestore.go +5 -0
M internal/sheet/historysize.go +3 -0
M internal/sheet/names.go +6 -2
M internal/sheet/ops.go +1 -0
M internal/sheet/recalc.go +3 -1
A internal/sheet/region.go +458 -0
@@ -0,0 +1,458 @@
1+package sheet2+3+import (4+ "errors"5+ "fmt"6+ "maps"7+ "slices"8+ "strconv"9+ "strings"10+)11+12+// Regions are blocks of cells whose values come from outside the13+// engine: a nushell command's output on a notebook sheet, and in time a14+// file followed as it grows. A region is an anchor (its label line, At)15+// and a definition saying where its table comes from (Command, opaque to16+// the engine); what fills it arrives as a RegionData the UI hands over17+// (ShowRegion). Like a spill, its cells are derived: written by the18+// engine outside the undo history, refused by Set, values to formulas,19+// copies and exports, formatting kept, never saved. The definitions and20+// the data shown are undo steps, so undoing a run shows the table it21+// replaced; the file keeps the definitions only.22+//23+// On a notebook sheet regions stack in run order, a gap row between24+// them: a new one goes below everything on the sheet, and a table that25+// grows or shrinks inserts or deletes rows under it, so what's below26+// moves as rows inserted by hand would move it.27+//28+// In formulas a region is a name: nu. and its own (=SUM(nu.r1)), which29+// stands for its table, header row included.30+31+// Region is a region's definition.32+type Region struct {33+ // Name identifies the region in the workbook, ignoring case: r1, or34+ // a name the user gave. Commands read other regions as $name.35+ Name string36+ // Command is what fills the region: for a notebook, a nushell37+ // pipeline.38+ Command string39+ // At is the label line's first cell; the table starts below it.40+ At Addr41+ // Rows and Cols are the table's size as last shown, header row42+ // included, so a region not run yet keeps its place.43+ Rows, Cols int44+ // Deps are the regions the command reads, in the order it names45+ // them.46+ Deps []string47+ // Input is the range the command reads as $in, qualified with its48+ // sheet ("Sheet1!A1:C9"), or "".49+ Input string50+ // Sort orders the table's rows below its header, by columns counted51+ // from the region's first.52+ Sort []SortKey53+}54+55+// regionState is what a sheet keeps of its regions in the undo history:56+// the definitions in run order and the data each shows, by name key.57+// Both are replaced whole, never changed in place.58+type regionState struct {59+ notebook bool60+ list []Region61+ data map[string]*RegionData62+}63+64+// regionMeta is what isn't undone: what the region's label says it's65+// doing, and the cells written last.66+type regionMeta struct {67+ status string68+ written Rect69+ has bool // written holds something70+ why string // why the table isn't shown, or ""71+}72+73+// Errors of regions.74+var (75+ // ErrRegionEdit is what typing into a region's cell says.76+ ErrRegionEdit = errors.New("That cell is part of a shell region: change its command, or Freeze it to edit its values")77+ errNoRegion = errors.New("There's no region by that name")78+)79+80+// regionPrefix starts a region's name in formulas.81+const regionPrefix = "nu."82+83+// FormulaName is how formulas name the region: nu.r1.84+func (r Region) FormulaName() string { return regionPrefix + r.Name }85+86+// reservedRegionNames are nushell's own variables.87+var reservedRegionNames = []string{"in", "env", "nu", "it"}88+89+// ValidRegionName checks that name can name a region: a nushell90+// variable name of letters, digits and _, not starting with a digit.91+func ValidRegionName(name string) error {92+ switch {93+ case name == "":94+ return errors.New("Enter a name")95+ case len(name) > 64:96+ return errors.New("A region's name can be at most 64 characters")97+ case isDigit(name[0]):98+ return errors.New("A region's name can't start with a digit")99+ case slices.Contains(reservedRegionNames, strings.ToLower(name)):100+ return fmt.Errorf("$%s is nushell's own", name)101+ }102+ for i := range len(name) {103+ if c := name[i]; !isLetter(c) && !isDigit(c) && c != '_' {104+ return errors.New("A region's name can only have letters, digits and _")105+ }106+ }107+ return nil108+}109+110+// Notebook reports whether the sheet is a notebook: its regions stack111+// in run order.112+func (s *Sheet) Notebook() bool { return s.regions.notebook }113+114+// MakeNotebook turns an empty sheet into a notebook, as a loader does,115+// without an undo step.116+func (s *Sheet) MakeNotebook() { s.regions.notebook = true }117+118+// AddNotebook adds a notebook sheet named name ("" for the next Shell119+// N) after the active one, as one undo step.120+func (w *Workbook) AddNotebook(name string, after *Sheet) (*Sheet, error) {121+ if name == "" {122+ name = w.NextNotebookName()123+ }124+ if err := w.checkName(nil, name); err != nil {125+ return nil, err126+ }127+ s := w.newSheet(name)128+ s.regions.notebook = true129+ at := len(w.sheets)130+ if i := w.Index(after); i >= 0 {131+ at = i + 1132+ }133+ w.change(s, "insert sheet "+name, Rect{}, func() {134+ w.recordSheets()135+ w.insert(s, at)136+ })137+ return s, nil138+}139+140+// NextNotebookName is Shell 1, then 2 and so on.141+func (w *Workbook) NextNotebookName() string {142+ for n := 1; ; n++ {143+ if name := "Shell " + strconv.Itoa(n); w.Lookup(name) == nil {144+ return name145+ }146+ }147+}148+149+// HasRegions reports whether the sheet has regions, so what draws it can150+// skip looking for them.151+func (s *Sheet) HasRegions() bool { return len(s.regions.list) > 0 }152+153+// Regions returns the sheet's regions in run order.154+func (s *Sheet) Regions() []Region {155+ out := slices.Clone(s.regions.list)156+ for i := range out {157+ out[i].Deps, out[i].Sort = slices.Clone(out[i].Deps), slices.Clone(out[i].Sort)158+ }159+ return out160+}161+162+// Region finds a region by name, ignoring case, on any sheet.163+func (w *Workbook) Region(name string) (*Sheet, Region, bool) {164+ k := nameKey(name)165+ for _, s := range w.sheets {166+ if i := s.regionIndex(k); i >= 0 {167+ return s, s.Regions()[i], true168+ }169+ }170+ return nil, Region{}, false171+}172+173+func (s *Sheet) regionIndex(k string) int {174+ return slices.IndexFunc(s.regions.list, func(r Region) bool { return nameKey(r.Name) == k })175+}176+177+// NextRegionName is the first of r1, r2 and so on that no region or178+// named range has.179+func (w *Workbook) NextRegionName() string {180+ for n := 1; ; n++ {181+ name := "r" + strconv.Itoa(n)182+ if w.checkRegionName(name) == nil {183+ return name184+ }185+ }186+}187+188+// checkRegionName reports why name can't name a new region.189+func (w *Workbook) checkRegionName(name string) error {190+ if err := ValidRegionName(name); err != nil {191+ return err192+ }193+ if _, r, ok := w.Region(name); ok {194+ return fmt.Errorf("There's already a region named %s", r.Name)195+ }196+ if n, ok := w.LookupName(regionPrefix + name); ok {197+ return fmt.Errorf("%s already names %s", n.Name, n.Ref())198+ }199+ return nil200+}201+202+// RegionShown reports whether the region has data to show: it has run203+// since the file was opened.204+func (s *Sheet) RegionShown(name string) bool {205+ return s.regions.data[nameKey(name)] != nil206+}207+208+// RegionStatus is what the region's label says it's doing: "Running…",209+// "not run", why its table can't be shown, or "".210+func (s *Sheet) RegionStatus(name string) string {211+ k := nameKey(name)212+ if me := s.regionMeta[k]; me != nil {213+ switch {214+ case me.status != "":215+ return me.status216+ case me.why != "":217+ return me.why218+ }219+ }220+ switch d := s.regions.data[k]; {221+ case d == nil:222+ return "not run"223+ case d.Rows == 0:224+ return "no rows"225+ }226+ return ""227+}228+229+// RegionNote is what the data shown left out, or "".230+func (s *Sheet) RegionNote(name string) string {231+ if d := s.regions.data[nameKey(name)]; d != nil {232+ return d.Note233+ }234+ return ""235+}236+237+// AddRegion adds a region to the sheet as one undo step. On a notebook238+// its label goes below everything on the sheet, a row left free, and r.At239+// is ignored.240+func (s *Sheet) AddRegion(r Region) error {241+ if err := s.wb.checkRegionName(r.Name); err != nil {242+ return err243+ }244+ if err := s.wb.checkDeps(r.Name, r.Deps); err != nil {245+ return err246+ }247+ if s.regions.notebook {248+ r.At = Addr{Row: s.nextRegionRow()}249+ }250+ r.Deps, r.Sort = slices.Clone(r.Deps), slices.Clone(r.Sort)251+ s.change("add "+r.Name, Rect{From: r.At, To: r.At}, func() {252+ st := s.regionsCopy()253+ st.list = append(st.list, r)254+ s.putRegions(st)255+ })256+ return nil257+}258+259+// nextRegionRow is where a new region's label goes on a notebook: below260+// the last cell and region, a row left free, or row 1 on an empty sheet.261+func (s *Sheet) nextRegionRow() int {262+ next := 0263+ if used, ok := s.UsedRange(); ok {264+ next = used.To.Row + 2265+ }266+ for _, r := range s.regions.list {267+ next = max(next, r.At.Row+r.Rows+2)268+ }269+ return min(next, MaxRows-1)270+}271+272+// EditRegion changes a region's command, what it reads and its input,273+// as one undo step. The table stays until the command runs again.274+func (s *Sheet) EditRegion(name, command string, deps []string, input string) error {275+ i := s.regionIndex(nameKey(name))276+ if i < 0 {277+ return errNoRegion278+ }279+ if err := s.wb.checkDeps(s.regions.list[i].Name, deps); err != nil {280+ return err281+ }282+ s.change("edit "+s.regions.list[i].Name, Rect{From: s.regions.list[i].At, To: s.regions.list[i].At}, func() {283+ st := s.regionsCopy()284+ st.list[i].Command, st.list[i].Deps, st.list[i].Input = command, slices.Clone(deps), input285+ s.putRegions(st)286+ })287+ return nil288+}289+290+// DeleteRegion removes a region and its cells, as one undo step.291+func (s *Sheet) DeleteRegion(name string) error {292+ k := nameKey(name)293+ i := s.regionIndex(k)294+ if i < 0 {295+ return errNoRegion296+ }297+ r := s.regions.list[i]298+ s.change("delete "+r.Name, s.regionArea(r), func() {299+ st := s.regionsCopy()300+ st.list = slices.Delete(st.list, i, i+1)301+ delete(st.data, k)302+ s.putRegions(st)303+ })304+ return nil305+}306+307+// FreezeRegion turns a region's table into plain values, each in the308+// format it showed, and removes the region, as one undo step.309+func (s *Sheet) FreezeRegion(name string) error {310+ k := nameKey(name)311+ i := s.regionIndex(k)312+ if i < 0 {313+ return errNoRegion314+ }315+ r := s.regions.list[i]316+ table, ok := s.RegionTable(r.Name)317+ s.change("freeze "+r.Name, s.regionArea(r), func() {318+ if ok {319+ for a := range s.cells.anyKeysIn(table) {320+ if c := s.cells.get(a); c.Spilled() {321+ s.place(a, frozen(c))322+ }323+ }324+ }325+ st := s.regionsCopy()326+ st.list = slices.Delete(st.list, i, i+1)327+ delete(st.data, k)328+ s.putRegions(st)329+ })330+ return nil331+}332+333+// frozen is a region's cell as a plain one: its value typed in, in the334+// format it shows.335+func frozen(c *Cell) *Cell {336+ p := &Cell{Value: c.Value, Format: c.Format, Style: c.Style, spilled: true}337+ if p.Format.IsZero() {338+ p.Format = c.auto339+ }340+ return p.plain().withNote(c.Note)341+}342+343+// SortRegion sorts a region's table below its header by keys, columns of344+// the sheet, as one undo step: the order is the region's, so the table345+// stays sorted when it runs again.346+func (s *Sheet) SortRegion(name string, keys []SortKey) error {347+ i := s.regionIndex(nameKey(name))348+ if i < 0 {349+ return errNoRegion350+ }351+ r := s.regions.list[i]352+ rel := make([]SortKey, len(keys))353+ for j, key := range keys {354+ rel[j] = SortKey{Col: key.Col - r.At.Col, Desc: key.Desc}355+ }356+ s.change("sort "+r.Name, s.regionArea(r), func() {357+ st := s.regionsCopy()358+ st.list[i].Sort = rel359+ s.putRegions(st)360+ })361+ return nil362+}363+364+// ShowRegion shows d in the region, as one undo step: the table grows or365+// shrinks, and on a notebook the rows under it move with it.366+func (s *Sheet) ShowRegion(name string, d *RegionData) error {367+ k := nameKey(name)368+ i := s.regionIndex(k)369+ if i < 0 {370+ return errNoRegion371+ }372+ if d == nil {373+ d = &RegionData{}374+ }375+ r := s.regions.list[i]376+ s.change("run "+r.Name, s.regionArea(r), func() {377+ if s.regions.notebook {378+ s.fitRegion(k, d.Rows)379+ }380+ st := s.regionsCopy()381+ i := s.regionIndex(k) // fitting may have moved it382+ if i < 0 {383+ return384+ }385+ st.list[i].Rows, st.list[i].Cols = d.Rows, d.Cols386+ st.data[k] = d387+ s.putRegions(st)388+ })389+ return nil390+}391+392+// fitRegion makes room under the region with key k for a table of rows393+// rows: inserting rows below its table, or deleting those it no longer394+// needs when nothing else is on them.395+func (s *Sheet) fitRegion(k string, rows int) {396+ r := s.regions.list[s.regionIndex(k)]397+ end := r.At.Row + r.Rows // the table's last row398+ switch {399+ case rows > r.Rows:400+ below := rowRect(end+1, MaxRows-1)401+ for range s.cells.anyKeysIn(below) {402+ _ = s.insert(true, end+1, rows-r.Rows) // rows pushed past the edge: the table is cut instead403+ return404+ }405+ case rows < r.Rows:406+ from := r.At.Row + rows + 1407+ spare := rowRect(from, end)408+ area := s.regionArea(r)409+ for a := range s.cells.anyKeysIn(spare) {410+ if !area.Contains(a) {411+ return // something else is on those rows412+ }413+ }414+ s.restructure(true, spanOf(from, -(end-from+1)))415+ }416+}417+418+// regionsCopy is a copy of the sheet's regions to change and put back.419+func (s *Sheet) regionsCopy() regionState {420+ return regionState{notebook: s.regions.notebook, list: slices.Clone(s.regions.list), data: maps.Clone(s.regions.data)}421+}422+423+// putRegions replaces the sheet's regions, recording them for undo, and424+// has their cells written again.425+func (s *Sheet) putRegions(st regionState) {426+ s.recordRegions()427+ if st.data == nil {428+ st.data = map[string]*RegionData{}429+ }430+ s.regions = st431+ s.regionsStale = true432+}433+434+// recordRegions saves the sheet's regions before their first change in435+// the open step.436+func (s *Sheet) recordRegions() {437+ if st := s.wb.hist.open; st != nil {438+ if _, seen := st.regions[s]; !seen {439+ st.regions[s] = s.regions440+ }441+ }442+}443+444+// sameRegions reports whether two states of a sheet's regions are the445+// same.446+func sameRegions(a, b regionState) bool {447+ if a.notebook != b.notebook || len(a.list) != len(b.list) || !maps.Equal(a.data, b.data) {448+ return false449+ }450+ for i := range a.list {451+ x, y := a.list[i], b.list[i]452+ if x.Name != y.Name || x.Command != y.Command || x.At != y.At || x.Rows != y.Rows || x.Cols != y.Cols ||453+ x.Input != y.Input || !slices.Equal(x.Deps, y.Deps) || !slices.Equal(x.Sort, y.Sort) {454+ return false455+ }456+ }457+ return true458+}
A internal/sheet/region_test.go +189 -0
@@ -0,0 +1,189 @@
1+package sheet2+3+import (4+ "slices"5+ "testing"6+)7+8+// table makes region data of text rows: the first is the header, and9+// cells that read as numbers are numbers.10+func table(rows ...[]string) *RegionData {11+ d := &RegionData{Rows: len(rows)}12+ for _, r := range rows {13+ d.Cols = max(d.Cols, len(r))14+ }15+ d.Values = make([]Value, d.Rows*d.Cols)16+ for i, r := range rows {17+ for j, text := range r {18+ v, _, ok := ParseValue(text)19+ switch {20+ case text == "":21+ continue22+ case ok && i > 0:23+ d.Values[i*d.Cols+j] = Value{Kind: Number, Num: v}24+ default:25+ d.Values[i*d.Cols+j] = Value{Kind: Text, Str: text}26+ }27+ }28+ }29+ return d30+}31+32+func notebook(t *testing.T) *Sheet {33+ t.Helper()34+ s := New()35+ s.MakeNotebook()36+ return s37+}38+39+func addShown(t *testing.T, s *Sheet, name, command string, d *RegionData) {40+ t.Helper()41+ if err := s.AddRegion(Region{Name: name, Command: command, Deps: s.Book().RegionDeps(command)}); err != nil {42+ t.Fatal(err)43+ }44+ if err := s.ShowRegion(name, d); err != nil {45+ t.Fatal(err)46+ }47+}48+49+func TestRegionsStack(t *testing.T) {50+ s := notebook(t)51+ addShown(t, s, "r1", "ls", table([]string{"name", "size"}, []string{"a", "1"}, []string{"b", "2"}))52+ wantShown(t, s, map[string]string{"A1": "r1 ls", "A2": "name", "B2": "size", "A3": "a", "B4": "2", "A5": ""})53+ addShown(t, s, "r2", "$r1 | where size > 1", table([]string{"name", "size"}, []string{"b", "2"}))54+ // A gap row, then r2's label.55+ wantShown(t, s, map[string]string{"A5": "", "A6": "r2 $r1 | where size > 1", "A7": "name", "A8": "b"})56+ if r, ok := s.RegionTable("r2"); !ok || r != rect("A7:B8") {57+ t.Errorf("r2's table %v %v", r, ok)58+ }59+ // r1 growing pushes r2 down; shrinking pulls it back.60+ if err := s.ShowRegion("r1", table([]string{"name", "size"}, []string{"a", "1"}, []string{"b", "2"}, []string{"c", "3"})); err != nil {61+ t.Fatal(err)62+ }63+ wantShown(t, s, map[string]string{"A5": "c", "A6": "", "A7": "r2 $r1 | where size > 1", "A9": "b"})64+ s.ShowRegion("r1", table([]string{"name"}, []string{"a"}))65+ wantShown(t, s, map[string]string{"A3": "a", "B2": "", "A4": "", "A5": "r2 $r1 | where size > 1", "A7": "b"})66+ // Undo shows the table it replaced, with r2 where it was.67+ s.Book().Undo()68+ wantShown(t, s, map[string]string{"A5": "c", "A7": "r2 $r1 | where size > 1"})69+ s.Book().Undo()70+ s.Book().Undo() // r2's run71+ wantShown(t, s, map[string]string{"A6": "r2 $r1 | where size > 1 not run", "A7": ""})72+ s.Book().Undo() // r2 itself73+ wantShown(t, s, map[string]string{"A6": ""})74+}75+76+func TestRegionGuards(t *testing.T) {77+ s := notebook(t)78+ addShown(t, s, "r1", "ls", table([]string{"n"}, []string{"1"}))79+ for _, a := range []string{"A1", "A2", "A3"} {80+ if err := s.Set(at(a), "x"); err != ErrRegionEdit {81+ t.Errorf("Set %s: %v, want ErrRegionEdit", a, err)82+ }83+ }84+ if a, r, ok := s.InRegion(rect("A3:C9")); !ok || a != at("A3") || r.Name != "r1" {85+ t.Errorf("InRegion = %v %v %v", a, r.Name, ok)86+ }87+ // Formatting stays, and the value with it.88+ s.Batch(Change{Label: "bold"}, func() error {89+ c := s.Cell(at("A3")).clone()90+ c.Style.Bold = true91+ s.place(at("A3"), c)92+ return nil93+ })94+ if c := s.Cell(at("A3")); c == nil || !c.Style.Bold || c.Value.Num != 1 {95+ t.Errorf("A3 after bold: %+v", c)96+ }97+ // Saved: the definition, not the table.98+ t2 := roundTrip(t, s)99+ if !t2.Notebook() || len(t2.Regions()) != 1 || t2.RegionShown("r1") {100+ t.Fatalf("read back: notebook %v, regions %+v", t2.Notebook(), t2.Regions())101+ }102+ wantShown(t, t2, map[string]string{"A1": "r1 ls not run", "A3": ""})103+ if !t2.Cell(at("A3")).Style.Bold {104+ t.Error("the table's formatting wasn't kept")105+ }106+}107+108+func TestRegionFormulaName(t *testing.T) {109+ s := notebook(t)110+ w := s.Book()111+ other, _ := w.AddSheet("Sheet2", 1)112+ if err := other.Set(at("A1"), "=SUM(nu.r1)"); err != nil {113+ t.Fatal(err)114+ }115+ wantShown(t, other, map[string]string{"A1": "#NAME?"})116+ addShown(t, s, "r1", "ls", table([]string{"n"}, []string{"1"}, []string{"2"}))117+ wantShown(t, other, map[string]string{"A1": "3"})118+ s.ShowRegion("r1", table([]string{"n"}, []string{"5"}))119+ wantShown(t, other, map[string]string{"A1": "5"})120+ if err := w.DefineName("nu.r2", s, rect("A1")); err != nil {121+ t.Fatal(err)122+ }123+ if err := s.AddRegion(Region{Name: "r2", Command: "x"}); err == nil {124+ t.Error("a region took a named range's name")125+ }126+}127+128+func TestRegionGraph(t *testing.T) {129+ s := notebook(t)130+ w := s.Book()131+ addShown(t, s, "r1", "ls", table([]string{"n"}))132+ addShown(t, s, "r2", "$r1 | first", table([]string{"n"}))133+ addShown(t, s, "big", "$r2 | append $r1", table([]string{"n"}))134+ addShown(t, s, "r3", "$env.PATH | $in", table([]string{"n"}))135+ if got := w.RefreshOrder("r1"); !slices.Equal(got, []string{"r1", "r2", "big"}) {136+ t.Errorf("refresh r1: %v", got)137+ }138+ if got := w.RefreshOrder("r2"); !slices.Equal(got, []string{"r2", "big"}) {139+ t.Errorf("refresh r2: %v", got)140+ }141+ if got := w.RunOrder(); !slices.Equal(got, []string{"r1", "r2", "big", "r3"}) {142+ t.Errorf("run all: %v", got)143+ }144+ if err := s.EditRegion("r1", "$big", w.RegionDeps("$big"), ""); err == nil {145+ t.Error("a cycle was accepted")146+ }147+ if err := s.EditRegion("r1", "$r1", w.RegionDeps("$r1"), ""); err == nil {148+ t.Error("a region reading itself was accepted")149+ }150+ if got := RegionRefs("$r1 | where x == $in.a and $nu.home | $r_2.x"); !slices.Equal(got, []string{"r1", "r_2"}) {151+ t.Errorf("refs %v", got)152+ }153+}154+155+func TestRegionFreezeAndSort(t *testing.T) {156+ s := notebook(t)157+ addShown(t, s, "r1", "ls", table([]string{"n", "s"}, []string{"b", "2"}, []string{"a", "10"}, []string{"c", ""}))158+ if err := s.SortRegion("r1", []SortKey{{Col: 1, Desc: true}}); err != nil {159+ t.Fatal(err)160+ }161+ wantShown(t, s, map[string]string{"A3": "a", "A4": "b", "A5": "c"})162+ // Running again keeps the order.163+ s.ShowRegion("r1", table([]string{"n", "s"}, []string{"x", "1"}, []string{"y", "3"}))164+ wantShown(t, s, map[string]string{"A3": "y", "A4": "x"})165+ if err := s.FreezeRegion("r1"); err != nil {166+ t.Fatal(err)167+ }168+ wantShown(t, s, map[string]string{"A1": "", "A2": "n", "A3": "y", "B3": "3"})169+ if err := s.Set(at("A3"), "z"); err != nil {170+ t.Errorf("a frozen cell can't be edited: %v", err)171+ }172+ s.Book().Undo()173+ s.Book().Undo()174+ wantShown(t, s, map[string]string{"A1": "r1 ls", "A3": "y"})175+}176+177+func TestRegionBlocked(t *testing.T) {178+ s := New()179+ if err := s.Set(at("B3"), "mine"); err != nil {180+ t.Fatal(err)181+ }182+ if err := s.AddRegion(Region{Name: "feed", Command: "tail", At: at("A1")}); err != nil {183+ t.Fatal(err)184+ }185+ s.ShowRegion("feed", table([]string{"a", "b"}, []string{"1", "2"}, []string{"3", "4"}))186+ wantShown(t, s, map[string]string{"A1": "feed tail can't show: it would overwrite data in B3", "A2": ""})187+ s.Set(at("B3"), "")188+ wantShown(t, s, map[string]string{"A1": "feed tail", "A2": "a", "B3": "2", "B4": "4"})189+}
A internal/sheet/regiondata.go +66 -0
@@ -0,0 +1,66 @@
1+package sheet2+3+import "github.com/FelineStateMachine/012/internal/formula"4+5+// What fills a region, and how formulas name one (see region.go).6+7+// RegionData is a region's table: Rows by Cols values, row by row, the8+// header row first, with the formats they come in (a file size's Size,9+// a date's Date time) by index.10+type RegionData struct {11+ Rows, Cols int12+ Values []Value13+ Formats map[int]Format14+ // Note says what was left out, e.g. rows past max-cells.15+ Note string16+}17+18+// At returns the value at row r, column c and its format.19+func (d *RegionData) At(r, c int) (Value, Format) {20+ if d == nil || r < 0 || c < 0 || r >= d.Rows || c >= d.Cols {21+ return Value{}, Format{}22+ }23+ i := r*d.Cols + c24+ return d.Values[i], d.Formats[i]25+}26+27+// RegionDataOf copies the used range of src, from A1, as a region's28+// table: how a table read by an importer becomes one.29+func RegionDataOf(src *Sheet) *RegionData {30+ used, ok := src.UsedRange()31+ if !ok {32+ return &RegionData{}33+ }34+ d := &RegionData{Rows: used.To.Row + 1, Cols: used.To.Col + 1}35+ d.Values = make([]Value, d.Rows*d.Cols)36+ for a := range src.cells.keysIn(Rect{To: used.To}) {37+ i := a.Row*d.Cols + a.Col38+ d.Values[i] = src.Value(a)39+ if f := src.DisplayFormat(a); !f.IsZero() {40+ if d.Formats == nil {41+ d.Formats = map[int]Format{}42+ }43+ d.Formats[i] = f44+ }45+ }46+ return d47+}48+49+// boundRegion is a name in a formula that isn't a named range: a50+// region's table (nu.r1), #REF! while it has none, or the name as51+// written, which shows #NAME?.52+func (s *Sheet) boundRegion(nn formula.Name) Node {53+ t, r, ok := s.wb.regionName(nameKey(nn.Name))54+ switch {55+ case !ok:56+ return nn57+ case r == (Rect{}):58+ return formula.RefErr{}59+ }60+ sheet := ""61+ if t != s {62+ sheet = t.name63+ }64+ const fixed = formula.AbsCol | formula.AbsRow65+ return formula.Range{Rect: r, Abs: [2]formula.Abs{fixed, fixed}, Sheet: sheet}66+}
A internal/sheet/regionfile.go +109 -0
@@ -0,0 +1,109 @@
1+package sheet2+3+import (4+ "bufio"5+ "encoding/json"6+ "fmt"7+)8+9+// fileRegion is a region in the file: its definition, one line per10+// region after the cells of its sheet, never its table, which its11+// command makes again when it runs. They need no version: earlier builds12+// ignore them and open the sheet without its regions.13+//14+// "notebook": true,15+// "regions": [16+// {"name": "r1", "command": "ls", "at": "A1", "rows": 12, "cols": 4},17+// {"name": "big", "command": "$r1 | where size > 1kb", "at": "A16", "rows": 3, "cols": 4, "reads": ["r1"]}18+// ]19+type fileRegion struct {20+ Name string `json:"name"`21+ Command string `json:"command"`22+ At string `json:"at"`23+ Rows int `json:"rows,omitempty"`24+ Cols int `json:"cols,omitempty"`25+ Reads []string `json:"reads,omitempty"`26+ Input string `json:"input,omitempty"`27+ Sort []fileRegionSort `json:"sort,omitempty"`28+}29+30+// fileRegionSort is a column a region's table is sorted by, counted from31+// 1 at the region's first column.32+type fileRegionSort struct {33+ Column int `json:"column"`34+ Desc bool `json:"desc,omitempty"`35+}36+37+// writeRegionDefs writes the notebook flag and the regions, one per line.38+func (s *Sheet) writeRegionDefs(b *bufio.Writer, indent string) error {39+ if s.regions.notebook {40+ b.WriteString(",\n" + indent + `"notebook": true`)41+ }42+ if len(s.regions.list) == 0 {43+ return nil44+ }45+ b.WriteString(",\n" + indent + `"regions": [`)46+ for i, r := range s.regions.list {47+ fr := fileRegion{Name: r.Name, Command: r.Command, At: r.At.String(), Rows: r.Rows, Cols: r.Cols, Reads: r.Deps, Input: r.Input}48+ for _, k := range r.Sort {49+ fr.Sort = append(fr.Sort, fileRegionSort{Column: k.Col + 1, Desc: k.Desc})50+ }51+ raw, err := json.Marshal(fr)52+ if err != nil {53+ return err54+ }55+ if i > 0 {56+ b.WriteByte(',')57+ }58+ fmt.Fprintf(b, "\n%s %s", indent, raw)59+ }60+ b.WriteString("\n" + indent + "]")61+ return nil62+}63+64+// readRegions reads a sheet's regions, not yet shown.65+func (s *Sheet) readRegions(notebook bool, frs []fileRegion) error {66+ s.regions.notebook = notebook67+ for _, fr := range frs {68+ r, err := decodeRegion(fr)69+ if err != nil {70+ return err71+ }72+ if _, _, dup := s.wb.Region(r.Name); dup || s.regionIndex(nameKey(r.Name)) >= 0 {73+ return fmt.Errorf("region %q is defined twice", r.Name)74+ }75+ s.regions.list = append(s.regions.list, r)76+ }77+ if len(s.regions.list) > 0 {78+ s.regions.data = map[string]*RegionData{}79+ s.regionsStale = true80+ }81+ return nil82+}83+84+func decodeRegion(fr fileRegion) (Region, error) {85+ if err := ValidRegionName(fr.Name); err != nil {86+ return Region{}, fmt.Errorf("region %q: %w", fr.Name, err)87+ }88+ at, ok := ParseAddr(fr.At)89+ if !ok {90+ return Region{}, fmt.Errorf("region %s: invalid cell %q", fr.Name, fr.At)91+ }92+ if fr.Rows < 0 || fr.Cols < 0 || at.Row+fr.Rows >= MaxRows || at.Col+fr.Cols > MaxCols {93+ return Region{}, fmt.Errorf("region %s: invalid size %d by %d", fr.Name, fr.Rows, fr.Cols)94+ }95+ r := Region{Name: fr.Name, Command: fr.Command, At: at, Rows: fr.Rows, Cols: fr.Cols, Input: fr.Input}96+ for _, d := range fr.Reads {97+ if err := ValidRegionName(d); err != nil {98+ return Region{}, fmt.Errorf("region %s reads %q: %w", fr.Name, d, err)99+ }100+ r.Deps = append(r.Deps, d)101+ }102+ for _, k := range fr.Sort {103+ if k.Column < 1 || k.Column > MaxCols {104+ return Region{}, fmt.Errorf("region %s: invalid sort column %d", fr.Name, k.Column)105+ }106+ r.Sort = append(r.Sort, SortKey{Col: k.Column - 1, Desc: k.Desc})107+ }108+ return r, nil109+}
A internal/sheet/regiongraph.go +149 -0
@@ -0,0 +1,149 @@
1+package sheet2+3+import (4+ "fmt"5+ "slices"6+ "strings"7+)8+9+// The regions' dependency graph: a command reading $r1 depends on region10+// r1, so refreshing r1 runs it again and then every region that reads11+// it, each after what it reads. A command can't read itself, directly12+// or through others.13+14+// RegionRefs returns the names a command reads as $name, in the order it15+// first names them, leaving out nushell's own variables.16+func RegionRefs(command string) []string {17+ var out []string18+ for i := 0; i < len(command); i++ {19+ if command[i] != '$' {20+ continue21+ }22+ j := i + 123+ for j < len(command) && (isLetter(command[j]) || isDigit(command[j]) || command[j] == '_') {24+ j++25+ }26+ name := command[i+1 : j]27+ if name != "" && ValidRegionName(name) == nil && !slices.Contains(out, name) {28+ out = append(out, name)29+ }30+ i = j - 131+ }32+ return out33+}34+35+// RegionDeps returns the regions a command reads: the names in it that36+// regions of the workbook have, as the regions spell them.37+func (w *Workbook) RegionDeps(command string) []string {38+ var out []string39+ for _, name := range RegionRefs(command) {40+ if _, r, ok := w.Region(name); ok && r.Name == name {41+ out = append(out, r.Name)42+ }43+ }44+ return out45+}46+47+// allRegions returns every region of the workbook's sheets, sheet by48+// sheet in run order.49+func (w *Workbook) allRegions() []Region {50+ var out []Region51+ for _, s := range w.sheets {52+ out = append(out, s.regions.list...)53+ }54+ return out55+}56+57+// checkDeps refuses deps for the region name when one of them reads it,58+// directly or through others.59+func (w *Workbook) checkDeps(name string, deps []string) error {60+ k := nameKey(name)61+ byKey := map[string]Region{}62+ for _, r := range w.allRegions() {63+ byKey[nameKey(r.Name)] = r64+ }65+ seen := map[string]bool{}66+ var reads func(d string) bool67+ reads = func(d string) bool {68+ dk := nameKey(d)69+ if dk == k {70+ return true71+ }72+ if seen[dk] {73+ return false74+ }75+ seen[dk] = true76+ return slices.ContainsFunc(byKey[dk].Deps, reads)77+ }78+ for _, d := range deps {79+ if reads(d) {80+ return fmt.Errorf("%s can't read $%s, which reads %s: that would be a cycle", name, d, name)81+ }82+ }83+ return nil84+}85+86+// RefreshOrder returns the regions to run when name is refreshed: name,87+// then every region that reads it, directly or through others, each88+// after the regions it reads.89+func (w *Workbook) RefreshOrder(name string) []string {90+ regions := w.allRegions()91+ want := map[string]bool{nameKey(name): true}92+ for changed := true; changed; {93+ changed = false94+ for _, r := range regions {95+ k := nameKey(r.Name)96+ if !want[k] && slices.ContainsFunc(r.Deps, func(d string) bool { return want[nameKey(d)] }) {97+ want[k], changed = true, true98+ }99+ }100+ }101+ return w.ordered(regions, want)102+}103+104+// RunOrder returns every region, each after the regions it reads, in run105+// order otherwise: what Run all runs.106+func (w *Workbook) RunOrder() []string {107+ regions := w.allRegions()108+ want := map[string]bool{}109+ for _, r := range regions {110+ want[nameKey(r.Name)] = true111+ }112+ return w.ordered(regions, want)113+}114+115+// ordered lists the regions want names, each after those it reads among116+// them, otherwise in run order.117+func (w *Workbook) ordered(regions []Region, want map[string]bool) []string {118+ var out []string119+ done := map[string]bool{}120+ for len(out) < len(want) {121+ progress := false122+ for _, r := range regions {123+ k := nameKey(r.Name)124+ if !want[k] || done[k] {125+ continue126+ }127+ ready := !slices.ContainsFunc(r.Deps, func(d string) bool { dk := nameKey(d); return want[dk] && !done[dk] })128+ if ready {129+ out, done[k], progress = append(out, r.Name), true, true130+ }131+ }132+ if !progress {133+ break // a cycle a file made: what's left doesn't run134+ }135+ }136+ return out137+}138+139+// DependsOn returns the regions that read name directly, for saying what140+// deleting or freezing it leaves without input.141+func (w *Workbook) DependsOn(name string) []string {142+ var out []string143+ for _, r := range w.allRegions() {144+ if slices.ContainsFunc(r.Deps, func(d string) bool { return strings.EqualFold(d, name) }) {145+ out = append(out, r.Name)146+ }147+ }148+ return out149+}
A internal/sheet/regionwrite.go +422 -0
@@ -0,0 +1,422 @@
1+package sheet2+3+import (4+ "slices"5+6+ "github.com/FelineStateMachine/012/internal/formula"7+)8+9+// Writing regions' cells (see region.go): after a change that touched10+// regions, each stale sheet's labels and tables are written again, only11+// the cells that differ, and cells no region covers any more are12+// cleared. A table that would overwrite other contents isn't shown; its13+// label says where it's blocked, and clearing that cell shows it.14+15+// regionArea is the cells a region covers: its label line and table, at16+// least one column wide.17+func (s *Sheet) regionArea(r Region) Rect {18+ return Rect{From: r.At, To: Addr{Col: min(r.At.Col+max(r.Cols, 1)-1, MaxCols-1), Row: min(r.At.Row+r.Rows, MaxRows-1)}}19+}20+21+// RegionTable returns the cells of a region's table, header row22+// included, when it has one.23+func (s *Sheet) RegionTable(name string) (Rect, bool) {24+ i := s.regionIndex(nameKey(name))25+ if i < 0 {26+ return Rect{}, false27+ }28+ r := s.regions.list[i]29+ if r.Rows == 0 || r.Cols == 0 || r.At.Row+1 >= MaxRows {30+ return Rect{}, false31+ }32+ area := s.regionArea(r)33+ area.From.Row++34+ return area, true35+}36+37+// covered is the cells a region holds: those it wrote, or while it's38+// yet to write them, those its definition reserves.39+func (s *Sheet) covered(r Region) Rect {40+ if me := s.regionMeta[nameKey(r.Name)]; me != nil && me.has {41+ return me.written42+ }43+ return s.regionArea(r)44+}45+46+// RegionAt returns the region covering the cell at a, and whether a is on47+// its label line.48+func (s *Sheet) RegionAt(a Addr) (Region, bool, bool) {49+ for _, r := range s.regions.list {50+ if s.covered(r).Contains(a) {51+ return r, a.Row == r.At.Row, true52+ }53+ }54+ return Region{}, false, false55+}56+57+// InRegion returns a cell of r a region covers, and the region.58+func (s *Sheet) InRegion(r Rect) (Addr, Region, bool) {59+ for _, reg := range s.regions.list {60+ if overlap, ok := intersectRect(s.covered(reg), r); ok {61+ return overlap.From, reg, true62+ }63+ }64+ return Addr{}, Region{}, false65+}66+67+// SetRegionStatus sets what a region's label says it's doing, such as68+// "Running…", "" for nothing. It isn't an undo step.69+func (s *Sheet) SetRegionStatus(name, status string) {70+ k := nameKey(name)71+ if s.regionIndex(k) < 0 {72+ return73+ }74+ me := s.meta(k)75+ if me.status == status {76+ return77+ }78+ me.status = status79+ s.regionsStale = true80+ if s.wb.hist.open == nil {81+ s.wb.settleRegions()82+ }83+}84+85+// meta is the region with key k's meta, made when missing.86+func (s *Sheet) meta(k string) *regionMeta {87+ if s.regionMeta == nil {88+ s.regionMeta = map[string]*regionMeta{}89+ }90+ me := s.regionMeta[k]91+ if me == nil {92+ me = &regionMeta{}93+ s.regionMeta[k] = me94+ }95+ return me96+}97+98+// regionTouched has the regions write their cells again after the cell99+// at a is placed in their way or over their cells.100+func (s *Sheet) regionTouched(a Addr) {101+ for _, r := range s.regions.list {102+ if s.regionArea(r).Contains(a) || s.blockedAt(nameKey(r.Name), a) {103+ s.regionsStale = true104+ return105+ }106+ }107+}108+109+// blockedAt reports whether the region with key k isn't shown because of110+// the cell at a's row and columns.111+func (s *Sheet) blockedAt(k string, a Addr) bool {112+ me := s.regionMeta[k]113+ if me == nil || me.why == "" {114+ return false115+ }116+ i := s.regionIndex(k)117+ if i < 0 {118+ return false119+ }120+ r := s.regions.list[i]121+ d := s.regions.data[k]122+ if d == nil {123+ return false124+ }125+ need := Rect{From: r.At, To: Addr{Col: r.At.Col + max(d.Cols, 1) - 1, Row: r.At.Row + d.Rows}}126+ return need.Contains(a)127+}128+129+// settleRegions writes the cells of the sheets whose regions changed,130+// then recalculates what reads them.131+func (w *Workbook) settleRegions() {132+ if w.settling {133+ return134+ }135+ w.settling = true136+ defer func() { w.settling = false }()137+ var changed []loc138+ for _, s := range w.sheets {139+ if s.regionsStale {140+ s.regionsStale = false141+ changed = append(changed, s.writeRegions()...)142+ }143+ }144+ if len(changed) > 0 {145+ w.recalc(changed)146+ }147+}148+149+// regionCell is a cell a region writes: its value and the format it150+// comes in.151+type regionCell struct {152+ v Value153+ f Format154+}155+156+// writeRegions writes every region's cells on s and clears those no157+// region covers any more, returning the cells that changed and the158+// formulas naming a region whose table moved.159+func (s *Sheet) writeRegions() []loc {160+ var old []Rect161+ for k, me := range s.regionMeta {162+ if me.has {163+ old = append(old, me.written)164+ }165+ if s.regionIndex(k) < 0 {166+ delete(s.regionMeta, k)167+ }168+ }169+ want := map[Addr]regionCell{}170+ var changed []loc171+ for _, r := range s.regions.list {172+ changed = append(changed, s.planRegion(r, want)...)173+ }174+ for _, area := range old {175+ changed = append(changed, s.clearRegion(area, want)...)176+ }177+ for a, c := range want {178+ if s.writeRegionCell(a, c) {179+ changed = append(changed, loc{s, a})180+ }181+ }182+ return changed183+}184+185+// planRegion adds the cells r writes to want, recording where they are,186+// and returns the formulas to recalculate because its table moved.187+func (s *Sheet) planRegion(r Region, want map[Addr]regionCell) []loc {188+ k := nameKey(r.Name)189+ me := s.meta(k)190+ d := s.regions.data[k]191+ me.why = ""192+ area := s.regionArea(r)193+ if d != nil {194+ need := Rect{From: r.At, To: Addr{Col: r.At.Col + max(d.Cols, 1) - 1, Row: r.At.Row + d.Rows}}195+ if need.To.Row >= MaxRows || need.To.Col >= MaxCols {196+ me.why = "can't show: it would go past the edge of the sheet"197+ } else if at, ok := s.inTheWay(need, me); ok {198+ me.why = "can't show: it would overwrite data in " + at.String()199+ } else {200+ area = need201+ }202+ }203+ if me.why != "" {204+ area = Rect{From: r.At, To: r.At} // the label says why205+ }206+ var moved []loc207+ if !me.has || me.written != area {208+ for u := range s.wb.nameUsers[nameKey(r.FormulaName())] {209+ moved = append(moved, u)210+ }211+ }212+ me.written, me.has = area, true213+ if !s.writable(r.At, me) {214+ return moved215+ }216+ want[r.At] = regionCell{v: Value{Kind: Text, Str: s.regionLabel(r)}}217+ if d == nil || me.why != "" {218+ return moved219+ }220+ order := sortedTable(d, r.Sort)221+ for row := range d.Rows {222+ for col := range d.Cols {223+ v, f := d.At(order[row], col)224+ if v.Kind != Empty {225+ want[Addr{Col: r.At.Col + col, Row: r.At.Row + 1 + row}] = regionCell{v, f}226+ }227+ }228+ }229+ return moved230+}231+232+// regionLabel is what a region's label line says: its name and command,233+// and what it's doing.234+func (s *Sheet) regionLabel(r Region) string {235+ label := r.Name + " " + r.Command236+ if st := s.RegionStatus(r.Name); st != "" {237+ label += " " + st238+ }239+ return label240+}241+242+// inTheWay returns a cell of need holding something the region with243+// meta me didn't write.244+func (s *Sheet) inTheWay(need Rect, me *regionMeta) (Addr, bool) {245+ for a := range s.cells.anyKeysIn(need) {246+ if s.cells.filledAt(a) && !s.regionOwns(a, me) {247+ return a, true248+ }249+ }250+ return Addr{}, false251+}252+253+// writable reports whether the cell at a is free for the region with254+// meta me to write.255+func (s *Sheet) writable(a Addr, me *regionMeta) bool {256+ return !s.cells.filledAt(a) || s.regionOwns(a, me)257+}258+259+// regionOwns reports whether the cell at a is one the region with meta260+// me wrote: a derived cell of what it wrote last that no formula261+// spilled.262+func (s *Sheet) regionOwns(a Addr, me *regionMeta) bool {263+ if !me.has || !me.written.Contains(a) || s.cells.derivedAt(a) != slotSpill {264+ return false265+ }266+ _, spilled := s.SpillAnchor(a)267+ return !spilled268+}269+270+// clearRegion clears the cells a region wrote in area that want doesn't271+// cover.272+func (s *Sheet) clearRegion(area Rect, want map[Addr]regionCell) []loc {273+ var gone []Addr274+ for a := range s.cells.anyKeysIn(area) {275+ if _, ok := want[a]; ok || s.cells.derivedAt(a) != slotSpill {276+ continue277+ }278+ if _, spilled := s.SpillAnchor(a); !spilled {279+ gone = append(gone, a)280+ }281+ }282+ changed := make([]loc, len(gone))283+ for i, a := range gone {284+ s.setDerived(a, s.cells.get(a).leftover())285+ changed[i] = loc{s, a}286+ }287+ return changed288+}289+290+// writeRegionCell makes the cell at a show c as a region's, keeping its291+// formatting and note, and reports whether it changed.292+func (s *Sheet) writeRegionCell(a Addr, c regionCell) bool {293+ was, l, kind := s.cells.derivedOf(a)294+ if kind == slotSpill && was == c.v && l.auto == c.f {295+ return false296+ }297+ return s.writeSpilled(a, c.v, c.f)298+}299+300+// sortedTable is the order of d's rows: the header first, then the rest301+// by keys.302+func sortedTable(d *RegionData, keys []SortKey) []int {303+ order := make([]int, d.Rows)304+ for i := range order {305+ order[i] = i306+ }307+ if len(keys) == 0 || d.Rows < 3 {308+ return order309+ }310+ slices.SortStableFunc(order[1:], func(i, j int) int {311+ for _, k := range keys {312+ a, _ := d.At(i, k.Col)313+ b, _ := d.At(j, k.Col)314+ if c := compareKey(a, b, k.Desc); c != 0 {315+ return c316+ }317+ }318+ return 0319+ })320+ return order321+}322+323+// compareKey orders two values of a sort key, blanks last either way.324+func compareKey(a, b Value, desc bool) int {325+ switch {326+ case a.Kind == Empty && b.Kind == Empty:327+ return 0328+ case a.Kind == Empty:329+ return 1330+ case b.Kind == Empty:331+ return -1332+ case desc:333+ return -sortCompare(a, b)334+ }335+ return sortCompare(a, b)336+}337+338+// shiftRegions keeps the sheet's regions in step with rows or columns339+// inserted or deleted: a region moves and resizes as a range would, and340+// one whose label line was deleted goes.341+func (s *Sheet) shiftRegions(rows bool, sp formula.Span) {342+ if len(s.regions.list) == 0 {343+ return344+ }345+ cell, rng := formula.AxisMaps(rows, sp)346+ st := s.regionsCopy()347+ st.list = st.list[:0]348+ for _, r := range s.regions.list {349+ at, ok := cell(r.At)350+ area, ok2 := rng(s.regionArea(r))351+ if !ok || !ok2 {352+ delete(st.data, nameKey(r.Name))353+ continue354+ }355+ r.At, r.Rows = at, area.To.Row-at.Row356+ if r.Cols > 0 {357+ r.Cols = area.To.Col - at.Col + 1358+ }359+ st.list = append(st.list, r)360+ }361+ for _, me := range s.regionMeta {362+ if w, ok := rng(me.written); ok && me.has {363+ me.written = w364+ }365+ }366+ if !sameRegions(st, s.regions) {367+ s.putRegions(st)368+ }369+ s.regionsStale = true370+}371+372+// RegionLabels returns the label lines of the regions on row, each as373+// wide as its table, for drawing them.374+func (s *Sheet) RegionLabels(row int) []Rect {375+ var out []Rect376+ for _, r := range s.regions.list {377+ if r.At.Row == row {378+ c := s.covered(r)379+ out = append(out, Rect{From: r.At, To: Addr{Col: c.To.Col, Row: row}})380+ }381+ }382+ return out383+}384+385+// spanOf is n rows inserted at row at, or deleted when n is negative.386+func spanOf(at, n int) formula.Span { return formula.Span{At: at, N: n, Size: MaxRows} }387+388+// regionName resolves a name formulas use, nu.r1, to the region's sheet389+// and table.390+func (w *Workbook) regionName(k string) (*Sheet, Rect, bool) {391+ if len(k) <= len(regionPrefix) || k[:len(regionPrefix)] != nameKey(regionPrefix) {392+ return nil, Rect{}, false393+ }394+ for _, s := range w.sheets {395+ if i := s.regionIndex(k[len(regionPrefix):]); i >= 0 {396+ r, ok := s.RegionTable(s.regions.list[i].Name)397+ if !ok {398+ return s, Rect{}, true399+ }400+ return s, r, true401+ }402+ }403+ return nil, Rect{}, false404+}405+406+// regionsInUse are the regions formulas name, with their tables, for407+// finding what reads a changed cell.408+func (w *Workbook) regionsInUse() []namedUsers {409+ var out []namedUsers410+ for _, s := range w.sheets {411+ for _, r := range s.regions.list {412+ users := w.nameUsers[nameKey(r.FormulaName())]413+ if len(users) == 0 {414+ continue415+ }416+ if t, ok := s.RegionTable(r.Name); ok {417+ out = append(out, namedUsers{s, t, users})418+ }419+ }420+ }421+ return out422+}
M internal/sheet/sheet.go +13 -0
A internal/sheet/shellhistory.go +35 -0
M internal/sheet/workbook.go +7 -0
M internal/ui/file.go +5 -0
M internal/ui/gridlines.go +17 -0
M internal/ui/hosts.go +16 -0
@@ -11,6 +11,7 @@ import (
1111 "github.com/FelineStateMachine/012/internal/ui/filterpick"1212 "github.com/FelineStateMachine/012/internal/ui/findbar"1313 "github.com/FelineStateMachine/012/internal/ui/lineedit"14+ "github.com/FelineStateMachine/012/internal/ui/nuprompt"1415 "github.com/FelineStateMachine/012/internal/ui/picker"1516 "github.com/FelineStateMachine/012/internal/ui/rules"1617 "github.com/FelineStateMachine/012/internal/ui/shortcuts"
@@ -31,6 +32,7 @@ func (m *Model) host() host { return host{m} }
3132 var (3233 _ picker.Host = host{}3334 _ cmdline.Host = host{}35+ _ nuprompt.Host = host{}3436 _ findbar.Host = host{}3537 _ themepicker.Host = host{}3638 _ rules.Host = host{}
@@ -129,6 +131,20 @@ func (h host) Run(text string) (tea.Cmd, bool) { return h.m.runCmdLine(text) }
129131 func (h host) Fail(msg string) { h.m.fail(msg) }130132 func (h host) History() *cmdline.History { return &h.m.session.history }131133 134+// The shell prompt's host: nuprompt.Host.135+136+func (h host) Submit(line string) tea.Cmd { return h.m.submitShell(line) }137+func (h host) Words() []nuprompt.Word { return h.m.shellWords() }138+func (h host) ShellHistory() []string { return h.m.book().ShellHistory() }139+func (h host) Stop() { h.m.stopShell() }140+func (h host) Said() string { return h.m.shell.said }141+func (h host) Running() string {142+ if j := h.m.shell.running; j != nil {143+ return j.name144+ }145+ return ""146+}147+132148 // The theme picker.133149 134150 func (h host) Current() string { return h.m.prefs.Config.Theme().Pick(!h.m.prefs.light) }
M internal/ui/menubar.go +3 -2
M internal/ui/model.go +12 -4
@@ -43,13 +43,13 @@ func (m mode) String() string {
4343 // line), the column header, the grid, and a status line.4444 const (4545 menuLine = overlay.MenuLine // menu bar on the left, mode indicator on the right46- formulaLine = 1 // name box, then the cell's contents or the entry46+ formulaLine = overlay.FormulaLine // name box, then the cell's contents or the entry4747 contextLine = overlay.ContextLine // prompts, key hints and formula errors4848 panelLines = 34949 headerLine = panelLines5050 gridTop = overlay.GridTop51- minRowHdrW = 6 // the row numbers up to 9999; see grid.hdrW52- nameBoxW = 11 // fits most ranges, e.g. "AA100:AB200", without jumping51+ minRowHdrW = 6 // the row numbers up to 9999; see grid.hdrW52+ nameBoxW = overlay.FormulaBarX - 1 // fits most ranges, e.g. "AA100:AB200", without jumping5353 )5454 5555 // doubleClick is the longest gap between two clicks that edits a cell.
@@ -131,6 +131,7 @@ type Model struct {
131131 rec *recorder // a macro being recorded: macrorec.go132132 macros macroState // a macro running, and trust in the file's macros: macrorun.go133133 follow followState // the linked regions followed, and trust in them: follow.go134+ shell shellState // a notebook's commands running, and what they said: nurun.go134135 135136 keyAt time.Time // when the key the next frame answers was pressed, for telemetry136137 spans *telemetry.Trace // the spans open, which what the model starts nests in: trace.go
@@ -157,7 +158,7 @@ func (m *Model) TraceUnder(p telemetry.Parent) { m.spans.Enter(p) }
157158 // so the theme can adapt to light terminals.158159 func (m *Model) Init() tea.Cmd {159160 // jev.send starts any questions queued while loading the file.160- return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.term.probes(), m.jev.send(m.spans.Parent()), m.startupCmd(), m.startStdin(), m.startOpenCmd(), m.syncFollowers())161+ return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.term.probes(), m.jev.send(m.spans.Parent()), m.startupCmd(), m.startStdin(), m.startOpenCmd(), m.startShell(), m.syncFollowers())161162 }162163 163164 // Update implements tea.Model.
@@ -232,6 +233,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
232233 cmd = m.finishMacro(msg.r, msg.done)233234 case macroEditedMsg:234235 m.macroEdited(msg)236+ case nuDoneMsg:237+ cmd = m.finishRegion(msg)238+ case nuWordsMsg:239+ m.shell.words = msg.words235240 case tea.KeyReleaseMsg:236241 cmd = m.keyReleased(msg)237242 case tea.KeyboardEnhancementsMsg:
@@ -329,6 +334,9 @@ func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {
329334 }330335 }331336 key := k.String()337+ if cmd, ok := m.notebookKey(key); ok {338+ return cmd339+ }332340 if m.recordMove(key) {333341 m.entry.tabbing = false334342 return nil
M internal/ui/model_test.go +1 -1
M internal/ui/monochrome_test.go +20 -0
A internal/ui/notebook.go +388 -0
@@ -0,0 +1,388 @@
1+package ui2+3+import (4+ "slices"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/ui/nuprompt"11+)12+13+// Notebooks: ! (or Data > Shell, or 012 nu) opens a nushell prompt on14+// the formula bar. Each line run becomes a region of the notebook sheet15+// (the one shown, or the first, or a new Shell 1): a live table named16+// r1, r2 and so on, or name = pipeline to name it, which later commands17+// read as $name. Refreshing a region runs it again, then what reads it18+// (nurun.go). The engine keeps the regions (sheet/region.go); this is19+// the prompt, the commands and the trust a file's commands need.20+21+func init() {22+ onRegion := func(m *Model) bool { _, _, ok := m.sheet.RegionAt(m.cur); return ok }23+ register(24+ &command{id: "nu.prompt", macro: macroNever, title: "Shell",25+ desc: "Run a nushell pipeline; its table becomes a live region of the notebook sheet",26+ run: func(m *Model) tea.Cmd { return m.openShell("") }},27+ &command{id: "nu.refresh", macro: macroNever, title: "Refresh region",28+ desc: "Run the region's command again, then every region that reads it",29+ enabled: onRegion,30+ run: func(m *Model) tea.Cmd { return m.refreshRegion(m.regionHere()) }},31+ &command{id: "nu.run_all", macro: macroNever, title: "Run all regions",32+ desc: "Run every region's command, each after the regions it reads",33+ enabled: func(m *Model) bool { return len(m.book().RunOrder()) > 0 },34+ run: (*Model).runAllRegions},35+ &command{id: "nu.edit", macro: macroNever, title: "Edit region's command",36+ desc: "Open the region's command at the prompt, to change it and run it again",37+ enabled: onRegion,38+ run: func(m *Model) tea.Cmd {39+ r := m.regionHere()40+ return m.openShell(r.Name + " = " + r.Command)41+ }},42+ &command{id: "nu.freeze", macro: macroNever, title: "Freeze region",43+ desc: "Turn the region's table into plain values you can edit, and stop running its command",44+ enabled: onRegion,45+ run: func(m *Model) tea.Cmd { return m.freezeRegion(m.regionHere()) }},46+ &command{id: "nu.delete", macro: macroNever, title: "Delete region",47+ desc: "Remove the region, its command and its table",48+ enabled: onRegion,49+ run: func(m *Model) tea.Cmd { return m.deleteRegion(m.regionHere()) }},50+ &command{id: "nu.stop", macro: macroNever, title: "Stop shell command",51+ desc: "Stop the region's command that's running, and those waiting after it",52+ enabled: func(m *Model) bool { return m.shell.running != nil },53+ run: func(m *Model) tea.Cmd { m.stopShell(); return nil }},54+ )55+ keymap["!"] = "nu.prompt"56+ keymap["f9"] = "nu.run_all"57+}58+59+// regionHere is the region at the active cell.60+func (m *Model) regionHere() sheet.Region {61+ r, _, _ := m.sheet.RegionAt(m.cur)62+ return r63+}64+65+// openShell opens the prompt with text on it. What's selected is what66+// the command reads as $in.67+func (m *Model) openShell(text string) tea.Cmd {68+ m.shell.input = ""69+ if m.hasRange() && !m.sheet.Notebook() {70+ m.shell.input = sheet.Qualified(m.sheet.Name(), m.selection())71+ }72+ m.shell.said = ""73+ m.openOverlay(nuprompt.New(m.host(), text))74+ return m.loadWords()75+}76+77+// OpenShell starts on a notebook sheet with the prompt open, as 012 nu78+// does: the workbook's first notebook, or a new sheet made one. A new79+// file's empty sheet becomes the notebook.80+func (m *Model) OpenShell() {81+ s := m.notebookSheet(false)82+ if s == nil {83+ w := m.book()84+ if first := w.Sheet(0); w.Len() == 1 && first.Len() == 0 && !w.CanUndo() {85+ if w.RenameSheet(first, w.NextNotebookName()) == nil {86+ first.MakeNotebook()87+ w.ClearHistory()88+ s = first89+ }90+ }91+ }92+ if s != nil {93+ m.showSheet(s)94+ }95+ m.shell.startup = true96+}97+98+// notebookSheet is where a command's region goes: the sheet shown when99+// it's a notebook, or the first notebook, or with create a new Shell N100+// after the sheet shown.101+func (m *Model) notebookSheet(create bool) *sheet.Sheet {102+ if m.sheet.Notebook() {103+ return m.sheet104+ }105+ for _, s := range m.book().Sheets() {106+ if s.Notebook() {107+ return s108+ }109+ }110+ if !create {111+ return nil112+ }113+ s, err := m.book().AddNotebook("", m.sheet)114+ if err != nil {115+ m.shell.said = m.th.Warning.Render(err.Error())116+ return nil117+ }118+ return s119+}120+121+// splitName splits "name = pipeline" into the name and the pipeline; a122+// line that doesn't start so has no name.123+func splitName(line string) (string, string) {124+ name, rest, ok := strings.Cut(line, "=")125+ name = strings.TrimSpace(name)126+ if !ok || strings.HasPrefix(rest, "=") || sheet.ValidRegionName(name) != nil {127+ return "", line128+ }129+ return name, strings.TrimSpace(rest)130+}131+132+// submitShell runs a line typed at the prompt: a new region, or a named133+// region's new command.134+func (m *Model) submitShell(line string) tea.Cmd {135+ w := m.book()136+ w.AddShellHistory(line)137+ if why := m.shellOff(); why != "" {138+ m.shell.said = m.th.Warning.Render(why)139+ return nil140+ }141+ name, command := splitName(line)142+ if command == "" {143+ m.shell.said = m.th.Warning.Render("Type a pipeline after " + name + " =")144+ return nil145+ }146+ input := ""147+ if strings.Contains(command, "$in") {148+ input = m.shell.input149+ }150+ deps := w.RegionDeps(command)151+ trusted := m.macroTrusted() || len(w.Macros()) == 0 && len(w.RunOrder()) == 0152+ var s *sheet.Sheet153+ var err error154+ if t, r, ok := w.Region(name); ok && name != "" {155+ s, name = t, r.Name156+ err = s.EditRegion(name, command, deps, input)157+ } else {158+ if s = m.notebookSheet(true); s == nil {159+ return nil160+ }161+ if name == "" {162+ name = w.NextRegionName()163+ }164+ err = s.AddRegion(sheet.Region{Name: name, Command: command, Deps: deps, Input: input})165+ }166+ if err != nil {167+ m.shell.said = m.th.Warning.Render(err.Error())168+ return nil169+ }170+ if trusted {171+ m.trustHere() // what you type is yours172+ }173+ m.showSheet(s)174+ if _, r, ok := w.Region(name); ok {175+ m.clearSelection()176+ m.cur = r.At177+ }178+ return m.runRegions(m.withInputs(w.RefreshOrder(name)))179+}180+181+// shellOff says why commands can't run here, or "".182+func (m *Model) shellOff() string {183+ switch {184+ case m.shell.served && !m.configBool("serve-shell", false):185+ return "Shell commands are off in 012 serve (serve-shell in the server's config)"186+ case m.configString("shell", "ask") == "off":187+ return "Shell commands are off (shell = off in your config)"188+ }189+ return ""190+}191+192+// refreshRegion runs a region's command again and then what reads it,193+// asking first when the file's commands came from elsewhere.194+func (m *Model) refreshRegion(r sheet.Region) tea.Cmd {195+ order := m.withInputs(m.book().RefreshOrder(r.Name))196+ return m.trustShell(func(m *Model) tea.Cmd { return m.runRegions(order) })197+}198+199+// runAllRegions runs every region, each after those it reads.200+func (m *Model) runAllRegions() tea.Cmd {201+ order := m.book().RunOrder()202+ return m.trustShell(func(m *Model) tea.Cmd { return m.runRegions(order) })203+}204+205+// withInputs adds before order the regions its commands read that206+// haven't run yet, so they have tables to read.207+func (m *Model) withInputs(order []string) []string {208+ w := m.book()209+ var before []string210+ for _, name := range w.RunOrder() {211+ if slices.Contains(order, name) {212+ continue213+ }214+ if s, _, ok := w.Region(name); ok && !s.RegionShown(name) && m.readBy(name, order) {215+ before = append(before, name)216+ }217+ }218+ return append(before, order...)219+}220+221+// readBy reports whether a region of names reads name, directly or222+// through others.223+func (m *Model) readBy(name string, names []string) bool {224+ for _, n := range names {225+ if slices.Contains(m.book().RefreshOrder(name), n) {226+ return true227+ }228+ }229+ return false230+}231+232+// trustShell runs run once commands may run: shell = on, or the file's233+// commands were made or trusted here, or the user agrees now.234+func (m *Model) trustShell(run func(*Model) tea.Cmd) tea.Cmd {235+ if why := m.shellOff(); why != "" {236+ m.fail(why)237+ return nil238+ }239+ if m.configString("shell", "ask") == "on" || m.macroTrusted() {240+ return run(m)241+ }242+ m.ask(question{243+ msg: "Run this file's shell commands?",244+ desc: "It was saved on another computer: its commands run as you, with your files",245+ choices: []choice{246+ {key: "enter", label: "Run", run: func(m *Model) tea.Cmd { m.trustHere(); return run(m) }},247+ {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},248+ },249+ })250+ return nil251+}252+253+// sortRegion sorts the table of a region a sort's range r reaches, by254+// its columns in keys: the rows below its header, whatever else r255+// holds. The order is the region's, kept when it runs again.256+func (m *Model) sortRegion(reg sheet.Region, r sheet.Rect, keys []sheet.SortKey) tea.Cmd {257+ t, ok := m.sheet.RegionTable(reg.Name)258+ inTable := func(k sheet.SortKey) bool { return k.Col >= t.From.Col && k.Col <= t.To.Col }259+ if !ok || len(keys) == 0 || !slices.ContainsFunc(keys, inTable) || r.To.Row <= t.From.Row {260+ m.note = "Sort a region by a column of its table"261+ return nil262+ }263+ keys = slices.DeleteFunc(slices.Clone(keys), func(k sheet.SortKey) bool { return !inTable(k) })264+ if err := m.sheet.SortRegion(reg.Name, keys); err != nil {265+ m.fail(err.Error())266+ return nil267+ }268+ m.note = "Sorted " + reg.Name + "; it stays sorted when it runs again"269+ return nil270+}271+272+// freezeRegion turns a region's table into plain values.273+func (m *Model) freezeRegion(r sheet.Region) tea.Cmd {274+ if m.shell.running != nil && m.shell.running.name == r.Name {275+ m.stopShell()276+ }277+ if err := m.sheet.FreezeRegion(r.Name); err != nil {278+ m.fail(err.Error())279+ return nil280+ }281+ m.note = "Froze " + r.Name + " into values" + m.orphans(r.Name)282+ return nil283+}284+285+// deleteRegion removes a region.286+func (m *Model) deleteRegion(r sheet.Region) tea.Cmd {287+ if m.shell.running != nil && m.shell.running.name == r.Name {288+ m.stopShell()289+ }290+ if err := m.sheet.DeleteRegion(r.Name); err != nil {291+ m.fail(err.Error())292+ return nil293+ }294+ m.note = "Deleted " + r.Name + m.orphans(r.Name)295+ return nil296+}297+298+// orphans says which regions read one that's gone.299+func (m *Model) orphans(name string) string {300+ if by := m.book().DependsOn(name); len(by) > 0 {301+ return "; " + strings.Join(by, ", ") + " read it"302+ }303+ return ""304+}305+306+// notebookKey is Enter and F2 on a region's label (refresh it, edit its307+// command) and Esc while a command runs (stop it), in READY.308+func (m *Model) notebookKey(key string) (tea.Cmd, bool) {309+ switch key {310+ case "esc":311+ if m.shell.running != nil {312+ return m.runCommand("nu.stop"), true313+ }314+ case "enter", "f2":315+ if !m.sheet.HasRegions() || m.hasRange() {316+ return nil, false317+ }318+ if _, label, ok := m.sheet.RegionAt(m.cur); ok && label {319+ if key == "f2" {320+ return m.runCommand("nu.edit"), true321+ }322+ return m.runCommand("nu.refresh"), true323+ }324+ }325+ return nil, false326+}327+328+// regionLine is the context line on a region: what it is and the keys329+// for it, or, on a notebook with regions not run, the key that runs them.330+func (m *Model) regionLine() string {331+ r, label, ok := m.sheet.RegionAt(m.cur)332+ if !ok {333+ if m.sheet.Notebook() && m.notRun() > 0 {334+ return m.th.KeyHints(m.shortcut("nu.run_all"), "run all regions", m.shortcut("nu.prompt"), "shell")335+ }336+ return ""337+ }338+ if f := m.shell.failed[strings.ToUpper(r.Name)]; f != "" && label {339+ return m.th.Warning.Render(r.Name+" failed: "+f) + " " + m.th.KeyHints("F2", "edit")340+ }341+ what := r.Name342+ if n := m.sheet.RegionNote(r.Name); n != "" {343+ what += ": " + n344+ }345+ if label {346+ return m.th.Muted.Render(what+" ") + m.th.KeyHints("Enter", "refresh", "F2", "edit", m.shortcut("nu.run_all"), "run all")347+ }348+ return m.th.Muted.Render("Region " + what + " of " + r.Command)349+}350+351+// notRun counts the sheet's regions that haven't run since the file352+// opened.353+func (m *Model) notRun() int {354+ n := 0355+ for _, r := range m.sheet.Regions() {356+ if !m.sheet.RegionShown(r.Name) {357+ n++358+ }359+ }360+ return n361+}362+363+// regionEntry is what the formula bar shows on a region: its command.364+func (m *Model) regionEntry() (string, bool) {365+ if !m.sheet.HasRegions() {366+ return "", false367+ }368+ r, _, ok := m.sheet.RegionAt(m.cur)369+ if !ok {370+ return "", false371+ }372+ return m.th.Muted.Render(nuprompt.Mark + r.Command), true373+}374+375+// shellWords are the prompt's completions: the regions, and nu's376+// commands once they're known.377+func (m *Model) shellWords() []nuprompt.Word {378+ var out []nuprompt.Word379+ for _, s := range m.book().Sheets() {380+ for _, r := range s.Regions() {381+ out = append(out, nuprompt.Word{Text: "$" + r.Name, Desc: r.Command})382+ }383+ }384+ for _, c := range m.shell.words {385+ out = append(out, nuprompt.Word{Text: c, Desc: "command"})386+ }387+ return out388+}
A internal/ui/notebook_test.go +260 -0
@@ -0,0 +1,260 @@
1+package ui2+3+import (4+ "context"5+ "io"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/nushell"10+ "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/nuprompt"12+)13+14+// fakeNu answers commands from a table of outputs, failing those it15+// lacks, and keeps the jobs it ran.16+type fakeNu struct {17+ out map[string]string18+ jobs []nushell.Job19+}20+21+func (f *fakeNu) Run(_ context.Context, job nushell.Job, _ string, stdout io.Writer) error {22+ if job.Command == "help commands | get name" {23+ _, err := io.WriteString(stdout, `["ls", "where", "sort-by", "str join"]`)24+ return err25+ }26+ f.jobs = append(f.jobs, job)27+ out, ok := f.out[job.Command]28+ if !ok {29+ return &nushell.Error{Msg: "Command `" + job.Command + "` not found"}30+ }31+ _, err := io.WriteString(stdout, out)32+ return err33+}34+35+func notebookModel(t *testing.T, out map[string]string) (*Model, *fakeNu) {36+ t.Helper()37+ m := newModel()38+ nu := &fakeNu{out: out}39+ m.SetShellRunner(nu)40+ m.SetMachine("here")41+ return m, nu42+}43+44+const lsOut = "[[name, size]; [a.txt, 2kb], [b.txt, 10b], [c.txt, 5kb]]"45+46+func TestShellMakesRegions(t *testing.T) {47+ m, nu := notebookModel(t, map[string]string{48+ "ls": lsOut,49+ "$r1 | where size > 1kb": "[[name, size]; [a.txt, 2kb], [c.txt, 5kb]]",50+ })51+ press(t, m, "!")52+ if _, ok := m.overlay.(*nuprompt.Prompt); !ok || m.indicator() != "NU" {53+ t.Fatalf("no prompt: %T %q", m.overlay, m.indicator())54+ }55+ if got := bar(m); got != nuprompt.Mark[:len(nuprompt.Mark)-1] {56+ t.Errorf("formula bar %q", got)57+ }58+ press(t, m, "ls", "<enter>")59+ if m.sheet.Name() != "Shell 1" || !m.sheet.Notebook() {60+ t.Fatalf("on %q, notebook %v", m.sheet.Name(), m.sheet.Notebook())61+ }62+ if got := input(m, "A1"); got != "r1 ls" {63+ t.Errorf("label %q", got)64+ }65+ if got := m.sheet.ShownText(addr("B3")); got != "2.0 kB" {66+ t.Errorf("B3 shows %q", got)67+ }68+ if !strings.Contains(m.shell.said, "r1: 3 rows") {69+ t.Errorf("said %q", m.shell.said)70+ }71+ press(t, m, "$r1 | where size > 1kb", "<enter>")72+ if got := input(m, "A7"); got != "r2 $r1 | where size > 1kb" {73+ t.Errorf("r2's label %q", got)74+ }75+ if len(nu.jobs) != 2 || !strings.Contains(string(nu.jobs[1].Tables["r1"]), "a.txt") {76+ t.Fatalf("r2 read r1 as %q", nu.jobs[len(nu.jobs)-1].Tables)77+ }78+ // Refreshing r1 runs r2 after it.79+ press(t, m, "<esc>")80+ m.cur = addr("A1")81+ nu.out["ls"] = "[[name, size]; [d.txt, 9kb]]"82+ press(t, m, "<enter>")83+ if got := input(m, "A3"); got != "d.txt" {84+ t.Errorf("r1 refreshed to %q", got)85+ }86+ if got := input(m, "A5"); !strings.HasPrefix(got, "r2 ") {87+ t.Errorf("r2 didn't move up: A5 %q", got)88+ }89+ if last := nu.jobs[len(nu.jobs)-1]; last.Command != "$r1 | where size > 1kb" || !strings.Contains(string(last.Tables["r1"]), "d.txt") {90+ t.Errorf("r2 wasn't run after r1: %+v", last)91+ }92+ // Undo takes the refresh back, one region at a time.93+ press(t, m, "<ctrl+z>")94+ press(t, m, "<ctrl+z>")95+ if got := input(m, "A3"); got != "a.txt" {96+ t.Errorf("after undo A3 %q", got)97+ }98+}99+100+func TestShellFails(t *testing.T) {101+ m, _ := notebookModel(t, map[string]string{})102+ press(t, m, "!", "lss", "<enter>")103+ if got := input(m, "A1"); got != "r1 lss failed" {104+ t.Errorf("label %q", got)105+ }106+ if !strings.Contains(line(m, contextLine), "r1 failed: Command `lss` not found") {107+ t.Errorf("context line %q", line(m, contextLine))108+ }109+ press(t, m, "<esc>")110+ m.cur = addr("A1")111+ if !strings.Contains(line(m, contextLine), "r1 failed") {112+ t.Errorf("on the label: %q", line(m, contextLine))113+ }114+ // Named: name = pipeline, and redefining it.115+ m.shell.runner.(*fakeNu).out["ls"] = lsOut116+ press(t, m, "!", "files = ls", "<enter>")117+ if _, r, ok := m.book().Region("files"); !ok || r.Command != "ls" {118+ t.Fatalf("no region files: %+v", r)119+ }120+ press(t, m, "r1 = ls", "<enter>")121+ if got := input(m, "A1"); got != "r1 ls" {122+ t.Errorf("r1 redefined: %q", got)123+ }124+ if got := m.book().Sheet(1).Value(addr("A3")).String(); got != "a.txt" {125+ t.Errorf("r1's table %q", got)126+ }127+}128+129+func TestShellCompletes(t *testing.T) {130+ m, _ := notebookModel(t, map[string]string{"ls": lsOut})131+ press(t, m, "!", "ls", "<enter>", "$")132+ p := m.overlay.(*nuprompt.Prompt)133+ if got := p.Shown(); len(got) != 1 || got[0].Text != "$r1" {134+ t.Fatalf("completions of $: %+v", got)135+ }136+ press(t, m, "<tab>")137+ if m.line.Text() != "$r1" {138+ t.Errorf("tab put %q", m.line.Text())139+ }140+ press(t, m, " | so")141+ if got := p.Shown(); len(got) != 1 || got[0].Text != "sort-by" {142+ t.Errorf("completions of so: %+v", got)143+ }144+ if !strings.Contains(screen(m), "Completions") {145+ t.Error("no completions box")146+ }147+ // History: Up recalls the lines run.148+ m.line.Clear()149+ p.Changed()150+ press(t, m, "<up>")151+ if m.line.Text() != "ls" {152+ t.Errorf("up recalled %q", m.line.Text())153+ }154+ if got := m.book().ShellHistory(); len(got) != 1 || got[0] != "ls" {155+ t.Errorf("history %q", got)156+ }157+}158+159+func TestShellTrust(t *testing.T) {160+ m, nu := notebookModel(t, map[string]string{"ls": lsOut})161+ press(t, m, "!", "ls", "<enter>", "<esc>")162+ if m.book().MacroOrigin() != "here" {163+ t.Errorf("origin %q", m.book().MacroOrigin())164+ }165+ // A file from elsewhere asks before its commands run.166+ m.book().SetMacroOrigin("elsewhere")167+ m.macros.trusted = false168+ m.cur = addr("A1")169+ n := len(nu.jobs)170+ press(t, m, "<enter>")171+ if len(nu.jobs) != n || !strings.Contains(line(m, contextLine), "Run this file's shell commands?") {172+ t.Fatalf("didn't ask: %q", line(m, contextLine))173+ }174+ press(t, m, "<esc>")175+ if len(nu.jobs) != n {176+ t.Error("ran after Esc")177+ }178+ press(t, m, "<f9>", "<enter>")179+ if len(nu.jobs) != n+1 || m.book().MacroOrigin() != "here" {180+ t.Errorf("run all after trusting: %d jobs, origin %q", len(nu.jobs)-n, m.book().MacroOrigin())181+ }182+ // Served, commands are off unless the server allows them.183+ m.shell.served = true184+ press(t, m, "<f9>")185+ if !strings.Contains(m.errMsg, "off in 012 serve") {186+ t.Errorf("served: %q", m.errMsg)187+ }188+}189+190+func TestShellGuardsAndFreeze(t *testing.T) {191+ m, _ := notebookModel(t, map[string]string{"ls": lsOut})192+ press(t, m, "!", "ls", "<enter>", "<esc>")193+ m.cur = addr("A3")194+ press(t, m, "x", "<enter>")195+ if got := input(m, "A3"); got != "a.txt" {196+ t.Errorf("typed over a region: %q", got)197+ }198+ if !strings.Contains(m.note, "region r1") {199+ t.Errorf("note %q", m.note)200+ }201+ if got := bar(m); got != "nu❯ ls" {202+ t.Errorf("formula bar on a region %q", got)203+ }204+ run(m, m.runCommand("nu.freeze"))205+ press(t, m, "x", "<enter>")206+ if got := input(m, "A3"); got != "x" {207+ t.Errorf("frozen A3 %q", got)208+ }209+ if got := input(m, "B4"); got != "10" || m.sheet.ShownText(addr("B4")) != "10 B" {210+ t.Errorf("frozen B4 %q shows %q", got, m.sheet.ShownText(addr("B4")))211+ }212+}213+214+// Sorting a region's table keeps the order through the next run.215+func TestShellSortsRegion(t *testing.T) {216+ m, _ := notebookModel(t, map[string]string{"ls": lsOut})217+ press(t, m, "!", "ls", "<enter>", "<esc>")218+ m.cur = addr("B3")219+ run(m, m.runCommand("data.sort_range_za"))220+ if got := input(m, "A3"); got != "c.txt" {221+ t.Fatalf("sorted A3 %q, note %q", got, m.note)222+ }223+ m.cur = addr("A1")224+ press(t, m, "<enter>")225+ if got := input(m, "A3"); got != "c.txt" {226+ t.Errorf("after refresh A3 %q", got)227+ }228+}229+230+// Opening a saved notebook shows its regions not run, and F9 runs them.231+func TestShellReopened(t *testing.T) {232+ m, _ := notebookModel(t, map[string]string{"ls": lsOut, "$r1 | first": "{name: a.txt, size: 2kb}"})233+ press(t, m, "!", "ls", "<enter>", "$r1 | first", "<enter>", "<esc>")234+ s := roundTripSheet(t, m.sheet)235+ m2, nu := notebookModel(t, map[string]string{"ls": lsOut, "$r1 | first": "{name: a.txt, size: 2kb}"})236+ m2.reset(s, "nb.012")237+ if got := input(m2, "A1"); got != "r1 ls not run" {238+ t.Errorf("label %q", got)239+ }240+ if !strings.Contains(line(m2, contextLine), "run all") {241+ t.Errorf("context line %q", line(m2, contextLine))242+ }243+ press(t, m2, "<f9>")244+ if len(nu.jobs) != 2 || input(m2, "A1") != "r1 ls" {245+ t.Errorf("run all: %d jobs, A1 %q", len(nu.jobs), input(m2, "A1"))246+ }247+}248+249+func roundTripSheet(t *testing.T, s *sheet.Sheet) *sheet.Sheet {250+ t.Helper()251+ var b strings.Builder252+ if err := s.Write(&b); err != nil {253+ t.Fatal(err)254+ }255+ got, err := sheet.Read(strings.NewReader(b.String()))256+ if err != nil {257+ t.Fatal(err)258+ }259+ return got260+}
A internal/ui/nuprompt/nuprompt.go +331 -0
@@ -0,0 +1,331 @@
1+// Package nuprompt is the nushell prompt of a notebook: a pipeline typed2+// on the formula bar after nu❯, with the workbook's history on Up and3+// Down and completions of regions ($r1) and nu's commands in a box4+// under the context line. It knows the UI only through Host: running a5+// line, and what's running, are the host's.6+package nuprompt7+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/lineedit"16+ "github.com/FelineStateMachine/012/internal/ui/overlay"17+ "github.com/FelineStateMachine/012/internal/ui/theme"18+)19+20+// Host is what the prompt needs of the UI.21+type Host interface {22+ Theme() *theme.Theme23+ Size() (width, height int)24+ // Line is the shared edit line the command is typed in.25+ Line() *lineedit.Line26+ // Close closes the prompt.27+ Close()28+ // Submit runs a line; the prompt stays open for the next.29+ Submit(line string) tea.Cmd30+ // Words are what can be completed: the regions and nu's commands.31+ Words() []Word32+ // ShellHistory is the workbook's lines, oldest first.33+ ShellHistory() []string34+ // Running names the region running, or "" when none is.35+ Running() string36+ // Stop stops what's running.37+ Stop()38+ // Said is what the last run said: how many rows, or why it failed.39+ Said() string40+}41+42+// Word is a completion.43+type Word struct {44+ Text string // what Tab puts on the line: $r1, or a command45+ Desc string // what it is: a region's command, or "command"46+}47+48+// ID identifies the completions' box in mouse events.49+const ID = "nuprompt"50+51+// Mark starts the prompt on the formula bar.52+const Mark = "nu❯ "53+54+// Prompt is the open prompt.55+type Prompt struct {56+ h Host57+ overlay.List58+ shown []Word59+ tabbed bool // the word is a completion Tab put there; Tab again moves on60+ hist browse61+}62+63+// New returns a prompt with the edit line holding text.64+func New(h Host, text string) *Prompt {65+ p := &Prompt{h: h}66+ h.Line().Set(text)67+ p.Changed()68+ return p69+}70+71+// Shown are the completions of the word at the caret.72+func (p *Prompt) Shown() []Word { return p.shown }73+74+func (p *Prompt) Indicator() string { return "NU" }75+76+// word is the word before the caret, and where it starts: a $name, or a77+// command where one goes (at the start, or after |, ( or ;).78+func (p *Prompt) word() (string, int, bool) {79+ head := []rune(p.h.Line().Head())80+ i := len(head)81+ for i > 0 && !strings.ContainsRune(" \t|(;{[", head[i-1]) {82+ i--83+ }84+ w := string(head[i:])85+ if strings.HasPrefix(w, "$") {86+ return w, i, true87+ }88+ before := strings.TrimRight(string(head[:i]), " \t")89+ command := before == "" || strings.ContainsAny(before[len(before)-1:], "|(;{") ||90+ strings.HasSuffix(before, "=") && !strings.HasSuffix(before, "==")91+ return w, i, command && w != ""92+}93+94+// Changed completes the word at the caret.95+func (p *Prompt) Changed() {96+ p.Sel, p.Top, p.tabbed = 0, 0, false97+ p.shown = p.shown[:0]98+ w, _, ok := p.word()99+ if !ok {100+ return101+ }102+ lw := strings.ToLower(w)103+ var contains []Word104+ for _, it := range p.h.Words() {105+ if strings.HasPrefix(it.Text, "$") != strings.HasPrefix(w, "$") {106+ continue107+ }108+ switch t := strings.ToLower(it.Text); {109+ case t == lw:110+ case strings.HasPrefix(t, lw):111+ p.shown = append(p.shown, it)112+ case len(lw) > 1 && strings.Contains(t, lw):113+ contains = append(contains, it)114+ }115+ }116+ p.shown = append(p.shown, contains...)117+}118+119+func (p *Prompt) Key(k tea.KeyPressMsg) tea.Cmd {120+ line := p.h.Line()121+ switch key := k.String(); key {122+ case "esc":123+ if p.h.Running() != "" {124+ p.h.Stop()125+ return nil126+ }127+ p.h.Close()128+ case "enter":129+ text := strings.TrimSpace(line.Text())130+ if text == "" {131+ return nil132+ }133+ line.Clear()134+ p.hist = browse{}135+ p.Changed()136+ return p.h.Submit(text)137+ case "tab":138+ p.complete(1)139+ case "shift+tab":140+ p.complete(-1)141+ case "up", "down":142+ p.recall(key)143+ case "ctrl+p":144+ p.Move(-1, len(p.shown))145+ case "ctrl+n":146+ p.Move(1, len(p.shown))147+ default:148+ before := line.Text()149+ line.Key(k)150+ if line.Text() != before {151+ p.hist = browse{}152+ p.Changed()153+ }154+ }155+ return nil156+}157+158+// recall is Up and Down: an older or newer line of the history, among159+// those starting with what was typed.160+func (p *Prompt) recall(key string) {161+ d := -1162+ if key == "down" {163+ d = 1164+ }165+ text, ok := p.hist.step(p.h.ShellHistory(), p.h.Line().Text(), d)166+ if !ok {167+ return168+ }169+ p.h.Line().Set(text)170+ p.Changed()171+ p.shown = p.shown[:0] // a line recalled is complete172+}173+174+// complete puts the highlighted completion in place of the word at the175+// caret; pressed again, it moves on to the next (d = 1) or the previous.176+func (p *Prompt) complete(d int) {177+ if len(p.shown) == 0 {178+ return179+ }180+ if p.tabbed {181+ p.Move(d, len(p.shown))182+ }183+ p.put(p.shown[p.Sel].Text)184+ p.tabbed = true185+}186+187+// put replaces the word at the caret with text.188+func (p *Prompt) put(text string) {189+ line := p.h.Line()190+ _, start, _ := p.completionStart()191+ tail := line.Tail()192+ head := string(line.Buf[:start]) + text193+ line.Set(head + tail)194+ line.Pos = len([]rune(head))195+}196+197+// completionStart is where the word Tab replaces starts: the word at the198+// caret, or where it was before the last Tab.199+func (p *Prompt) completionStart() (string, int, bool) {200+ head := []rune(p.h.Line().Head())201+ i := len(head)202+ for i > 0 && !strings.ContainsRune(" \t|(;{[", head[i-1]) {203+ i--204+ }205+ if p.tabbed { // the word Tab put may hold spaces: str join206+ for _, it := range p.shown {207+ if n := len([]rune(it.Text)); n <= len(head) && string(head[len(head)-n:]) == it.Text {208+ return it.Text, len(head) - n, true209+ }210+ }211+ }212+ return string(head[i:]), i, true213+}214+215+// FormulaBar is what the formula bar shows after the name box.216+func (p *Prompt) FormulaBar() string {217+ return p.h.Theme().Title.Render(Mark) + p.h.Line().Text()218+}219+220+// Cursor is on the formula bar, after the prompt's mark.221+func (p *Prompt) Cursor() (int, int) {222+ return overlay.FormulaBarX + ansi.StringWidth(Mark) + ansi.StringWidth(p.h.Line().Head()), overlay.FormulaLine223+}224+225+// ContextLine says what's running, or what the last run said.226+func (p *Prompt) ContextLine() (string, string) {227+ th := p.h.Theme()228+ if r := p.h.Running(); r != "" {229+ return th.Hint.Render("Running " + r + "…"), th.KeyHints("Esc", "stop")230+ }231+ if said := p.h.Said(); said != "" {232+ return said, th.KeyHints("Esc", "back to the grid")233+ }234+ return th.KeyHints("Enter", "run", "name = ", "name it", "$r1", "a region", "$in", "the selection"), ""235+}236+237+func (p *Prompt) Status() (string, string) {238+ if p.Sel >= len(p.shown) {239+ return "", "" // the sheet's tabs, with the notebook240+ }241+ return p.shown[p.Sel].Desc, p.h.Theme().KeyHints("Tab", "complete", "Ctrl+N", "next", "Enter", "run", "Esc", "grid")242+}243+244+// rows is how many completions show.245+func (p *Prompt) rows() int {246+ _, height := p.h.Size()247+ return max(min(len(p.shown), 8, height-overlay.ContextLine-4), 0)248+}249+250+// Layout draws the completions in a box under the context line.251+func (p *Prompt) Layout() []overlay.Box {252+ rows := p.rows()253+ if rows == 0 {254+ return nil255+ }256+ p.Show(rows)257+ th := p.h.Theme()258+ width, _ := p.h.Size()259+ tw, dw := 0, 0260+ for _, it := range p.shown {261+ tw = max(tw, ansi.StringWidth(it.Text))262+ dw = max(dw, ansi.StringWidth(it.Desc))263+ }264+ inner := min(max(1+tw+2+dw+1, 30), 72, width-2)265+ tw = min(tw, inner-2)266+ dw = max(inner-1-tw-2-1, 0)267+ lines := make([]string, rows)268+ for r := range rows {269+ i := p.Top + r270+ it := p.shown[i]271+ base, dim := th.MenuBar, th.Muted272+ if i == p.Sel {273+ base, dim = th.MenuSelected, th.MenuSelected274+ }275+ row := base.Render(" "+theme.PadRight(ansi.Truncate(it.Text, tw, "…"), tw)+" ") +276+ dim.Render(theme.PadRight(ansi.Truncate(it.Desc, dw, "…"), dw)) + base.Render(" ")277+ lines[r] = row278+ }279+ footer := strconv.Itoa(p.Sel+1) + " of " + strconv.Itoa(len(p.shown))280+ x := min(overlay.FormulaBarX, max(width-inner-2, 0))281+ return []overlay.Box{{ID: ID, X: x, Y: overlay.ContextLine + 1, Lines: th.Frame(inner, "Completions", footer, lines)}}282+}283+284+func (p *Prompt) Mouse(e overlay.MouseEvent) tea.Cmd {285+ if e.Box != ID {286+ return nil // the grid stays where it is while typing287+ }288+ i := p.Top + e.Row - 1 // under the top border289+ switch {290+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:291+ p.Move(-1, len(p.shown))292+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:293+ p.Move(1, len(p.shown))294+ case e.Row < 1 || i >= len(p.shown) || i >= p.Top+p.rows():295+ case e.Kind == overlay.MouseMotion:296+ p.Sel = i297+ case e.Kind == overlay.MousePress && e.Button == tea.MouseLeft:298+ p.Sel = i299+ p.put(p.shown[i].Text)300+ p.Changed()301+ }302+ return nil303+}304+305+// browse is a walk through the history with Up and Down: only lines306+// starting with what was typed before the first Up.307+type browse struct {308+ on bool309+ at int // the line shown, len(lines) for what was typed310+ prefix string // what was typed311+}312+313+// step moves d lines through lines (-1 older, 1 newer) from what's314+// shown, skipping lines that don't start with the prefix, and returns315+// the text to show and whether there was a line to move to.316+func (b *browse) step(lines []string, typed string, d int) (string, bool) {317+ if !b.on {318+ *b = browse{on: true, at: len(lines), prefix: typed}319+ }320+ for i := b.at + d; i >= 0 && i <= len(lines); i += d {321+ if i == len(lines) {322+ b.at = i323+ return b.prefix, true324+ }325+ if strings.HasPrefix(lines[i], b.prefix) {326+ b.at = i327+ return lines[i], true328+ }329+ }330+ return "", false331+}
A internal/ui/nuprompt/nuprompt_test.go +100 -0
@@ -0,0 +1,100 @@
1+package nuprompt2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/ui/lineedit"10+ "github.com/FelineStateMachine/012/internal/ui/theme"11+)12+13+type fakeHost struct {14+ th theme.Theme15+ line lineedit.Line16+ closed bool17+ ran []string18+ history []string19+ running string20+ stopped bool21+}22+23+func (h *fakeHost) Theme() *theme.Theme { return &h.th }24+func (h *fakeHost) Size() (int, int) { return 80, 24 }25+func (h *fakeHost) Line() *lineedit.Line { return &h.line }26+func (h *fakeHost) Close() { h.closed = true }27+func (h *fakeHost) ShellHistory() []string { return h.history }28+func (h *fakeHost) Running() string { return h.running }29+func (h *fakeHost) Stop() { h.stopped = true }30+func (h *fakeHost) Said() string { return "" }31+func (h *fakeHost) Submit(line string) tea.Cmd {32+ h.ran = append(h.ran, line)33+ return nil34+}35+func (h *fakeHost) Words() []Word {36+ return []Word{{Text: "$r1", Desc: "ls"}, {Text: "$big", Desc: "$r1 | first"}, {Text: "sort-by", Desc: "command"}, {Text: "str join", Desc: "command"}}37+}38+39+func typeText(p *Prompt, text string) {40+ for _, r := range text {41+ p.Key(tea.KeyPressMsg{Code: r, Text: string(r)})42+ }43+}44+45+func key(p *Prompt, code rune) { p.Key(tea.KeyPressMsg{Code: code}) }46+47+func TestCompletes(t *testing.T) {48+ h := &fakeHost{th: theme.New(true)}49+ p := New(h, "")50+ typeText(p, "$")51+ if len(p.Shown()) != 2 {52+ t.Fatalf("$ completes %+v", p.Shown())53+ }54+ typeText(p, "b")55+ key(p, tea.KeyTab)56+ if h.line.Text() != "$big" {57+ t.Errorf("tab put %q", h.line.Text())58+ }59+ typeText(p, " | st")60+ if got := p.Shown(); len(got) != 1 || got[0].Text != "str join" {61+ t.Errorf("st completes %+v", got)62+ }63+ key(p, tea.KeyTab)64+ if h.line.Text() != "$big | str join" {65+ t.Errorf("tab put %q", h.line.Text())66+ }67+ // A word that isn't a command's place isn't completed.68+ typeText(p, " so")69+ if len(p.Shown()) != 0 {70+ t.Errorf("an argument completes %+v", p.Shown())71+ }72+ if !strings.Contains(p.FormulaBar(), Mark) {73+ t.Errorf("formula bar %q", p.FormulaBar())74+ }75+}76+77+func TestRunsAndRecalls(t *testing.T) {78+ h := &fakeHost{th: theme.New(true), history: []string{"ls", "$r1 | first"}}79+ p := New(h, "")80+ key(p, tea.KeyUp)81+ if h.line.Text() != "$r1 | first" {82+ t.Errorf("up %q", h.line.Text())83+ }84+ key(p, tea.KeyUp)85+ key(p, tea.KeyEnter)86+ if len(h.ran) != 1 || h.ran[0] != "ls" || h.line.Text() != "" {87+ t.Errorf("ran %q, line %q", h.ran, h.line.Text())88+ }89+ // Esc stops what runs, then goes back to the grid.90+ h.running = "r1"91+ key(p, tea.KeyEscape)92+ if !h.stopped || h.closed {93+ t.Errorf("esc while running: stopped %v closed %v", h.stopped, h.closed)94+ }95+ h.running = ""96+ key(p, tea.KeyEscape)97+ if !h.closed {98+ t.Error("esc didn't close")99+ }100+}
A internal/ui/nurun.go +325 -0
@@ -0,0 +1,325 @@
1+package ui2+3+import (4+ "bytes"5+ "context"6+ "errors"7+ "fmt"8+ "log/slog"9+ "strings"10+ "time"11+12+ tea "charm.land/bubbletea/v2"13+14+ "github.com/FelineStateMachine/012/internal/fileio"15+ "github.com/FelineStateMachine/012/internal/nushell"16+ "github.com/FelineStateMachine/012/internal/sheet"17+ "github.com/FelineStateMachine/012/internal/ui/transfer"18+)19+20+// Running regions' commands. One runs at a time, in the background, so21+// the screen stays live and Esc stops it: a region and those waiting22+// after it (what reads it) are a queue, each run when the one before23+// has shown its table. The tables a command reads are taken when it24+// starts; its own table is shown as one undo step when it ends.25+26+// shellState is the notebook's part of the model.27+type shellState struct {28+ runner nushell.Runner // nu, or a fake in tests29+ queue []string // regions waiting to run, in order30+ running *nuJob31+ gen int32+ // input is the range the prompt was opened on, which commands read33+ // as $in; said is what the last run said, for the prompt's context34+ // line; failed is why each region's last run failed, by name key.35+ input string36+ said string37+ failed map[string]string38+ // words are nu's command names, asked for once (asked).39+ words []string40+ asked bool41+ // served is set in a session of 012 serve, where commands run only42+ // when the server's config allows them.43+ served bool44+ // startup opens the prompt once the program starts (012 nu).45+ startup bool46+}47+48+// nuJob is a command running.49+type nuJob struct {50+ name string51+ gen int52+ cancel context.CancelFunc53+ start time.Time54+}55+56+type (57+ // nuDoneMsg is a command finished: its table, or why it has none.58+ nuDoneMsg struct {59+ job *nuJob60+ data *sheet.RegionData61+ err error62+ }63+ // nuWordsMsg brings nu's command names.64+ nuWordsMsg struct{ words []string }65+)66+67+// SetShellRunner makes commands run with r rather than nu, for tests.68+func (m *Model) SetShellRunner(r nushell.Runner) { m.shell.runner = r }69+70+func (m *Model) runner() nushell.Runner {71+ if m.shell.runner == nil {72+ return nushell.Nu{}73+ }74+ return m.shell.runner75+}76+77+// runRegions queues names to run, after what's queued, and starts the78+// first unless one is running.79+func (m *Model) runRegions(names []string) tea.Cmd {80+ for _, name := range names {81+ if !containsFold(m.shell.queue, name) && (m.shell.running == nil || !strings.EqualFold(m.shell.running.name, name)) {82+ m.shell.queue = append(m.shell.queue, name)83+ m.setStatus(name, "waiting")84+ }85+ }86+ if m.shell.running != nil {87+ return nil88+ }89+ return m.nextRegion()90+}91+92+func containsFold(list []string, s string) bool {93+ for _, x := range list {94+ if strings.EqualFold(x, s) {95+ return true96+ }97+ }98+ return false99+}100+101+// setStatus sets what a region's label says.102+func (m *Model) setStatus(name, status string) {103+ if s, _, ok := m.book().Region(name); ok {104+ s.SetRegionStatus(name, status)105+ }106+}107+108+// nextRegion starts the first region queued.109+func (m *Model) nextRegion() tea.Cmd {110+ for len(m.shell.queue) > 0 {111+ name := m.shell.queue[0]112+ m.shell.queue = m.shell.queue[1:]113+ s, r, ok := m.book().Region(name)114+ if !ok {115+ continue116+ }117+ job, err := m.jobFor(r)118+ if err != nil {119+ m.failRegion(r.Name, err.Error())120+ continue121+ }122+ s.SetRegionStatus(r.Name, "Running…")123+ return m.startJob(r.Name, job)124+ }125+ return nil126+}127+128+// jobFor is what running r takes: the tables it reads, as NUON.129+func (m *Model) jobFor(r sheet.Region) (nushell.Job, error) {130+ job := nushell.Job{Command: r.Command, Tables: map[string][]byte{}, Config: m.configBool("nu-config", false)}131+ w := m.book()132+ for _, d := range r.Deps {133+ s, dr, ok := w.Region(d)134+ if !ok {135+ return job, fmt.Errorf("it reads $%s, which isn't a region any more", d)136+ }137+ t, ok := s.RegionTable(dr.Name)138+ if !ok {139+ t = sheet.Rect{From: dr.At, To: dr.At} // nothing to read: an empty table140+ }141+ snap := fileio.Snap(s, t, dr.Name)142+ snap.HiddenRows = nil // a region reads another whole, whatever its filter shows143+ if !ok {144+ snap.Cells = nil145+ }146+ data, err := encodeNUON(snap)147+ if err != nil {148+ return job, err149+ }150+ job.Tables[d] = data151+ }152+ if r.Input != "" {153+ name, ref := sheet.SplitSheet(r.Input)154+ s, rng, ok := w.Lookup(name), sheet.Rect{}, false155+ if s != nil {156+ rng, ok = sheet.ParseRange(ref)157+ }158+ if !ok {159+ return job, fmt.Errorf("its input %s is gone", r.Input)160+ }161+ data, err := encodeNUON(fileio.Snap(s, rng, s.Name()))162+ if err != nil {163+ return job, err164+ }165+ job.Input = data166+ }167+ return job, nil168+}169+170+func encodeNUON(snap *fileio.Snapshot) ([]byte, error) {171+ var b bytes.Buffer172+ if _, err := fileio.Encode(&b, fileio.NUON, snap); err != nil {173+ return nil, err174+ }175+ return b.Bytes(), nil176+}177+178+// startJob runs a job in the background.179+func (m *Model) startJob(name string, job nushell.Job) tea.Cmd {180+ m.shell.gen++181+ ctx, cancel := context.WithCancel(context.Background())182+ j := &nuJob{name: name, gen: m.shell.gen, cancel: cancel, start: time.Now()}183+ m.shell.running = j184+ runner, timeout, parent := m.runner(), m.configDuration("nu-timeout", 30*time.Second), m.spans.Parent()185+ return func() tea.Msg {186+ defer cancel()187+ span := parent.Start("nu", slog.Int("tables", len(job.Tables)))188+ data, err := nushell.Exec(ctx, runner, job, timeout, 0)189+ if err != nil {190+ span.Fail(err)191+ } else {192+ span.End(slog.Int("rows", data.Rows))193+ }194+ return nuDoneMsg{job: j, data: data, err: err}195+ }196+}197+198+// finishRegion shows what a command made, or why it failed, and starts199+// the next region queued.200+func (m *Model) finishRegion(msg nuDoneMsg) tea.Cmd {201+ if m.shell.running != msg.job {202+ return nil // stopped, and another started since203+ }204+ m.shell.running = nil205+ name := msg.job.name206+ s, r, ok := m.book().Region(name)207+ switch {208+ case !ok:209+ case msg.err != nil:210+ m.failRegion(r.Name, runError(msg.err))211+ m.clearQueue()212+ return nil213+ default:214+ delete(m.shell.failed, strings.ToUpper(r.Name))215+ s.SetRegionStatus(r.Name, "")216+ if err := s.ShowRegion(r.Name, msg.data); err != nil {217+ m.failRegion(r.Name, err.Error())218+ break219+ }220+ m.shell.said = m.th.Hint.Render(r.Name + ": " + transfer.Rows(max(msg.data.Rows-1, 0)) + noteSuffix(msg.data.Note))221+ }222+ if len(m.shell.queue) == 0 && time.Since(msg.job.start) > 5*time.Second {223+ return tea.Batch(m.nextRegion(), m.term.notify("Shell commands finished in "+m.displayName()))224+ }225+ return m.nextRegion()226+}227+228+func noteSuffix(note string) string {229+ if note == "" {230+ return ""231+ }232+ return "; " + note233+}234+235+// runError is what a failed run says.236+func runError(err error) string {237+ var nuErr *nushell.Error238+ switch {239+ case errors.Is(err, context.Canceled):240+ return "stopped"241+ case errors.As(err, &nuErr):242+ return nuErr.Msg243+ }244+ return err.Error()245+}246+247+// failRegion marks a region failed, saying why on its label and the248+// context line.249+func (m *Model) failRegion(name, why string) {250+ if m.shell.failed == nil {251+ m.shell.failed = map[string]string{}252+ }253+ m.shell.failed[strings.ToUpper(name)] = why254+ m.setStatus(name, "failed")255+ if why == "stopped" {256+ m.setStatus(name, "stopped")257+ }258+ m.shell.said = m.th.Warning.Render(name + " failed: " + why)259+ if why == "stopped" {260+ m.shell.said = m.th.Hint.Render("Stopped " + name)261+ }262+ m.warn = m.shell.said263+}264+265+// clearQueue forgets the regions waiting to run.266+func (m *Model) clearQueue() {267+ for _, name := range m.shell.queue {268+ m.setStatus(name, "")269+ }270+ m.shell.queue = nil271+}272+273+// stopShell stops the command running and those waiting.274+func (m *Model) stopShell() {275+ if j := m.shell.running; j != nil {276+ j.cancel()277+ }278+ m.clearQueue()279+}280+281+// loadWords asks nu for its command names, once a session, for the282+// prompt's completions.283+func (m *Model) loadWords() tea.Cmd {284+ if m.shell.asked || m.shellOff() != "" {285+ return nil286+ }287+ m.shell.asked = true288+ runner := m.runner()289+ return func() tea.Msg {290+ words, _ := nushell.Commands(context.Background(), runner, 30*time.Second)291+ return nuWordsMsg{words}292+ }293+}294+295+// Config helpers that work without a config, as in tests.296+297+func (m *Model) configString(name, def string) string {298+ if c := m.prefs.Config; c != nil {299+ return c.String(name)300+ }301+ return def302+}303+304+func (m *Model) configBool(name string, def bool) bool {305+ if c := m.prefs.Config; c != nil {306+ return c.Bool(name)307+ }308+ return def309+}310+311+func (m *Model) configDuration(name string, def time.Duration) time.Duration {312+ if c := m.prefs.Config; c != nil {313+ return c.Duration(name)314+ }315+ return def316+}317+318+// startShell opens the prompt as the program starts, when 012 nu asked.319+func (m *Model) startShell() tea.Cmd {320+ if !m.shell.startup {321+ return nil322+ }323+ m.shell.startup = false324+ return m.openShell("")325+}
M internal/ui/overlay/overlay.go +10 -0
M internal/ui/panel.go +9 -0
M internal/ui/pivot.go +4 -0
M internal/ui/sort.go +3 -0
M internal/ui/theme/scheme.go +1 -1
M internal/ui/theme/theme.go +7 -3
@@ -41,9 +41,12 @@ type Theme struct {
4141 // ErrorMark is layered on an error's text, so errors show beyond4242 // color: a curly underline, in the error color where the terminal4343 // supports colored underlines.44- ErrorMark lipgloss.Style45- Link lipgloss.Style // a cell's URL or HYPERLINK label, layered on the cell's role46- Spilled lipgloss.Style // values an array formula spilled into the cells below and right of it44+ ErrorMark lipgloss.Style45+ Link lipgloss.Style // a cell's URL or HYPERLINK label, layered on the cell's role46+ Spilled lipgloss.Style // values an array formula spilled into the cells below and right of it47+ // Region is a notebook region's label line, its name and command,48+ // dimmed and underlined as wide as its table: the top of its frame.49+ Region lipgloss.Style4750 Found lipgloss.Style // cells matching an open search4851 Traced lipgloss.Style // precedents or dependents being traced4952 Argument lipgloss.Style // the argument at the caret in a function's signature
@@ -257,6 +260,7 @@ func roles(dark bool) Theme {
257260 ErrorMark: lipgloss.NewStyle().UnderlineStyle(lipgloss.UnderlineCurly).UnderlineColor(lipgloss.Red),258261 Link: lipgloss.NewStyle().Foreground(link).Underline(true),259262 Spilled: lipgloss.NewStyle().Foreground(bar).Italic(true),263+ Region: lipgloss.NewStyle().Foreground(muted).Underline(true).UnderlineSpaces(true),260264 Found: lipgloss.NewStyle().Background(lipgloss.Yellow).Foreground(lipgloss.Black),261265 Traced: lipgloss.NewStyle().Background(lipgloss.Green).Foreground(lipgloss.Black),262266 Argument: lipgloss.NewStyle().Bold(true).Underline(true),
M internal/ui/view.go +3 -0