- commit
- 3f008192e7fcc0222a955e9bb76ba94c9f5be48e
- parent
- 30ba8702c01f270151e5e1644febfedf51f303c4
- parent
- 6328ad1a0791ef9a0cc8d0ea1a24518e87253eea
- author
- Dami <dami.etoile@gmail.com>
@@ -29,7 +29,8 @@ rest: building from a clone, importing files, `012 serve` and settings.
2929 mouse, named ranges, references between sheets, arrays that spill3030 (FILTER, SORT, UNIQUE, LAMBDA), optional decimal3131 arithmetic for money, and undo for everything32- ([formulas](docs/formulas/README.md)).32+ ([formulas](docs/formulas/README.md)); numbers, dates and formulas33+ typed and shown in a [locale](docs/sheets/locale.md) per file.3334 - **Data tools**: freeze, sort, filter, find and replace, conditional3435 formatting, dropdowns and checkboxes, notes, protected ranges, and live3536 pivot tables ([working with data](docs/sheets/README.md)); wrapped@@ -56,19 +57,16 @@ rest: building from a clone, importing files, `012 serve` and settings.
5657 | Menus and the command palette (Ctrl+K) | Charts that float over the grid and follow their data |5758 |  |  |5859 | Conditional formatting and data validation | Pivot tables, live |59-|  |  |60-| Freeze, sort and filter | Find and replace |61-|  |  |62-| JEV functions in formulas | Formats detected as you type |63-|  |  |64-| Arrays that spill | Macros |65-|  |  |66-| Wrapped text, borders and merged cells | A locale per file |60+|  |  |61+| Arrays that spill | Wrapped text, borders and merged cells |62+|  |  |63+| JEV functions in formulas | Macros |6764 68-The recordings are [VHS](https://github.com/charmbracelet/vhs) tapes in69-[`demos/`](demos), rendered by `make demos` in the Catppuccin Mocha70-palette; 012 draws in your terminal's own colors by default71-([themes](docs/terminal/themes.md)). Charts show as text here.65+Each guide shows its own recordings too. They are66+[VHS](https://github.com/charmbracelet/vhs) tapes in [`demos/`](demos),67+rendered by `make demos` in the Catppuccin Mocha palette; 012 draws in68+your terminal's own colors by default ([themes](docs/terminal/themes.md)).69+Charts show as text here.7270 7371 ## More7472 @@ -18,7 +18,6 @@ Sizes: S (a day or two), M (about a week), L (weeks).
1818 1919 | Item | Why | Size |2020 |---|---|---|21-| Open 012's XLSX output in Excel and LibreOffice before releases, as was done for Google Sheets | The writer is otherwise checked by excelize and 012's reader | S |2221 | Conditional formatting and validation, what's left: data bars and icon sets, "top 10", "duplicate values", date periods (this week, last month), rules moving with cells cut to another sheet, a dropdown's chip look, and custom checkbox values | Excel's other rule types come in as notes | M |2322 2423 ### 2. Spreadsheet features Sheets users reach for@@ -35,17 +34,8 @@ rises in measured steps; see [Bounds of support](docs/contributing/limits.md#wha
3534 3635 | Item | Result | Size |3736 |---|---|---|38-| Compact spilled and pivot cells: derived cells in the compact slot form instead of whole `Cell`s | Large spills and pivots cost what plain cells cost ([Bounds of support](docs/contributing/limits.md#what-would-raise-the-bounds)) | M |3937 | Linked, paged read-only ranges over Parquet and SQLite that feed pivots and formulas by streaming | Sources too big for any grid | L |4038 41-### 4. Macros, keys and the terminal42-43-| Item | Size |44-|---|---|45-| Vim: `.` repeat, registers, marks, `cc`/`s`, command-line history, `:w!` | M |46-| Hold-to-preview and Shift+Enter on terminals with the kitty keyboard protocol (`View.KeyboardEnhancements`) | S |47-| Sixel chart images, redrawn on resize, for terminals without kitty graphics | M |48-4939 ## Later: sharing a live sheet (shelved)5040 5141 Explored, not scheduled. `012 serve` gives each SSH session its own@@ -74,9 +64,9 @@ by a gateway dialing the iroh ticket.
7464 - Dynamic arrays that spill (FILTER, SORT, UNIQUE, SEQUENCE and more, ARRAYFORMULA), LET and LAMBDA, SPLIT and the REGEX functions: [Arrays and spills](docs/formulas/arrays.md)7565 - Opt-in decimal arithmetic for money: [Decimal arithmetic](docs/formulas/decimal.md)7666 - A locale per file, as Sheets' File > Settings > Locale: decimal commas, date order, currency and `;` in formulas, typed and shown while files store en-US's form: [Locale](docs/sheets/locale.md)77-- An Excel-sized grid with a `max-cells` budget; operations cost the data, not the grid: [Bounds of support](docs/contributing/limits.md#sheet-size)78-- Compact column storage and a ten-million-cell `max-cells` budget: [Bounds of support](docs/contributing/limits.md#sheet-size)67+- An Excel-sized grid in compact column storage, with a ten-million-cell `max-cells` budget; operations cost the data, not the grid: [Bounds of support](docs/contributing/limits.md#sheet-size)7968 - Undo steps in the compact form: clearing a full ten-million-cell sheet holds about what the sheet does, and a step past 1 GB asks first: [Bounds of support](docs/contributing/limits.md#undo)69+- Spilled cells and pivot results in the compact form, about 20 B each: [Bounds of support](docs/contributing/limits.md#sheet-size)8070 8171 **Finding and using features**8272 @@ -84,6 +74,7 @@ by a gateway dialing the iroh ticket.
8474 - Formula suggestions, argument hints and pointing at cells and sheets: [Building formulas](docs/formulas/building.md)8575 - Tracing precedents and dependents, hidden sheets explained: [Building formulas](docs/formulas/building.md)8676 - An optional vim keymap with a `:` command line: [Keys and mouse](docs/reference/keys.md#vim-keys)77+- Vim `.` repeat, registers, marks, `cc` and `s`, `:` line history and `:w!`: [Keys and mouse](docs/reference/keys.md#vim-keys)8778 8879 **Data tools**8980 @@ -104,6 +95,8 @@ by a gateway dialing the iroh ticket.
10495 - Color schemes and a config file: [Themes](docs/terminal/themes.md), [Configuration](docs/reference/config.md)10596 - A high-contrast theme at WCAG AAA, and every state readable without color: [Themes](docs/terminal/themes.md#high-contrast), [UX](docs/contributing/ux.md#reading-without-color)10697 - `012 serve` over SSH, with files on the ssh command line and recovery of unsaved work: [Serving over SSH](docs/terminal/ssh.md)98+- 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)99+- Sixel chart images on terminals without kitty graphics, drawn after the frame and redrawn as the screen moves: [Charts](docs/sheets/charts.md)107100 108101 **Files**109102 @@ -113,7 +106,7 @@ by a gateway dialing the iroh ticket.
113106 **Upkeep**114107 115108 - `make check` before every push; demo tapes (`make demos`); annotated version tags with release notes (v0.2.0 onward; v0.1.0 remains on the Go module proxy): [Testing](docs/contributing/testing.md)116-- A release checklist and `make dist`, release archives cross-compiled locally with SHA256SUMS: [Releasing](docs/contributing/releasing.md)109+- A release checklist (XLSX output opened in Excel, LibreOffice and Google Sheets) and `make dist`, release archives cross-compiled locally with SHA256SUMS: [Releasing](docs/contributing/releasing.md)117110 - `make stress-report` flags regressions against the last release's run, allowing for noise: [Observability](docs/contributing/observability.md#regressions-against-the-last-release)118111 - Grafana: recent traces and a trace view of the nested spans: [Observability](docs/contributing/observability.md#the-stack)119112 - Every overlay, prompts and formula suggestions behind narrow hosts, most in packages of their own with fake-host tests: [Architecture](docs/contributing/architecture.md#the-ui)@@ -123,7 +123,9 @@ style.
123123 its slot, with its text in a table of strings kept once each, its124124 formatting in a table of looks, and its input only when that isn't125125 the value's own text ("1.50" is kept as 1.5 printed with two126- decimals). Formulas, notes, and what pivots and spills write are whole126+ decimals). What pivots and spills write is a slot too, marked as127+ derived, holding the value and, in its look, a spill's inferred128+ format; its entry is the value's text. Formulas and notes are whole127129 `Cell`s in a side table, which recalculation updates in place. `get`128130 hands out a plain cell as a `Cell` made for the caller, a copy whose129131 changes reach nothing; `set` is the one way to change a cell. What@@ -328,7 +330,7 @@ move without touching callers:
328330 the places functions read ranges.329331 - **Files.** The `.012` reader and writer stream (`fileread.go`,330332 `filescan.go`) and meet the store only through its methods, so another331- encoding would sit beside them; [The .012 format](../files/format.md#reading-and-writing)333+ encoding would sit beside them; [Bounds of support](limits.md#the-012-file)332334 says why there is one.333335 - **Depth limits.** `internal/formula`'s parser caps nesting at334336 `formula.MaxDepth` (1024 levels), so a pathological formula fails to@@ -374,7 +376,7 @@ draw. The components:
374376 | mouse | `mouseState` | drags, hover, double clicks, the fill handle (`mouse.go`, `fill.go`) |375377 | import | `transfer.Transfer` | the import in progress, its progress display and cancelling (package `transfer`); choosing and placing imports (`transfer.go`, `importplace.go`) |376378 | 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`) |377-| others | `clipboard`, `trace`, `chartState`, `jevRunner`, `terminal` | what Ctrl+V pastes, a trace being shown, chart commands' target, JEV questions in flight, what the terminal supports and the chart images sent to it |379+| 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`) |378380 379381 Overlays implement `overlay.Overlay` (package `overlay`): an indicator for380382 the mode, `Key` and `Mouse` handlers, a `Layout` of boxes to draw, and@@ -398,9 +400,9 @@ methods off `ui.Model`'s exported API:
398400 | `shortcuts` | `shortcuts.Host` | theme, size, close, the rows, built from the key bindings and the registry (4) |399401 | `sortbar` | `sortbar.Host` | theme, size, the sheet, close, sort (recorded as the bar's command) (5) |400402 | `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 |401-| `cmdline` | `cmdline.Host` | theme, size, the edit line, close, the commands to complete, run a line, fail (7) |403+| `cmdline` | `cmdline.Host` | theme, size, the edit line, close, the commands to complete, run a line, fail, the session's history (8) |402404 | `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) |403-| `themepicker` | `themepicker.Host` | a picker's host, and the current theme, the themes directory, preview, keep (9) |405+| `themepicker` | `themepicker.Host` | a picker's host, and the current theme, the themes directory, preview, keep, whether keys can be held (10) |404406 | `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) |405407 | `findbar` | `findbar.Host` | theme, size, the edit line, the workbook, the trace, the sheet and cell shown, show a cell, note, run a replacement (recorded as Find and replace), leave keeping the search, pass a click to the grid (11) |406408 | `tabstrip`, `transfer` | none | they're handed a view or messages and draw what they're given |@@ -415,7 +417,7 @@ implements itself:
415417 | `menuHost` | menus | 9 | their items are the command registry and the menu bar's definitions |416418 | `macrosHost` | the macro manager | 9 | a few keys over a picker; what they do is the macro machinery |417419 | `pivotHost` | the pivot editor and its field picker | 16 | it opens the model's pickers, filter values and range prompt and comes back from them |418-| `chartHost` | the chart editor and a selected chart | 23 | besides the charts, the grid's geometry, the pointer shape, the chart menu and prompts, and passing keys and clicks on to the grid |420+| `chartHost` | the chart editor and a selected chart | 25 | besides the charts, the grid's geometry, the pointer shape, the chart menu, holding Space to zoom, and prompts, and passing keys and clicks on to the grid |419421 420422 The cell entry has no host: typing into a cell is the model's ENTER,421423 EDIT and POINT modes, whose state is the model's edit line, active cell@@ -526,8 +528,12 @@ the user's editor (`AllowEditor`), since that starts a program.
526528 `internal/chart` draws a chart in two layers that share one layout: text527529 for any terminal (block elements for bars, braille for lines, half blocks528530 for pies, eighths filled column by column for areas) and, on terminals529-with kitty graphics, an image of the plot area with the axes and legend530-still terminal text. Each chart type is a layout in the `types` table,531+with kitty or sixel graphics, an image of the plot area with the axes and532+legend still terminal text. A kitty image is placed by placeholder533+characters, which the renderer treats as text; a sixel image534+(`chart.Sixel`) is pixels Bubble Tea's renderer doesn't know of, so535+`internal/ui/sixel.go` keeps blank cells for it, draws it once the frame536+has settled, and clears the screen when it moves. Each chart type is a layout in the `types` table,531537 returning a plan that draws it both ways, and the series its legend532538 lists; `Draw` places the legend and gives the layout the room left. The533539 type constants and their names, and the options of each chart@@ -38,7 +38,7 @@ it lags, and past a second it stalls.
3838 | Find, filter, sort, fill | Filter or sort 8191 rows: 1 to 32 ms; find over 213 k cells: 38 ms; fill 8192 rows: 4 ms | Replace all over 213 k cells: 280 ms | | Per-cell string conversion and regexp |3939 | SSH sessions (`012 serve`) | 50 sessions typing at once: frames within one frame interval (p95 8.7 ms), 1.3 MiB per session plus its sheets (upper bound) | | More than `--max-sessions` (8 by default): turned away | Bubble Tea's 120 fps pacing; per session, the terminal's cell buffers |4040 | Pivot tables | A pivot over 8191 rows recomputes after an edit to its source in 0.8 to 2.5 ms | | Results past the grid (the pivot shows #REF!) | Grouping the source rows |41-| Arrays and spills | FILTER, SORT or UNIQUE over 8192 rows: about 0.1 ms each per edit; 1000 of them spilling 516 k cells, an edit recomputing 500: 52 ms | Full recalculation of those 1000: 217 ms | An array past `maxArray` values (2,097,152) stored: `#VALUE!`; a spill past the sheet's edge or `max-cells` cells: `#REF!` | Computing each array's values; writing only the spilled cells that changed |41+| Arrays and spills | FILTER, SORT or UNIQUE over 8192 rows: about 0.1 ms each per edit; 1000 of them spilling 516 k cells, an edit recomputing 500: 52 ms | Full recalculation of those 1000: 106 ms | An array past `maxArray` values (2,097,152) stored: `#VALUE!`; a spill past the sheet's edge or `max-cells` cells: `#REF!` | Computing each array's values; writing only the spilled cells that changed |4242 4343 ## Sheet size4444 @@ -48,9 +48,8 @@ ten million cells by default): imports keep whole rows up to it, pastes
4848 and fills write at most that many cells at once. WK1 files keep their own4949 8192 x 256. The costs below grow with the cells; the grid itself costs5050 nothing (see [The grid](#the-grid)). A `.012` file is read and written51-as a stream, cells straight into and out of the store ([The .01252-format](../files/format.md#reading-and-writing)), so opening costs about53-the memory the workbook then holds.51+as a stream, so opening costs about the memory the workbook then holds52+([The .012 file](#the-012-file)).5453 5554 | Measure | 8192 x 26 (213 k cells) | 8192 x 256 (2.1 M cells) | 1,000,000 x 10 (10 M cells) |5655 |---|---|---|---|@@ -71,13 +70,32 @@ B (the parsed tree and reference lists); imported CSV, SQLite and
7170 Parquet 21 to 63 B; text about 60 B plus its length, once per distinct string7271 (a column of 90 texts repeated takes 27 B a cell). A plain cell (a7372 number, boolean or text as typed, with a format and style) is a 16-byte74-slot in its column's block of 1024 rows; formulas, notes, and the75-results pivots and spills write are whole `Cell`s, about 300 B each with76-their entry and input (the `Cell` itself is 216 B). How the store lays73+slot in its column's block of 1024 rows, and so is a derived cell, what74+a pivot or a spill writes: a pivot's results take 20.6 B a cell and75+spilled numbers 20.5 B, plus the 32 B a value of the array the anchor76+keeps to compare with the next one (`BenchmarkMemoryDerived`). Formulas77+and notes are whole `Cell`s, about 300 B each with their entry and78+input (the `Cell` itself is 216 B). How the store lays7779 them out is in [Architecture](architecture.md#the-engine). Reading a7880 cell's value is an index into its column's block: 2.8 ns7981 (`BenchmarkRead`), 8.6 ns a cell read by a SUM in a full recalculation.8082 83+### The .012 file84+85+The reader and writer stream (`internal/sheet/fileread.go`,86+`filescan.go`): each cell goes into its sheet as its line is read, a87+number typed plainly straight into its 16-byte slot, and the other88+fields are small and decoded whole. Saving writes the cells in89+row-major order as it reads them from the sheet, straight into the90+file.91+92+There is one format. A binary one that stored the slots themselves93+would open ten million numbers in about 0.3 s at best (storing ten94+million slots alone takes 0.17 s) where the JSON takes 1.2 s: a second95+saved on sheets near `max-cells`, and nothing a smaller sheet would96+notice, for the loss of what the JSON gives, diffs, merges, and a file97+anyone can read and fix.98+8199 ## The grid82100 83101 Nothing costs what the grid costs. The cells' occupancy@@ -210,7 +228,9 @@ every cell on screen a shade), at 200 x 60 (`BenchmarkFrame`,
210228 | Typing a number and Enter, through to the frame | 0.89 ms | 2.3 ms (the scale's percentile over 213 k numbers) |211229 212230 Shades and rule colors keep their escape codes, so a plain cell on one213-costs a string concatenation, not a style render.231+costs a string concatenation, not a style render. A custom formula is232+evaluated for each cell drawn, once per recalculation, with its233+references moved for the cell.214234 215235 Wrapped text, borders, row heights and merged cells cost what the216236 screen shows too. A sheet with none of them is drawn a line per row@@ -226,16 +246,17 @@ top (`BenchmarkFrame`, `BenchmarkKeystroke`):
226246 |---|---|---|---|227247 | 8192 x 26 numbers, a frame | 0.18 ms | 0.90 ms | 3.1 ms |228248 | The same laid out, a frame | 0.24 ms | 1.28 ms | 4.3 ms |229-| The same laid out, an arrow key through to its frame | 0.25 ms | 1.25 ms | | A custom formula is230-evaluated for each cell drawn, once per recalculation, with its231-references moved for the cell.249+| The same laid out, an arrow key through to its frame | 0.25 ms | 1.25 ms | |232250 233251 A chart drawn as text costs 4 to 50 us at 24 x 10 to 120 x 40 cells,234252 whatever its data: only the categories that fit are drawn (a pie of 8192235253 slices, 0.44 ms, is the worst). An image for kitty graphics is redrawn236254 when its data, size or theme changes: 25 to 260 us at 24 x 10 cells and237255 1 to 1.5 ms at 120 x 40, a pie 8.6 ms (`internal/chart`,238-`BenchmarkDraw`, `BenchmarkImage`). Key presses add little: an arrow key256+`BenchmarkDraw`, `BenchmarkImage`). A sixel image is encoded then too,257+in 0.07 to 0.3 ms at 24 x 10 cells and 3 to 7 ms at 120 x 40 (10 x 20258+pixel cells), 0.6 to 67 KB to send (`BenchmarkSixel`); it is sent again,259+without encoding, whenever the screen under it is redrawn. Key presses add little: an arrow key239260 through to its frame is 0.19 ms at 80 x 24 and 0.9 ms at 200 x 60;240261 Page Down 1.1 ms.241262 @@ -319,7 +340,7 @@ entities, so entity bombs fail as unknown entities.
319340 Each step keeps the cells it changed as they were, in the form the320341 sheet keeps them (`historyimage.go`): a plain cell as its 16-byte slot321342 in columns of blocks, its text and formatting in the step's own tables,322-and a whole `Cell` only for a formula, a note or a derived cell. A step343+and a whole `Cell` only for a formula or a note. A step323344 of one cell or a few keeps them in a list. Undoing costs about what the324345 change cost, since it recalculates the same cells. Clearing 213 k cells325346 and undoing it takes 86 ms; clearing all ten million cells of a full@@ -498,10 +519,10 @@ FILTERs, 63 of whose arrays change:
498519 499520 | Measure | Time | Allocated |500521 |---|---|---|501-| Edit a number | 52 ms | 161 MB, 7.9 k allocations |502-| Undo and redo it | 103 ms | |503-| Full recalculation | 217 ms | |504-| Build (load and recalculate) | 298 ms | |522+| Edit a number | 52 ms | 161 MB, 7.8 k allocations |523+| Undo and redo it | 106 ms | |524+| Full recalculation | 106 ms | |525+| Build (load and recalculate) | 181 ms | |505526 506527 Most of an edit is the FILTERs' own work: comparing 8192 categories each507528 (`B1:B8192="alpha"`, about 20 ns a value) and picking the rows that@@ -535,10 +556,10 @@ Sizes and scheduling are in the [roadmap](../../ROADMAP.md#3-scale).
535556 `max-cells` stands at ten million, as Google Sheets' limit does; the536557 sheet itself would hold several times that in a few GB, and opening,537558 saving and undoing cost about what the cells do ([Sheet size](#sheet-size),538-[Undo](#undo)). What stands in the way is derived cells: what arrays539-and pivots write is kept as whole `Cell`s (516 k of them in the arrays540-stress shape), where their values and inferred formats would fit in541-slots.559+[Undo](#undo)). Derived cells cost what plain cells cost, a slot each560+([Sheet size](#sheet-size)); what stays large is a formula, about 750 B561+with its parsed tree and reference lists, and the array a spilling562+formula keeps, 32 B a value.542563 543564 ## Measuring544565 @@ -39,26 +39,16 @@ only for their formatting and notes.
3939 4040 ## Reading and writing4141 42-012 reads and writes the file as a stream (`internal/sheet/fileread.go`,43-`filescan.go`). Each cell goes into its sheet as its line is read, a44-number typed plainly straight into the 16-byte slot the sheet keeps it45-in, so opening a file takes about the memory of the workbook, however46-large the file; the other fields are small and decoded whole. Saving47-writes the cells in row-major order as it reads them from the sheet,48-straight into the file. Ten million numbers (236 MB of JSON) open in49-1.2 s and save in 0.5 s ([Bounds of support](../contributing/limits.md#sheet-size)).50-5142 Any JSON with the same fields reads the same: the order of keys,5243 spacing, and the case of field names don't matter. A cell given twice5344 takes its last entry, though an invalid earlier one is refused; the5445 `version` or the `sheets` list given twice around cells is refused.5546 56-There is one format. A binary one that stored the slots themselves57-would open ten million numbers in about 0.3 s at best (storing ten58-million slots alone takes 0.17 s) where the JSON takes 1.2 s: a second59-saved on sheets near `max-cells`, and nothing a smaller sheet would60-notice, for the loss of what the JSON gives, diffs, merges, and a file61-anyone can read and fix.47+012 reads and writes the file as a stream, each cell straight into or48+out of its sheet, so opening a file takes about the memory of the49+workbook however large the file; what that costs, and why there is no50+binary format beside this one, is in51+[Bounds of support](../contributing/limits.md#the-012-file).6252 6353 ## Versions6454 @@ -5,6 +5,8 @@ sidebar_position: 7
55 66 # JEV functions77 8+9+810 With a TypeSafe API key, four functions ask the hosted JEV model about a911 value (a cell, a range or text). They follow Sheets' argument style: the1012 value, the question, then what the answers mean.@@ -22,8 +24,6 @@ line shows the confidence for the selected cell. Data > Ask JEV again
2224 re-asks the selection.2325 2426 Without a key the functions show `#N/A` and say how to add one.25-`JEV_LIVE_TEST=1 go test ./internal/jev -run TestLive` checks the real26-service.2727 2828 ## Setup2929 @@ -104,7 +104,7 @@ Colors. `terminal` uses the terminal's own 16-color palette. `high-contrast` dra
104104 105105 #### `chart-images`106106 107-Draw charts as images on terminals with kitty graphics (kitty, Ghostty, WezTerm). When false, charts are always text.107+Draw charts as images on terminals with kitty graphics (kitty, Ghostty, WezTerm) or, outside tmux, sixel graphics (foot, xterm, mlterm, Windows Terminal). When false, charts are always text.108108 109109 | | |110110 |---|---|@@ -124,6 +124,20 @@ range, F2 renames or repoints it and Ctrl+D deletes it.
124124 | Ctrl+Alt+Shift+0 to 9 | Run the macro with that shortcut ([Macros](../sheets/macros.md#running)) |125125 | Esc | Stop a macro while it runs (the mode indicator says CMD) |126126 127+## Keys the terminal has to tell apart128+129+Terminals send some keys as they send others: Shift+Enter as Enter,130+Ctrl+I as Tab, Ctrl+M as Enter, and Alt and a letter as Esc typed just131+before the letter. A terminal with the kitty keyboard protocol132+([Terminal features](../terminal/README.md#terminal-features-012-uses))133+tells them apart and says when a key is let go, and there 012 also does:134+135+| Key | Action | Where the terminal can't tell |136+|---|---|---|137+| Shift+Enter | Accept an entry and move up, as in Sheets | As Enter: accept and move down |138+| Ctrl+I | Italic | As Tab: move right |139+| Space, held | On a selected chart, show it across the grid until Space is let go; in File > Settings > Theme with nothing typed, hide the list to see the whole sheet in the highlighted theme | Space does what it does there: deselects the chart and starts an entry, or types a space |140+127141 ## Vim keys128142 129143 File > Settings > Vim keys (also `:settings.vim` or the palette) turns@@ -153,9 +167,16 @@ show them where Sheets' keys are taken.
153167 | `i` `a`, Enter | Edit the cell, caret at the start or the end |154168 | `=` | Start a formula |155169 | `o` `O` | Insert a row below or above and start typing in it |156-| `x` | Clear the cell (with a count, that many to the right) |170+| `x` | Clear the cell, keeping a copy to paste (with a count, that many to the right) |171+| `s` | Clear the cell and start typing in it |157172 | `dd` `yy` | Cut or copy the row (with a count, that many rows) |173+| `cc` `S` | Clear the row's cells, keeping the row and its formats and a copy to paste, and start typing in the active cell |158174 | `p` `P` | Paste rows cut or copied with `dd` or `yy` as new rows below or above, with their row formats; other copied cells paste at the active cell |175+| `.` | Repeat the last change: `x`, `s`, `dd`, `cc`, `p`, `o`, or an edit begun with `i`, `a` or `=` with what was typed; a count replaces the change's own (`3.`) |176+| `"a` to `"z`, `"0` to `"9`, `"-`, `"+` | Name the register the next `yy`, `dd`, `x`, `p` or VISUAL `y`, `d`, `p` uses (see [Registers](#registers)) |177+| `ma` to `mz` | Mark the active cell |178+| `` `a `` to `` `z ``, `'a` to `'z` | Go to the mark, or to its row in column A (on the mark's sheet) |179+| `''` ``` `` ``` | Back to where the last jump (`gg`, `G`, `H`, `M`, `L`, `/`, `n`, `N`, a mark, a `:` cell or row) left from |159180 | `u`, Ctrl+R | Undo, redo |160181 | `v` `V` | Select cells or whole rows (VISUAL); motions stretch the selection |161182 | `/`, `n` `N` | Find (Enter stays on the match), next and previous match |@@ -172,6 +193,24 @@ show them where Sheets' keys are taken.
172193 | `:` | The command line, acting on the selection |173194 | `v` `V`, Esc | Back to NORMAL |174195 196+### Registers197+198+Copies and deletes go to the clipboard Ctrl+V pastes, which is vim's199+unnamed register (`""`), and also to a register of their own:200+201+| Register | Holds |202+|---|---|203+| `"a` to `"z` | What the operator after it copied or deleted (`"ayy`, `"bx`), until written again; `"ap` pastes it |204+| `"0` | The last copy made without naming a register |205+| `"1` to `"9` | The last rows deleted (`dd`, `cc`, VISUAL `V` `d`), the newest in `"1` |206+| `"-` | The last cells deleted (`x`, `s`, VISUAL `v` `d`) |207+| `"+` | The system clipboard: every copy goes there (OSC 52), and `"+p` pastes what the terminal reads back from it, where the terminal allows reading it |208+209+Rows in a register paste as new rows, as they do after `dd` and `yy`.210+Registers and marks belong to the workbook open, as the clipboard does.211+212+### The command line213+175214 The `:` line takes:176215 177216 | Command | Action |@@ -179,6 +218,7 @@ The `:` line takes:
179218 | `:B12`, `:Sheet2!A1`, `:C3:D9`, `:Sales` | Go to a cell, range or named range |180219 | `:40` | Go to row 40 |181220 | `:w`, `:w name`, `:w out.csv` | Save, Save as, or Download as another format, as the File menu does |221+| `:w!`, `:w! name`, `:wq!` | Save even if the file changed on disk since it was opened, and replace a file of the name given, without asking (as choosing Overwrite or Replace would) |182222 | `:q`, `:q!` | Quit (asking about unsaved changes), quit discarding them |183223 | `:wq`, `:x` | Save and quit; `:x` saves only if something changed |184224 | `:e name`, `:e!` | Open a sheet or import a file; refused with unsaved changes unless `:e!` |@@ -186,9 +226,13 @@ The `:` line takes:
186226 187227 Completions for commands appear under the line as you type, drawn from188228 the same registry as the menus and palette: Tab puts the highlighted189-one on the line (Tab again for the next, Shift+Tab back), Up and Down190-move, and Enter runs the line, or the highlighted completion when the191-line isn't a command itself (`:fill d` Enter fills down).229+one on the line (Tab again for the next, Shift+Tab back), Ctrl+N and230+Ctrl+P move, and Enter runs the line, or the highlighted completion when231+the line isn't a command itself (`:fill d` Enter fills down). Up and232+Down go back and forth through the lines run before, as in vim: only233+those starting with what's typed (`:w` Up finds the last `:w` line).234+The history lasts until 012 closes, each `012 serve` session keeping its235+own.192236 193237 ## Mouse194238 @@ -5,6 +5,8 @@ sidebar_position: 10
55 66 # Charts77 8+9+810 Insert > Chart charts the selection, or the table around the active cell,911 guessing the header row, the label column and a title. The chart floats over1012 the grid, pinned to a cell, and redraws when its data changes.@@ -34,12 +36,22 @@ chart these options apply along the bottom; on a scatter, to Y.
3436 3537 Click a chart to select it. Drag it to move it, drag its corner to resize it,3638 or use the arrows and Shift+arrows; Enter edits it, Del deletes it, and37-right-click offers both. Charts follow inserted and deleted rows and39+right-click offers both. In terminals that say when a key is let go40+([Keys the terminal has to tell apart](../reference/keys.md#keys-the-terminal-has-to-tell-apart)),41+holding Space on a selected chart shows it across the grid until Space42+is let go. Charts follow inserted and deleted rows and3843 columns, and save with the sheet.3944 4045 **Images or text.** At startup 012 asks the terminal whether it supports the4146 kitty graphics protocol. Where it does (kitty, Ghostty, WezTerm) the plot is4247 a real image, drawn in the terminal's own palette and placed with Unicode43-placeholders, so redraws never erase it. Elsewhere, and usually inside tmux,44-the plot is drawn with block and braille characters. Axis labels, the45-legend and the frame are text either way.48+placeholders, so redraws never erase it. Otherwise, where the terminal49+lists sixel graphics among its device attributes (foot, xterm, mlterm,50+Windows Terminal), the plot is a sixel image in the terminal's palette,51+in as many colors as it has color registers. A sixel image is drawn over52+the finished screen, so it shows only where its whole plot area does: a53+chart scrolled partly out of the grid, or under another chart, a menu or54+a dialog, is text until it shows whole again, and the image is drawn55+again after scrolling, resizing or an edit to its data. Elsewhere, and56+inside tmux, the plot is drawn with block and braille characters. Axis57+labels, the legend and the frame are text either way.@@ -5,6 +5,8 @@ sidebar_position: 6
55 66 # Find and replace77 8+9+810 Ctrl+F opens a find bar on the context line: matches highlight as you type,911 the active cell follows the current one, and Enter and Shift+Enter step1012 through them. Ctrl+H adds a replacement field; Enter replaces and moves on,@@ -13,6 +13,8 @@ whole columns or rows, then pick a format from the Format menu, a key
1313 1414 ## Number formats1515 16+17+1618 Format > Number, as Sheets names them:1719 1820 | Format | Shows |@@ -53,16 +53,6 @@ results. They follow inserted and deleted rows and columns, and a rule
5353 whose cells are all deleted goes. Format > Clear conditional formats5454 takes the selection out of every rule.5555 56-Rules, conditional formats and validation alike, move with cut and57-paste on a sheet, as in Sheets: the cut cells take their rules to where58-they land, and the cells they land on lose theirs. A rule whose cells59-partly move keeps both parts; with a custom formula it becomes two60-rules, each with the formula written for its own first cell, so the61-cells that stayed read what they read and the moved ones read the cells62-beside their new places. Formulas in rules follow cells that63-move. Cut on one sheet and pasted on another, the cells leave their64-rules behind and meet the rules where they land.65-6656 ## Data validation6757 6858 Data > Data validation opens the sheet's validation rules in the same@@ -96,19 +86,36 @@ own words there. A formula is checked by the value it computes. Blanks
9686 are always valid.9787 9888 Each cell has one rule: a rule added over cells that had another takes99-them from it. Validation checks what's typed, picked from a dropdown and100-entered by macros. Pastes (Ctrl+V, paste values, text pasted from101-another program) and fills (Ctrl+D, Ctrl+R, Ctrl+Enter, the fill102-handle) are checked after they're written, as in Sheets: when a cell's103-rule rejects what landed there, the whole paste or fill is undone and104-ERROR mode says which cell and why ("Paste undone: Invalid entry in105-B2: ..."; Ctrl+Enter keeps the entry open instead); when only rules that106-warn fail, it stays, the cells are marked, and the context line says how107-many ("2 cells invalid, first B2: ..."). Imports aren't checked, and what108-they leave invalid is marked. Validation can't go on a pivot table's109-results, which take no entries. Data > Remove data validation takes the110-selection out of every rule.89+them from it. Validation can't go on a pivot table's results, which take90+no entries. Data > Remove data validation takes the selection out of91+every rule.92+93+What validation checks:94+95+- **Typing, a dropdown's pick and macros' entries**, as they're96+ entered, rejected or warned as above.97+- **Pastes and fills** (Ctrl+V, paste values, text pasted from another98+ program; Ctrl+D, Ctrl+R, Ctrl+Enter, the fill handle), after they're99+ written, as in Sheets. When a cell's rule rejects what landed there,100+ the whole paste or fill is undone and ERROR mode says which cell and101+ why ("Paste undone: Invalid entry in B2: ..."); Ctrl+Enter keeps the102+ entry open instead. When only rules that warn fail, it stays, the103+ cells are marked, and the context line says how many ("2 cells104+ invalid, first B2: ...").105+- **Imports** aren't checked; what they leave invalid is marked.111106 112107 Adding a checkbox or a dropdown changes no cells, so on a protected range113108 it doesn't ask first (on a protected sheet it does); checking a box or114109 picking an item asks as typing does ([protection](notes-protection.md#protected-sheets-and-ranges)).110+111+## Moving cells112+113+Conditional formats and validation rules move with cut and paste on a114+sheet, as in Sheets: the cut cells take their rules to where115+they land, and the cells they land on lose theirs. A rule whose cells116+partly move keeps both parts; with a custom formula it becomes two117+rules, each with the formula written for its own first cell, so the118+cells that stayed read what they read and the moved ones read the cells119+beside their new places. Formulas in rules follow cells that120+move. Cut on one sheet and pasted on another, the cells leave their121+rules behind and meet the rules where they land.@@ -18,10 +18,11 @@ colors that follow light and dark.
1818 1919 | Feature | What for | Where it works |2020 |---|---|---|21-| Kitty keyboard protocol | telling apart keys like Ctrl+I and Tab, Shift+Enter | kitty, Ghostty, WezTerm, foot, Alacritty, iTerm2 |21+| Kitty keyboard protocol | telling apart keys like Ctrl+I and Tab, Shift+Enter, and keys held to preview ([Keys](../reference/keys.md#keys-the-terminal-has-to-tell-apart)) | kitty, Ghostty, WezTerm, foot, Alacritty, iTerm2 |2222 | Mouse, all motion | hover highlights, drag, resize | most terminals |2323 | Pointer shape (OSC 22) | resize and text cursors over the grid | Ghostty, kitty, foot, xterm |2424 | Kitty graphics | chart images | kitty, Ghostty, WezTerm |25+| Sixel graphics (DA1 attribute 4, XTSMGRAPHICS) | chart images where kitty graphics aren't, outside tmux: [Charts](../sheets/charts.md) | foot, xterm, mlterm, Windows Terminal |2526 | Hyperlinks (OSC 8) | links in cells | most modern terminals |2627 | Clipboard (OSC 52) | copying ranges | most terminals; tmux needs `set-clipboard on` |2728 | Progress (OSC 9;4) | JEV and import progress in the tab | Ghostty, Windows Terminal, iTerm2 |@@ -139,7 +139,7 @@ Every session is a separate 012 with nothing shared but the process:
139139 its own workbook, undo history, clipboard, theme, JEV cache and140140 terminal state. What it learns about the terminal comes from the141141 client: the window size and its changes, the colors (from the `TERM`142-the client sends), the light or dark background, kitty graphics, and142+the client sends), the light or dark background, kitty or sixel graphics, and143143 the clipboard, which Ctrl+C sets on the client's machine through OSC 52.144144 145145 [Macros](../sheets/macros.md) can be recorded, run, renamed and deleted in a@@ -39,6 +39,10 @@ Lights), the rest of it searching their names: "light sol" finds
3939 Solarized Light. The highlighted theme is drawn live, Enter keeps it and4040 writes `theme = ...` to the config file, and Esc goes back to the theme you4141 had. With a `light:`/`dark:` pair, the picker changes the one in use.42+In terminals that say when a key is let go ([Keys the terminal has to43+tell apart](../reference/keys.md#keys-the-terminal-has-to-tell-apart)),44+holding Space before typing a search hides the list to show the whole45+sheet in the highlighted theme, until Space is let go.4246 `012 config themes` lists every theme, with a `*` on the current one.4347 4448 @@ -62,9 +66,8 @@ muted text included, and 4.5:1 for lines, borders, chart axes and
6266 unavailable menu items. The pointer is black on cyan, the selection6367 white on blue, errors a light red on black (a dark red on white).6468 65-States never rest on color alone in any theme: the pointer and the66-selection are in reverse video, errors have a curly underline, invalid67-entries a dotted one, spilled values are italic, and so on69+States never rest on color alone in any theme: each also shows as70+text, a glyph or an attribute such as reverse video or an underline6871 ([Reading without color](../contributing/ux.md#reading-without-color)).6972 7073 ## Your own schemes@@ -59,6 +59,9 @@ type session struct {
5959 mev *ghostty.MouseEvent6060 cols uint166161 rows uint1662+ // legacy, for options.legacyKeys, hides the keyboard protocol from63+ // the terminal and sends keys as legacy terminals do: keys_test.go.64+ legacy *keyFilter6265 6366 exited chan struct{} // closed when the pty reader stops6467 }@@ -79,6 +82,10 @@ type options struct {
7982 // images; off by default, so screens show the text charts every8083 // terminal gets.8184 graphics bool85+ // legacyKeys makes the terminal one without the kitty keyboard86+ // protocol or modifyOtherKeys: keys are sent as legacy terminals87+ // send them, and releases never (see keys_test.go).88+ legacyKeys bool8289 // program runs instead of 012, such as an ssh client reaching 0128390 // serve.8491 program string@@ -156,6 +163,10 @@ func startWith(t *testing.T, o options, args ...string) *session {
156163 return ghostty.SizeReportSize{Columns: s.cols, Rows: s.rows, CellWidth: cellW, CellHeight: cellH}, true157164 })158165 166+ if o.legacyKeys {167+ s.legacy = &keyFilter{}168+ }169+ legacy := s.legacy159170 go func() {160171 defer close(s.exited)161172 buf := make([]byte, 32*1024)@@ -163,7 +174,7 @@ func startWith(t *testing.T, o options, args ...string) *session {
163174 n, err := s.pty.Read(buf)164175 if n > 0 {165176 s.mu.Lock()166- s.vt.Write(buf[:n])177+ s.vt.Write(legacy.filter(buf[:n]))167178 s.raw = append(s.raw, buf[:n]...)168179 s.mu.Unlock()169180 }@@ -323,9 +334,19 @@ func (s *session) typeText(text string) {
323334 }324335 325336 func (s *session) press(key ghostty.Key, mods ghostty.Mods, text string, unshifted rune) {337+ s.keyEvent(ghostty.KeyActionPress, key, mods, text, unshifted)338+}339+340+// keyEvent sends a press, repeat or release of key, encoded for the341+// modes the program turned on.342+func (s *session) keyEvent(action ghostty.KeyAction, key ghostty.Key, mods ghostty.Mods, text string, unshifted rune) {343+ if data, ok := legacyBytes(action, key, mods, unshifted); ok && s.legacy != nil {344+ s.write(data)345+ return346+ }326347 s.mu.Lock()327348 s.enc.SetOptFromTerminal(s.vt)328- s.ev.SetAction(ghostty.KeyActionPress)349+ s.ev.SetAction(action)329350 s.ev.SetKey(key)330351 s.ev.SetMods(mods)331352 // Shift that produced the text is consumed by the layout, as a real@@ -342,6 +363,11 @@ func (s *session) press(key ghostty.Key, mods ghostty.Mods, text string, unshift
342363 if err != nil {343364 s.t.Fatalf("encoding key %v: %v", key, err)344365 }366+ s.write(data)367+}368+369+// write sends a key's bytes.370+func (s *session) write(data []byte) {345371 if _, err := s.pty.Write(data); err != nil {346372 s.t.Fatal(err)347373 }@@ -433,51 +459,6 @@ func (s *session) activeScreen() ghostty.TerminalScreen {
433459 return scr434460 }435461 436-var namedKeys = map[string]ghostty.Key{437- "enter": ghostty.KeyEnter, "esc": ghostty.KeyEscape, "backspace": ghostty.KeyBackspace,438- "tab": ghostty.KeyTab, "delete": ghostty.KeyDelete, "home": ghostty.KeyHome, "end": ghostty.KeyEnd,439- "up": ghostty.KeyArrowUp, "down": ghostty.KeyArrowDown,440- "left": ghostty.KeyArrowLeft, "right": ghostty.KeyArrowRight,441- "pgup": ghostty.KeyPageUp, "pgdown": ghostty.KeyPageDown,442- "f1": ghostty.KeyF1, "f2": ghostty.KeyF2, "f5": ghostty.KeyF5, "f10": ghostty.KeyF10, "f11": ghostty.KeyF11,443- "space": ghostty.KeySpace,444-}445-446-type physKey struct {447- key ghostty.Key448- mods ghostty.Mods449- base rune // unshifted character, for shifted symbols450-}451-452-// charKeys maps typed characters to physical keys on a US layout.453-var charKeys = func() map[rune]physKey {454- m := map[rune]physKey{' ': {key: ghostty.KeySpace}}455- for i := range 26 {456- k := ghostty.KeyA + ghostty.Key(i)457- m[rune('a'+i)] = physKey{key: k}458- m[rune('A'+i)] = physKey{key: k, mods: ghostty.ModShift, base: rune('a' + i)}459- }460- digits := ")!@#$%^&*("461- for i := range 10 {462- k := ghostty.KeyDigit0 + ghostty.Key(i)463- m[rune('0'+i)] = physKey{key: k}464- m[rune(digits[i])] = physKey{key: k, mods: ghostty.ModShift, base: rune('0' + i)}465- }466- for _, p := range []struct {467- plain, shifted rune468- key ghostty.Key469- }{470- {'-', '_', ghostty.KeyMinus}, {'=', '+', ghostty.KeyEqual},471- {',', '<', ghostty.KeyComma}, {'.', '>', ghostty.KeyPeriod},472- {'/', '?', ghostty.KeySlash}, {';', ':', ghostty.KeySemicolon},473- {'\'', '"', ghostty.KeyQuote},474- } {475- m[p.plain] = physKey{key: p.key}476- m[p.shifted] = physKey{key: p.key, mods: ghostty.ModShift, base: p.plain}477- }478- return m479-}()480-481462 // configEnv gives a session its own config directory, with o.config as482463 // its config file, and turns off the telemetry, theme and locale variables a483464 // developer's shell may set.@@ -0,0 +1,164 @@
1+package e2e2+3+import (4+ "bytes"5+ "regexp"6+7+ ghostty "go.mitchellh.com/libghostty"8+)9+10+// Keys as a terminal sends them. The program asks for the kitty keyboard11+// protocol (Bubble Tea asks for its first level, 012 for key events too)12+// and libghostty encodes keys for what it turned on, so sessions are13+// Ghostty's. A session with legacyKeys never sees those requests, as a14+// terminal without the protocol ignores them, and sends keys the legacy15+// way.16+17+// keyboardModes are the sequences that push, pop, set and query the kitty18+// keyboard protocol's flags, and turn modifyOtherKeys on or off.19+var keyboardModes = regexp.MustCompile(`\x1b\[(?:[>=<?][0-9;]*u|>4(?:;[0-9]*)?m)`)20+21+// keyFilter takes keyboardModes out of the program's output, keeping a22+// sequence split between reads until its end arrives.23+type keyFilter struct {24+ carry []byte25+}26+27+// filter returns data without keyboardModes. A nil filter changes28+// nothing.29+func (f *keyFilter) filter(data []byte) []byte {30+ if f == nil {31+ return data32+ }33+ data = append(f.carry, data...)34+ f.carry = nil35+ if i := bytes.LastIndexByte(data, 0x1b); i >= 0 && unfinished(data[i:]) {36+ f.carry = append([]byte(nil), data[i:]...)37+ data = data[:i]38+ }39+ return keyboardModes.ReplaceAll(data, nil)40+}41+42+// unfinished reports whether seq, which starts with Esc, may be the43+// start of a CSI sequence yet to end.44+func unfinished(seq []byte) bool {45+ if len(seq) < 2 {46+ return true47+ }48+ if seq[1] != '[' {49+ return false50+ }51+ for _, c := range seq[2:] {52+ if c >= 0x40 && c <= 0x7e {53+ return false54+ }55+ }56+ return true57+}58+59+// legacyBytes is what a terminal without the kitty keyboard protocol or60+// modifyOtherKeys sends for the keys libghostty tells apart even then,61+// and whether key is one: no release at all, Enter whatever the62+// modifiers, Ctrl and a letter as a control character, and Alt and a63+// letter as Esc before the letter.64+func legacyBytes(action ghostty.KeyAction, key ghostty.Key, mods ghostty.Mods, unshifted rune) ([]byte, bool) {65+ letter := unshifted >= 'a' && unshifted <= 'z'66+ switch {67+ case action == ghostty.KeyActionRelease:68+ return nil, true69+ case key == ghostty.KeyEnter && mods&ghostty.ModAlt != 0:70+ return []byte("\x1b\r"), true71+ case key == ghostty.KeyEnter:72+ return []byte("\r"), true73+ case letter && mods == ghostty.ModCtrl:74+ return []byte{byte(unshifted) & 0x1f}, true75+ case letter && mods == ghostty.ModAlt:76+ return []byte{0x1b, byte(unshifted)}, true77+ case letter && mods == ghostty.ModAlt|ghostty.ModShift:78+ return []byte{0x1b, byte(unshifted) - 'a' + 'A'}, true79+ }80+ return nil, false81+}82+83+// hold presses a key written as for keys ("<space>", "<shift+enter>")84+// and keeps it down: the terminal repeats it once, as it does for a key85+// held.86+func (s *session) hold(name string) {87+ s.t.Helper()88+ s.keys(name)89+ s.namedKeyAction(ghostty.KeyActionRepeat, name)90+}91+92+// release lets go of a key held.93+func (s *session) release(name string) {94+ s.t.Helper()95+ s.namedKeyAction(ghostty.KeyActionRelease, name)96+}97+98+// namedKeyAction sends a repeat or release of a key in angle brackets.99+func (s *session) namedKeyAction(action ghostty.KeyAction, name string) {100+ s.t.Helper()101+ name = name[1 : len(name)-1]102+ key, ok := namedKeys[name]103+ if !ok {104+ s.t.Fatalf("unknown key <%s>", name)105+ }106+ var cp rune107+ if name == "space" {108+ cp = ' '109+ }110+ s.keyEvent(action, key, 0, "", cp)111+}112+113+// kittyFlags are the keyboard protocol flags the program has on.114+func (s *session) kittyFlags() ghostty.KittyKeyFlags {115+ s.mu.Lock()116+ defer s.mu.Unlock()117+ f, _ := s.vt.KittyKeyboardFlags()118+ return f119+}120+121+var namedKeys = map[string]ghostty.Key{122+ "enter": ghostty.KeyEnter, "esc": ghostty.KeyEscape, "backspace": ghostty.KeyBackspace,123+ "tab": ghostty.KeyTab, "delete": ghostty.KeyDelete, "home": ghostty.KeyHome, "end": ghostty.KeyEnd,124+ "up": ghostty.KeyArrowUp, "down": ghostty.KeyArrowDown,125+ "left": ghostty.KeyArrowLeft, "right": ghostty.KeyArrowRight,126+ "pgup": ghostty.KeyPageUp, "pgdown": ghostty.KeyPageDown,127+ "f1": ghostty.KeyF1, "f2": ghostty.KeyF2, "f5": ghostty.KeyF5, "f10": ghostty.KeyF10, "f11": ghostty.KeyF11,128+ "space": ghostty.KeySpace,129+}130+131+type physKey struct {132+ key ghostty.Key133+ mods ghostty.Mods134+ base rune // unshifted character, for shifted symbols135+}136+137+// charKeys maps typed characters to physical keys on a US layout.138+var charKeys = func() map[rune]physKey {139+ m := map[rune]physKey{' ': {key: ghostty.KeySpace}}140+ for i := range 26 {141+ k := ghostty.KeyA + ghostty.Key(i)142+ m[rune('a'+i)] = physKey{key: k}143+ m[rune('A'+i)] = physKey{key: k, mods: ghostty.ModShift, base: rune('a' + i)}144+ }145+ digits := ")!@#$%^&*("146+ for i := range 10 {147+ k := ghostty.KeyDigit0 + ghostty.Key(i)148+ m[rune('0'+i)] = physKey{key: k}149+ m[rune(digits[i])] = physKey{key: k, mods: ghostty.ModShift, base: rune('0' + i)}150+ }151+ for _, p := range []struct {152+ plain, shifted rune153+ key ghostty.Key154+ }{155+ {'-', '_', ghostty.KeyMinus}, {'=', '+', ghostty.KeyEqual},156+ {',', '<', ghostty.KeyComma}, {'.', '>', ghostty.KeyPeriod},157+ {'/', '?', ghostty.KeySlash}, {';', ':', ghostty.KeySemicolon},158+ {'\'', '"', ghostty.KeyQuote}, {'`', '~', ghostty.KeyBackquote},159+ } {160+ m[p.plain] = physKey{key: p.key}161+ m[p.shifted] = physKey{key: p.key, mods: ghostty.ModShift, base: p.plain}162+ }163+ return m164+}()@@ -0,0 +1,105 @@
1+package e2e2+3+import (4+ "strings"5+ "testing"6+7+ ghostty "go.mitchellh.com/libghostty"8+)9+10+// With the kitty keyboard protocol, as Ghostty has it, 012 turns on key11+// events; Shift+Enter moves up after an entry and Ctrl+I is italic. A12+// terminal without it sends Shift+Enter as Enter and Ctrl+I as Tab, and13+// 012 does what those do.14+func TestKittyKeyboard(t *testing.T) {15+ t.Run("on", func(t *testing.T) {16+ s := start(t, "")17+ s.eventually("key events on", func() bool {18+ return s.kittyFlags()&ghostty.KittyKeyReportEvents != 019+ })20+ s.keys("<down>", "<down>", "5", "<shift+enter>")21+ s.waitForBar("A2", "")22+ s.keys("<down>", "<ctrl+i>")23+ s.waitFor("Italic on for A3")24+ // Alt+letter opens its menu; Esc then a letter, however quick, is25+ // Esc, then the letter typed.26+ s.keys("<alt+f>")27+ s.waitFor("MENU")28+ s.keys("<esc>", "j")29+ s.waitFor("ENTER")30+ s.waitForBar("A3", "j")31+ })32+ t.Run("off", func(t *testing.T) {33+ s := startWith(t, options{legacyKeys: true})34+ if f := s.kittyFlags(); f != 0 {35+ t.Fatalf("flags %v on a terminal without the protocol", f)36+ }37+ s.keys("<down>", "<down>", "5", "<shift+enter>")38+ s.waitForBar("A4", "")39+ s.keys("<up>", "<ctrl+i>")40+ s.waitForBar("B3", "")41+ s.keys("<alt+f>")42+ s.waitFor("MENU")43+ })44+}45+46+// Holding Space on a selected chart shows it across the grid until it's47+// let go; without key events Space deselects the chart and starts an48+// entry, as it always has.49+func TestHoldSpaceOnChart(t *testing.T) {50+ t.Run("on", func(t *testing.T) {51+ s := start(t, "")52+ spending(s)53+ insertChart(s)54+ s.keys("<enter>")55+ s.waitFor("hold Space to zoom")56+ s.hold("<space>")57+ s.eventually("chart over column A", func() bool {58+ return !strings.Contains(s.line(gridRow1), "Month") && strings.Contains(s.screen(), "let go")59+ })60+ s.release("<space>")61+ s.eventually("chart back at D1", func() bool { return strings.Contains(s.line(gridRow1), "Month") })62+ s.waitFor("hold Space to zoom")63+ if !strings.Contains(s.line(0), "CHART") {64+ t.Errorf("released: %q", s.line(0))65+ }66+ })67+ t.Run("off", func(t *testing.T) {68+ s := startWith(t, options{legacyKeys: true})69+ spending(s)70+ insertChart(s)71+ s.keys("<enter>")72+ s.waitFor("drag the corner to resize")73+ if strings.Contains(s.screen(), "hold Space") {74+ t.Errorf("hint without key events:\n%s", s.screen())75+ }76+ s.keys(" ") // as typed: the terminal sends the space itself77+ s.waitFor("ENTER")78+ })79+}80+81+// Holding Space in the theme picker hides the list, showing the sheet in82+// the highlighted theme, until it's let go; with a search typed, or83+// without key events, Space is typed.84+func TestHoldSpaceInThemePicker(t *testing.T) {85+ t.Run("on", func(t *testing.T) {86+ s := start(t, "")87+ openTheme(s)88+ s.waitFor("hold to peek")89+ s.hold("<space>")90+ s.eventually("list hidden", func() bool { return !strings.Contains(s.screen(), "─ Theme ─") })91+ s.release("<space>")92+ s.waitFor("─ Theme ─")93+ s.keys("tokyo", "<space>")94+ s.waitFor("› tokyo ")95+ })96+ t.Run("off", func(t *testing.T) {97+ s := startWith(t, options{legacyKeys: true})98+ openTheme(s)99+ if strings.Contains(s.screen(), "hold to peek") {100+ t.Errorf("hint without key events:\n%s", s.screen())101+ }102+ s.keys(" x")103+ s.waitFor("› x")104+ })105+}@@ -1,6 +1,6 @@
11 <pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">CHART</span><span style="background:var(--vt-palette-6)"> </span>22 <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> Month3-<span style="font-weight:bold">Column</span> <span style="font-weight:bold">chart</span> <span style="color:var(--vt-palette-8)">of</span> A1:C5 <span style="color:var(--vt-palette-8)">drag</span> <span style="color:var(--vt-palette-8)">to</span> <span style="color:var(--vt-palette-8)">move,</span> <span style="color:var(--vt-palette-8)">drag</span> <span style="color:var(--vt-palette-8)">the</span> <span style="color:var(--vt-palette-8)">corner</span> <span style="color:var(--vt-palette-8)">to</span> <span style="color:var(--vt-palette-8)">resize</span>3+<span style="font-weight:bold">Column</span> <span style="font-weight:bold">chart</span> <span style="color:var(--vt-palette-8)">of</span> A1:C5 <span style="color:var(--vt-palette-8)">drag</span> <span style="color:var(--vt-palette-8)">to</span> <span style="color:var(--vt-palette-8)">move,</span> <span style="color:var(--vt-palette-8)">drag</span> <span style="color:var(--vt-palette-8)">the</span> <span style="color:var(--vt-palette-8)">corner</span> <span style="color:var(--vt-palette-8)">to</span> <span style="color:var(--vt-palette-8)">resize,</span> <span style="color:var(--vt-palette-8)">hold</span> <span style="color:var(--vt-palette-8)">Space</span> <span style="color:var(--vt-palette-8)">to</span> <span style="color:var(--vt-palette-8)">zoom</span>44 <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>55 <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)">Month</span><span style="background:var(--vt-palette-6)"> </span> Rent Food <span style="color:var(--vt-palette-6);font-weight:bold">┌─</span> <span style="font-weight:bold">Rent</span> <span style="font-weight:bold">and</span> <span style="font-weight:bold">Food</span> <span style="color:var(--vt-palette-6);font-weight:bold">──────────────────────────────┐</span>66 <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> Jan $1,450 $600 <span style="color:var(--vt-palette-6);font-weight:bold">│</span> <span style="color:var(--vt-palette-6);font-weight:bold">│</span>@@ -27,4 +27,4 @@
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>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>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>30-Find text and replace it <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Tab</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">complete</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">run</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">cancel</span></pre>\ No newline at end of file30+Find text and replace it <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Tab</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">complete</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Up</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">history</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">run</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">cancel</span></pre>\ No newline at end of file@@ -27,4 +27,4 @@
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>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>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>30-Find text and replace it <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Tab</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">complete</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">cancel</span></pre>\ No newline at end of file30+Find text and replace it <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Tab</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">complete</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">history</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">cancel</span></pre>\ No newline at end of file@@ -0,0 +1,66 @@
1+package e2e2+3+import (4+ "os"5+ "path/filepath"6+ "testing"7+ "time"8+)9+10+// . repeats a change, "a keeps a copy of its own, marks and two quotes11+// jump back, cc clears a row to retype, Up on the : line recalls a line,12+// and :w! writes over a file changed on disk.13+func TestVimRepeatRegistersMarks(t *testing.T) {14+ dir := t.TempDir()15+ s := start(t, dir)16+ vimOn(s)17+ s.keys("i", "a", "<tab>", "i", "1", "<enter>")18+ s.keys("i", "b", "<tab>", "i", "2", "<enter>")19+ s.keys("i", "c", "<tab>", "i", "3", "<enter>")20+ s.waitForBar("A4", "")21+22+ // A mark on B1, a jump away and back, then back to the row jumped from.23+ s.keys("gg", "l", "mq", "G", "`q")24+ s.waitForBar("B1", "1")25+ s.keys("''")26+ s.waitForBar("A3", "c")27+28+ // x and . clear two cells; "ayy keeps row 1 while dd fills the29+ // clipboard, and "ap pastes row 1 back as a new row.30+ s.keys("gg", "l", "x", "j", ".")31+ s.waitForBar("B2", "")32+ s.keys("gg", `"ayy`, "j", "dd", "G", "0", `"ap`)33+ s.waitForBar("A3", "a")34+35+ // cc clears the row and starts typing; . does it again below.36+ s.keys("gg", "cc", "top", "<enter>")37+ s.waitForBar("A2", "c")38+ s.keys(".")39+ s.waitForBar("A3", "a")40+ s.keys("k")41+ s.waitForBar("A2", "top")42+43+ // The : line remembers what ran.44+ s.keys(":B3", "<enter>", "gg", ":", "<up>")45+ s.waitFor(":B3")46+ s.keys("<enter>")47+ s.waitForBar("B3", "")48+49+ s.keys(":w vim", "<enter>")50+ s.eventually("saved", func() bool { return s.title() == "012 - vim.012" })51+ name := filepath.Join(dir, "vim.012")52+ later := time.Now().Add(time.Minute)53+ if err := os.WriteFile(name, []byte("{}"), 0o644); err != nil {54+ t.Fatal(err)55+ }56+ if err := os.Chtimes(name, later, later); err != nil {57+ t.Fatal(err)58+ }59+ s.keys("gg", "0", "x", ":w", "<enter>")60+ s.waitFor("changed on disk")61+ s.keys("<esc>", ":w!", "<enter>")62+ s.eventually("written", func() bool {63+ data, _ := os.ReadFile(name)64+ return string(data) != "{}" && s.title() == "012 - vim.012"65+ })66+}@@ -0,0 +1,219 @@
1+package chart2+3+import (4+ "image"5+ "image/color"6+ "slices"7+ "strconv"8+ "strings"9+)10+11+// Sixel is the other way images reach a terminal, for terminals without12+// kitty graphics (foot, xterm, mlterm, Windows Terminal and others). A13+// sixel image is pixels drawn at the cursor, not text: the UI draws it14+// after the frame over blank cells it keeps for it (see the ui package's15+// sixel.go). The image is sent whole each time, in at most as many16+// colors as the terminal has color registers, run-length encoded.17+18+// Sixel returns the DCS sequence that draws img at the cursor: pixels19+// with any coverage composited over bg, fully transparent ones left20+// unset so the cell background shows, in at most registers colors21+// (at least 2, at most 256).22+func Sixel(img *image.RGBA, bg color.RGBA, registers int) string {23+ registers = min(max(registers, 2), 256)24+ b := img.Bounds()25+ w, h := b.Dx(), b.Dy()26+ if w <= 0 || h <= 0 {27+ return ""28+ }29+ idx, pal := quantize(img, bg, registers)30+ var out strings.Builder31+ // P2 = 1: pixels not drawn keep what's under them. The raster32+ // attributes set square pixels and the image's size.33+ out.WriteString("\x1bP0;1;0q\"1;1;")34+ out.WriteString(strconv.Itoa(w) + ";" + strconv.Itoa(h))35+ for i, c := range pal {36+ out.WriteString("#" + strconv.Itoa(i) + ";2;" + percent(c.R) + ";" + percent(c.G) + ";" + percent(c.B))37+ }38+ bands := sixelBands(idx, w, h, len(pal))39+ out.WriteString(bands)40+ out.WriteString("\x1b\\")41+ return out.String()42+}43+44+// percent is a color channel as sixel's 0 to 100.45+func percent(v uint8) string { return strconv.Itoa((int(v)*100 + 127) / 255) }46+47+// sixelBands encodes the image of register indexes (-1 for unset), six48+// rows at a time: for each color in the band, its row of sixels,49+// run-length encoded, then "$" to go back for the next color, and "-"50+// between bands.51+func sixelBands(idx []int16, w, h, colors int) string {52+ var out strings.Builder53+ b := bands{w: w, rows: make([][]byte, colors), band: make([]int, colors)}54+ for y0 := 0; y0 < h; y0 += 6 {55+ if y0 > 0 {56+ out.WriteByte('-')57+ }58+ b.gather(idx[y0*w:min(y0+6, h)*w], y0)59+ for i, c := range b.used {60+ if i > 0 {61+ out.WriteByte('$')62+ }63+ out.WriteString("#" + strconv.Itoa(int(c)))64+ writeRuns(&out, b.rows[c])65+ }66+ }67+ return out.String()68+}69+70+// bands holds the rows of sixels of one band of six pixel rows.71+type bands struct {72+ w int73+ rows [][]byte // each color's row of sixels, made when first used74+ band []int // the band each color was last used in, plus one75+ used []int16 // the colors of the band, in order76+}77+78+// gather makes the rows of the band at y0 from its pixels' registers.79+func (b *bands) gather(px []int16, y0 int) {80+ b.used = b.used[:0]81+ for i, c := range px {82+ if c < 0 {83+ continue84+ }85+ if b.band[c] != y0+1 {86+ b.band[c] = y0 + 187+ b.used = append(b.used, c)88+ if b.rows[c] == nil {89+ b.rows[c] = make([]byte, b.w)90+ }91+ for x := range b.rows[c] {92+ b.rows[c][x] = '?'93+ }94+ }95+ b.rows[c][i%b.w] += 1 << (i / b.w) // each pixel has one color, so each bit is added once96+ }97+ slices.Sort(b.used)98+}99+100+// writeRuns writes a row of sixels, runs of four or more as "!n", and101+// leaves out the empty sixels at its end.102+func writeRuns(out *strings.Builder, row []byte) {103+ end := len(row)104+ for end > 0 && row[end-1] == '?' {105+ end--106+ }107+ for i := 0; i < end; {108+ j := i + 1109+ for j < end && row[j] == row[i] {110+ j++111+ }112+ if n := j - i; n >= 4 {113+ out.WriteString("!" + strconv.Itoa(n))114+ out.WriteByte(row[i])115+ } else {116+ for range n {117+ out.WriteByte(row[i])118+ }119+ }120+ i = j121+ }122+}123+124+// quantize maps each pixel of img, composited over bg, to a register,125+// -1 for a fully transparent pixel, and returns the registers' colors.126+// Colors are kept exactly while there are at most registers of them;127+// past that, their low bits are dropped until they fit, and each128+// register is the average of the colors it stands for. Registers are129+// numbered in the order their colors first appear, so the same image130+// always encodes the same way.131+func quantize(img *image.RGBA, bg color.RGBA, registers int) ([]int16, []color.RGBA) {132+ b := img.Bounds()133+ w, h := b.Dx(), b.Dy()134+ px := make([]uint32, w*h) // 0 for transparent, else 1<<24 | RGB135+ for y := range h {136+ for x := range w {137+ if c := over(img, b.Min.X+x, b.Min.Y+y, bg); c.A != 0 {138+ px[y*w+x] = 1<<24 | uint32(c.R)<<16 | uint32(c.G)<<8 | uint32(c.B)139+ }140+ }141+ }142+ shift := uint(0)143+ for ; shift < 8; shift++ {144+ if distinct(px, shift, registers) <= registers {145+ break146+ }147+ }148+ reg := map[uint32]int16{}149+ var sums [][4]int // R, G, B sums and count per register150+ idx := make([]int16, w*h)151+ last, lastReg := uint32(0), int16(-1) // runs of one color are common: look each up once152+ for i, p := range px {153+ if p == 0 {154+ idx[i] = -1155+ continue156+ }157+ r := lastReg158+ if p != last {159+ k := bucket(p, shift)160+ var ok bool161+ if r, ok = reg[k]; !ok {162+ r = int16(len(sums))163+ reg[k] = r164+ sums = append(sums, [4]int{})165+ }166+ last, lastReg = p, r167+ }168+ idx[i] = r169+ s := &sums[r]170+ s[0] += int(p >> 16 & 0xff)171+ s[1] += int(p >> 8 & 0xff)172+ s[2] += int(p & 0xff)173+ s[3]++174+ }175+ pal := make([]color.RGBA, len(sums))176+ for i, s := range sums {177+ pal[i] = color.RGBA{uint8(s[0] / s[3]), uint8(s[1] / s[3]), uint8(s[2] / s[3]), 255}178+ }179+ return idx, pal180+}181+182+// bucket is the key of pixel p with shift low bits of each channel183+// dropped.184+func bucket(p uint32, shift uint) uint32 {185+ m := uint32(0xff>>shift<<shift) * 0x010101186+ return p & (1<<24 | m)187+}188+189+// distinct counts the buckets of px at shift, stopping past limit.190+func distinct(px []uint32, shift uint, limit int) int {191+ seen := map[uint32]struct{}{}192+ last := uint32(0)193+ for _, p := range px {194+ if p == 0 || p == last {195+ continue196+ }197+ last = p198+ seen[bucket(p, shift)] = struct{}{}199+ if len(seen) > limit {200+ break201+ }202+ }203+ return len(seen)204+}205+206+// over is the pixel at x, y of the premultiplied img composited over207+// bg, opaque, or transparent where img has no coverage.208+func over(img *image.RGBA, x, y int, bg color.RGBA) color.RGBA {209+ i := img.PixOffset(x, y)210+ p := img.Pix[i : i+4 : i+4]211+ a := int(p[3])212+ if a == 0 {213+ return color.RGBA{}214+ }215+ return color.RGBA{mix(p[0], bg.R, a), mix(p[1], bg.G, a), mix(p[2], bg.B, a), 255}216+}217+218+// mix is the premultiplied channel c of coverage a over under.219+func mix(c, under uint8, a int) uint8 { return uint8(min(int(c)+int(under)*(255-a)/255, 255)) }@@ -0,0 +1,177 @@
1+package chart2+3+import (4+ "image"5+ "image/color"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// The encoder's bytes for a small image: a register per color in the13+// order they appear, runs of four or more as "!n", trailing empty14+// sixels left out, "$" between the colors of a band and "-" between15+// bands, a pixel with half coverage composited over the background and16+// a transparent one left unset.17+func TestSixelGolden(t *testing.T) {18+ img := image.NewRGBA(image.Rect(0, 0, 4, 7))19+ red := color.RGBA{255, 0, 0, 255}20+ for x := range 4 {21+ img.SetRGBA(x, 0, red)22+ }23+ img.SetRGBA(1, 1, color.RGBA{0, 128, 0, 128}) // premultiplied: half green24+ img.SetRGBA(0, 2, red)25+ img.SetRGBA(1, 2, red)26+ img.SetRGBA(2, 2, red)27+ img.SetRGBA(0, 6, color.RGBA{0, 0, 255, 255})28+ got := Sixel(img, color.RGBA{0, 0, 0, 255}, 256)29+ want := "\x1bP0;1;0q\"1;1;4;7" +30+ "#0;2;100;0;0#1;2;0;50;0#2;2;0;0;100" +31+ "#0DDD@$#1?A" + // red: rows 0 and 2 (1+4) three times, then row 0 alone32+ "-#2@" +33+ "\x1b\\"34+ if got != want {35+ t.Errorf("Sixel =\n%q\nwant\n%q", got, want)36+ }37+ img.SetRGBA(3, 2, red)38+ if got := Sixel(img, color.RGBA{0, 0, 0, 255}, 256); !strings.Contains(got, "#0!4D$") {39+ t.Errorf("a run of four isn't run-length encoded: %q", got)40+ }41+}42+43+// Past the terminal's registers, colors are merged until they fit, and44+// every pixel still names a register that exists.45+func TestSixelQuantize(t *testing.T) {46+ img := image.NewRGBA(image.Rect(0, 0, 40, 30))47+ for y := range 30 {48+ for x := range 40 {49+ img.SetRGBA(x, y, color.RGBA{uint8(x * 6), uint8(y * 8), uint8((x + y) * 3), 255})50+ }51+ }52+ for _, regs := range []int{16, 256} {53+ idx, pal := quantize(img, color.RGBA{}, regs)54+ if len(pal) > regs {55+ t.Errorf("%d registers: %d colors", regs, len(pal))56+ }57+ for _, i := range idx {58+ if i < 0 || int(i) >= len(pal) {59+ t.Fatalf("%d registers: pixel names register %d of %d", regs, i, len(pal))60+ }61+ }62+ }63+ if s := Sixel(img, color.RGBA{}, 16); strings.Count(s, ";2;") > 16 {64+ t.Errorf("more than 16 colors defined")65+ }66+}67+68+// A chart's image encodes, and an empty image draws nothing.69+func TestSixelChart(t *testing.T) {70+ img := Image(sheet.ChartColumn, single, 40, 12, Options{CellW: 8, CellH: 16}, testPalette)71+ if img == nil {72+ t.Fatal("no image")73+ }74+ s := Sixel(img, color.RGBA{0, 0, 0, 255}, 256)75+ if !strings.HasPrefix(s, "\x1bP0;1;0q\"1;1;") || !strings.HasSuffix(s, "\x1b\\") {76+ t.Errorf("not a sixel sequence: %.40q", s)77+ }78+ if Sixel(image.NewRGBA(image.Rect(0, 0, 0, 0)), color.RGBA{}, 256) != "" {79+ t.Error("an empty image drew something")80+ }81+}82+83+// A chart's sixel image decodes to the image composited over the84+// background, each channel within sixel's percent steps, and pixels85+// without coverage unset.86+func TestSixelDecodes(t *testing.T) {87+ bg := color.RGBA{250, 250, 245, 255}88+ for _, ct := range []sheet.ChartType{sheet.ChartLine, sheet.ChartPie, sheet.ChartArea} {89+ img := Image(ct, budget, 40, 12, Options{CellW: 7, CellH: 15}, testPalette)90+ got := decodeSixel(t, Sixel(img, bg, 256))91+ b := img.Bounds()92+ for y := range b.Dy() {93+ for x := range b.Dx() {94+ want := over(img, x, y, bg)95+ c, set := got[image.Pt(x, y)]96+ if set != (want.A != 0) || set && (diff(c.R, want.R) > 3 || diff(c.G, want.G) > 3 || diff(c.B, want.B) > 3) {97+ t.Fatalf("%v at %d,%d: %v %v, want %v", ct, x, y, c, set, want)98+ }99+ }100+ }101+ }102+}103+104+func diff(a, b uint8) int { return max(int(a)-int(b), int(b)-int(a)) }105+106+// decodeSixel reads what Sixel writes: color definitions, "#n"107+// selections, sixels and their runs, "$" and "-".108+func decodeSixel(t *testing.T, s string) map[image.Point]color.RGBA {109+ t.Helper()110+ body, ok := strings.CutPrefix(s, "\x1bP0;1;0q")111+ body, ok2 := strings.CutSuffix(body, "\x1b\\")112+ if !ok || !ok2 {113+ t.Fatalf("not a sixel sequence: %.30q", s)114+ }115+ d := sixelDecoder{body: body, pal: map[int]color.RGBA{}, out: map[image.Point]color.RGBA{}}116+ for d.i < len(body) {117+ d.step()118+ }119+ return d.out120+}121+122+type sixelDecoder struct {123+ body string124+ i int125+ x, y, cur int126+ pal map[int]color.RGBA127+ out map[image.Point]color.RGBA128+}129+130+func (d *sixelDecoder) num() int {131+ n := 0132+ for ; d.i < len(d.body) && d.body[d.i] >= '0' && d.body[d.i] <= '9'; d.i++ {133+ n = n*10 + int(d.body[d.i]-'0')134+ }135+ return n136+}137+138+func (d *sixelDecoder) step() {139+ ch := d.body[d.i]140+ d.i++141+ switch ch {142+ case '"':143+ for d.i < len(d.body) && (d.body[d.i] >= '0' && d.body[d.i] <= '9' || d.body[d.i] == ';') {144+ d.i++145+ }146+ case '#':147+ d.cur = d.num()148+ if d.i < len(d.body) && d.body[d.i] == ';' {149+ var c [4]int150+ for k := range c {151+ d.i++152+ c[k] = d.num()153+ }154+ pc := func(v int) uint8 { return uint8((v*255 + 50) / 100) }155+ d.pal[d.cur] = color.RGBA{pc(c[1]), pc(c[2]), pc(c[3]), 255}156+ }157+ case '$':158+ d.x = 0159+ case '-':160+ d.x, d.y = 0, d.y+6161+ default:162+ run := 1163+ if ch == '!' {164+ run = d.num()165+ ch = d.body[d.i]166+ d.i++167+ }168+ for range run {169+ for dy := range 6 {170+ if (ch-'?')&(1<<dy) != 0 {171+ d.out[image.Pt(d.x, d.y+dy)] = d.pal[d.cur]172+ }173+ }174+ d.x++175+ }176+ }177+}@@ -76,3 +76,22 @@ func BenchmarkImage(b *testing.B) {
7676 }7777 }7878 }79+80+// BenchmarkSixel is a chart's image encoded as sixel, in 256 colors, as81+// it is whenever its data, size or colors change.82+func BenchmarkSixel(b *testing.B) {83+ pal := Palette{Series: [Colors]color.RGBA{{205, 49, 49, 255}, {13, 188, 121, 255}, {36, 114, 200, 255}}, Grid: color.RGBA{128, 128, 128, 80}}84+ d := stressData(12)85+ for _, ct := range stressTypes {86+ for _, sz := range stressSizes {87+ img := Image(ct.t, d, sz.w, sz.h, Options{CellW: 10, CellH: 20}, pal)88+ b.Run(fmt.Sprintf("%s/%dx%d", ct.name, sz.w, sz.h), func(b *testing.B) {89+ n := 090+ for b.Loop() {91+ n = len(Sixel(img, color.RGBA{0, 0, 0, 255}, 256))92+ }93+ b.ReportMetric(float64(n), "bytes")94+ })95+ }96+ }97+}@@ -93,8 +93,8 @@ var Options = []Option{
9393 "`light:NAME,dark:NAME` picks one by the terminal's background and follows it when it changes.",9494 Check: checkTheme},9595 {Name: "chart-images", Kind: Bool, Group: GroupAppearance, Default: "true", Env: []string{"O12_CHART_IMAGES"}, Live: true,96- Desc: "Draw charts as images on terminals with kitty graphics (kitty, Ghostty, WezTerm). " +97- "When false, charts are always text."},96+ Desc: "Draw charts as images on terminals with kitty graphics (kitty, Ghostty, WezTerm) or, " +97+ "outside tmux, sixel graphics (foot, xterm, mlterm, Windows Terminal). When false, charts are always text."},9898 {Name: "notifications", Kind: Bool, Group: GroupAppearance, Default: "true", Env: []string{"O12_NOTIFICATIONS"}, Live: true,9999 Desc: "Send a desktop notification (OSC 9) when JEV answers or an import finishes while the " +100100 "terminal window is in the background."},@@ -143,7 +143,7 @@ func decodeBorders(fb *fileBorders) (Borders, error) {
143143 // its slot, without making a Cell.144144 func (st *cellStore) appendSaved(buf []byte, a Addr) ([]byte, bool, error) {145145 b, i := st.find(a)146- if sl := b.vals[i]; sl.kind != slotRich && sl.look == 0 {146+ if sl := b.vals[i]; sl.kind != slotRich && sl.kind&slotDerived == 0 && sl.look == 0 {147147 return st.appendInput(buf, sl), true, nil148148 }149149 c := st.get(a).saved()@@ -24,10 +24,7 @@ func (s *Sheet) DisplayFormat(a Addr) Format {
2424 return f2525 }2626 }27- if c := s.cells.richAt(a); c != nil { // only formulas infer formats28- return c.auto29- }30- return Format{}27+ return s.cells.autoAt(a) // formulas and spilled cells infer formats3128 }3229 3330 // SetFormat gives every cell in r the number format f, including blank@@ -0,0 +1,50 @@
1+//go:build stress2+3+package sheet_test4+5+import (6+ "runtime"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/stress"11+)12+13+// BenchmarkMemoryDerived is the heap a derived cell holds: the cells a14+// formula spills (SEQUENCE over 8192 x 64, the array the anchor keeps15+// to compare with the next one reported apart) and a pivot's results16+// (a row per id of a 8191-row table, with a SUM and a COUNTA).17+func BenchmarkMemoryDerived(b *testing.B) {18+ b.Run("spilled-8192x64", func(b *testing.B) {19+ for b.Loop() {20+ before := heap()21+ s := sheet.New()22+ if err := s.Set(sheet.Addr{}, "=SEQUENCE(8192, 64)"); err != nil {23+ b.Fatal(err)24+ }25+ after := heap()26+ n := float64(s.Len())27+ const valueBytes = 32 // a Value in the array the anchor keeps28+ b.ReportMetric(float64(after-before)/n, "B/cell")29+ b.ReportMetric(float64(after-before)/n-valueBytes, "B/cell-stored")30+ runtime.KeepAlive(s)31+ }32+ })33+ b.Run("pivot-8191", func(b *testing.B) {34+ for b.Loop() {35+ src := stress.Table(stress.Rows-1, 8)36+ r := sheet.NewRect(sheet.Addr{}, sheet.Addr{Col: 7, Row: stress.Rows - 1})37+ p := sheet.NewPivot(src, r)38+ p.Rows = []sheet.PivotGroup{{Col: 0}}39+ p.Values = []sheet.PivotValue{{Col: 2, Summarize: sheet.SumBy}, {Col: 3, Summarize: sheet.CountABy}}40+ before := heap()41+ pv, err := src.Book().CreatePivot(src, r, "", p)42+ if err != nil {43+ b.Fatal(err)44+ }45+ after := heap()46+ b.ReportMetric(float64(after-before)/float64(pv.Len()), "B/cell")47+ runtime.KeepAlive(pv)48+ }49+ })50+}@@ -0,0 +1,131 @@
1+package sheet2+3+import (4+ "bytes"5+ "strings"6+ "testing"7+)8+9+// A derived cell of every value, format, inferred format and mark comes10+// back from the store as it went in, kept in a slot unless it has a11+// note, and the store counts the pivot's results it holds.12+func TestSlotDerived(t *testing.T) {13+ st := newCellStore()14+ a := Addr{Col: 2, Row: 700}15+ values := []Value{num(1.5), num(-0.25), boolean(true), boolean(false), {Kind: Text, Str: "north"},16+ {Kind: Text, Str: "=not a formula"}, {Kind: Text, Str: "12"}, ErrRef, ErrDiv0, {Kind: Text}}17+ for _, v := range values {18+ for _, pivot := range []bool{true, false} {19+ for _, note := range []string{"", "a note"} {20+ c := &Cell{Input: derivedInput(v), Value: v, Format: Preset(FmtCurrency), Note: note, derived: pivot, spilled: !pivot}21+ if !pivot {22+ c.auto = Preset(FmtPercent)23+ }24+ c.Style.Bold = pivot25+ checkDerived(t, &st, a, c)26+ }27+ }28+ }29+ st.delete(a)30+ if st.len() != 0 || st.pivots != 0 || len(st.strs.index) != 0 || len(st.rich) != len(st.richFree) {31+ t.Errorf("left: %d stored, %d pivot cells, %d strings, %d rich", st.len(), st.pivots, len(st.strs.index), len(st.rich)-len(st.richFree))32+ }33+}34+35+// checkDerived stores the derived cell c at a and reads it back.36+func checkDerived(t *testing.T, st *cellStore, a Addr, c *Cell) {37+ t.Helper()38+ want := c.clone()39+ st.set(a, c)40+ checkSame(t, st.get(a), want)41+ if rich := st.richAt(a) != nil; rich != (want.Note != "") {42+ t.Errorf("%+v: rich %v", want, rich)43+ }44+ if got := st.filledAt(a); got != !want.Blank() {45+ t.Errorf("%+v: filled %v", want, got)46+ }47+ if !st.holds(a, st.get(a)) {48+ t.Errorf("%+v: doesn't hold its own view", want)49+ }50+ if n := len(collect(st.pivotKeys())); n != st.pivots || (n == 1) != want.derived {51+ t.Errorf("%+v: %d pivot cells found, %d counted", want, n, st.pivots)52+ }53+}54+55+func collect(seq func(func(Addr) bool)) []Addr {56+ var out []Addr57+ seq(func(a Addr) bool { out = append(out, a); return true })58+ return out59+}60+61+// Spilled cells are slots, and read as they did as Cells: values of each62+// kind, the anchor's inferred format, values when copied, skipped by63+// Replace and left out of the file.64+func TestSpillCompact(t *testing.T) {65+ s := sheetOf(t, map[string]string{66+ "A1": "$5", "A2": "$6", "A3": "x",67+ "B1": "=ARRAYFORMULA(A1:A2*2)",68+ "C1": `=ARRAYFORMULA(IF(SEQUENCE(3)=2, "none", SEQUENCE(3)))`,69+ "D1": "=SEQUENCE(2)>1",70+ "E1": "=1/(SEQUENCE(2)-2)",71+ })72+ wantShown(t, s, map[string]string{"B2": "12", "C2": "none", "C3": "3", "D2": "TRUE", "E2": "#DIV/0!"})73+ for _, a := range []string{"B2", "C2", "C3", "D2", "E2"} {74+ if s.cells.richAt(at(a)) != nil {75+ t.Errorf("%s is a whole Cell", a)76+ }77+ if c := s.Cell(at(a)); !c.Spilled() || c.Input != derivedInput(c.Value) {78+ t.Errorf("%s = %+v", a, c)79+ }80+ }81+ if f := s.DisplayFormat(at("B2")); f.Kind != FmtCurrency {82+ t.Errorf("B2's format %v, want the anchor's currency", f)83+ }84+ found, err := s.Find("none", FindOptions{})85+ if err != nil || len(found) != 1 || found[0] != at("C2") {86+ t.Errorf("Find found %v %v, want the spilled C2", found, err)87+ }88+ if changed, err := s.Replace(at("C2"), "none", "x", FindOptions{}); changed || err != nil {89+ t.Errorf("Replace changed a spilled cell: %v %v", changed, err)90+ }91+ clip := s.Copy(rect("E1:E2"))92+ if _, err := s.Paste(clip, rect("G1:G1"), false); err != nil {93+ t.Fatal(err)94+ }95+ if c := s.Cell(at("G2")); c.Spilled() || c.Value.String() != "#DIV/0!" {96+ t.Errorf("pasted spilled error = %+v", c)97+ }98+ var buf bytes.Buffer99+ if err := s.Book().Write(&buf); err != nil {100+ t.Fatal(err)101+ }102+ if strings.Contains(buf.String(), `"B2"`) || strings.Contains(buf.String(), `"D2"`) {103+ t.Errorf("spilled cells saved:\n%s", buf.String())104+ }105+}106+107+// A pivot's results are slots, found again when they shrink.108+func TestPivotCompact(t *testing.T) {109+ src := sheetOf(t, map[string]string{"A1": "Region", "B1": "Sales", "A2": "north", "B2": "10", "A3": "south", "B3": "5", "A4": "east", "B4": "7"})110+ r := rect("A1:B4")111+ p := NewPivot(src, r)112+ p.Rows = []PivotGroup{{Col: 0}}113+ p.Values = []PivotValue{{Col: 1, Summarize: SumBy}}114+ pv, err := src.Book().CreatePivot(src, r, "", p)115+ if err != nil {116+ t.Fatal(err)117+ }118+ n := pv.cells.pivots119+ if n == 0 || len(pv.cells.rich) != len(pv.cells.richFree) {120+ t.Fatalf("%d pivot cells, %d rich", n, len(pv.cells.rich)-len(pv.cells.richFree))121+ }122+ if err := src.Set(at("A4"), "north"); err != nil {123+ t.Fatal(err)124+ }125+ if pv.cells.pivots >= n {126+ t.Errorf("%d pivot cells after a group went, had %d", pv.cells.pivots, n)127+ }128+ if got := len(collect(pv.cells.pivotKeys())); got != pv.cells.pivots {129+ t.Errorf("%d pivot cells found, %d counted", got, pv.cells.pivots)130+ }131+}@@ -385,7 +385,7 @@ func (s *Sheet) loadNum(a Addr, input []byte, f Format, st Style) bool {
385385 if err != nil {386386 return false387387 }388- lk, ok := s.cells.lookID(f, st)388+ lk, ok := s.cells.lookID(look{f: f, st: st})389389 if !ok {390390 return false391391 }@@ -6,7 +6,7 @@ import "slices"
66 // the form the sheet keeps them (store.go, slot.go): columns of blocks77 // of 16-byte slots, the text in a table of strings kept once each, the88 // formatting in a table of looks, and whole Cells only for rich cells9-// (formulas, notes, what pivots and spills write). A step that clears9+// (formulas, notes). A step that clears1010 // or rewrites a whole sheet so holds about what the sheet does, 20 B1111 // per number, rather than a Cell per cell. A cell that held nothing1212 // before the step is a bit in blank.@@ -178,7 +178,7 @@ func (img *image) look(src *cellStore, id uint16) (uint16, bool) {
178178 return img.looks[id] - 1, true179179 }180180 l := src.looks[id]181- lk, ok := img.store().lookID(l.f, l.st)181+ lk, ok := img.store().lookID(l)182182 if !ok {183183 return 0, false184184 }@@ -284,7 +284,7 @@ func (w *Workbook) refreshPivot(s *Sheet) []loc {
284284 s.pivot.blocked = extent(want)285285 s.pivot.err = err.Error()286286 want = []pivotCell{{v: ErrRef, bold: true}}287- if c := s.cells.get(Addr{}); c != nil && !c.derived && !c.Blank() {287+ if s.typedAt(Addr{}) {288288 want = nil // never write over what the user typed289289 }290290 }@@ -353,7 +353,7 @@ func (w *Workbook) computePivot(s *Sheet) ([]pivotCell, *pivotCalc, error) {
353353 return nil, c, fmt.Errorf("The pivot table doesn't fit on a sheet")354354 }355355 for _, pc := range l.cells {356- if old := s.cells.get(pc.a); old != nil && !old.derived && !old.Blank() {356+ if s.typedAt(pc.a) {357357 return l.cells, c, fmt.Errorf("The pivot table's results would overwrite data in %s", pc.a)358358 }359359 }@@ -387,16 +387,19 @@ func (s *Sheet) writeDerived(want []pivotCell) []loc {
387387 next[pc.a] = c388388 }389389 var changed []loc390- for a, c := range s.cells.richCells() {391- if c.derived && next[a] == nil {392- s.setDerived(a, nil)393- changed = append(changed, loc{s, a})390+ var gone []Addr391+ for a := range s.cells.pivotKeys() {392+ if next[a] == nil {393+ gone = append(gone, a)394394 }395395 }396+ for _, a := range gone {397+ s.setDerived(a, nil)398+ changed = append(changed, loc{s, a})399+ }396400 for a, c := range next {397- if old := s.cells.get(a); old != nil && old.derived && old.Input == c.Input && old.Value == c.Value &&398- old.Format == c.Format && old.Style == c.Style {399- continue401+ if v, l, kind := s.cells.derivedOf(a); kind == slotPivot && v == c.Value && l.f == c.Format && l.st == c.Style {402+ continue // its input is its value's400403 }401404 s.setDerived(a, c)402405 changed = append(changed, loc{s, a})@@ -404,6 +407,15 @@ func (s *Sheet) writeDerived(want []pivotCell) []loc {
404407 return changed405408 }406409 410+// typedAt reports whether the cell at a has contents that aren't the411+// pivot's results: what the pivot mustn't write over.412+func (s *Sheet) typedAt(a Addr) bool {413+ if !s.cells.filledAt(a) {414+ return false415+ }416+ return s.cells.derivedAt(a) != slotPivot417+}418+407419 // setDerived stores or removes a derived cell without recording it for408420 // undo. Derived cells have no formula, so there are no references to409421 // index.@@ -13,16 +13,21 @@ import (
1313 // number, a boolean or text, with at most a format and a style; their1414 // slot holds the value itself, the text in the store's string table, the1515 // format and style in its table of looks, and the input only when it16-// isn't the value's own text. Everything else (formulas, notes, what17-// pivots and spills write, text typed with a leading ') is rich: the18-// slot points at a whole Cell in the store's side table.16+// isn't the value's own text. What pivots and spills write (derived17+// cells) is a slot too, marked by its kind: the value alone, its entry18+// being the value's text (derivedInput), and a spill's inferred format19+// in its look. Everything else (formulas, notes, text typed with a20+// leading ') is rich: the slot points at a whole Cell in the store's21+// side table.1922 type slot struct {2023 num float64 // slotNum's value; slotBool's, 1 or 02124 // ref is slotText's text in strs; slotNum's or slotBool's input in2225 // strs when it isn't the value's text (0 when it is); slotRich's cell2326 // in rich.2427 ref uint3225- look uint16 // the format and style in looks; 0 for neither28+ look uint16 // the format, style and inferred format in looks; 0 for none29+ // kind is one of the kinds below, with slotPivot or slotSpill added30+ // for a derived cell.2631 kind uint82732 // dec is how a slotNum without ref prints its input: the value with2833 // dec-1 decimals, or its shortest text when 0.@@ -36,17 +41,30 @@ const (
3641 slotBool3742 slotText3843 slotRich44+ slotErr // a derived cell's error value, its code in strs3945 )4046 47+// Marks of a derived cell's slot, added to its kind: a pivot's result48+// (Cell.derived) or part of a spilled array (Cell.spilled).49+const (50+ slotPivot uint8 = 1 << 651+ slotSpill uint8 = 1 << 752+ slotDerived = slotPivot | slotSpill53+)54+55+// base is sl's kind without the marks of a derived cell.56+func (sl slot) base() uint8 { return sl.kind &^ slotDerived }57+4158 // maxDec bounds the decimals a number's input may have to be kept as a4259 // count rather than as text.4360 const maxDec = 404461 45-// look is a cell's format and style, kept once per store however many46-// cells share it.62+// look is a cell's format and style, and a spilled cell's inferred63+// format, kept once per store however many cells share it.4764 type look struct {48- f Format49- st Style65+ f Format66+ st Style67+ auto Format5068 }5169 5270 // strTable holds the text of plain cells, each distinct string once,@@ -97,8 +115,7 @@ func (t *strTable) release(i uint32) {
97115 98116 // lookID is the entry of l in the table of looks, added if new, and99117 // false when the table is full.100-func (st *cellStore) lookID(f Format, sty Style) (uint16, bool) {101- l := look{f, sty}118+func (st *cellStore) lookID(l look) (uint16, bool) {102119 if l == (look{}) {103120 return 0, true104121 }@@ -118,21 +135,29 @@ func (st *cellStore) lookID(f Format, sty Style) (uint16, bool) {
118135 return i, true119136 }120137 121-// lookOf is sl's format and style.122-func (st *cellStore) lookOf(sl slot) look {138+// lookOf is sl's look, which the caller mustn't change.139+func (st *cellStore) lookOf(sl slot) *look {123140 if sl.look == 0 {124- return look{}141+ return &noLook125142 }126- return st.looks[sl.look]143+ return &st.looks[sl.look]127144 }128145 146+// noLook is the look of a slot without one, never written.147+var noLook look148+129149 // plainSlot is c as a plain slot, or false if c is rich. It adds the130150 // strings it uses to the table.131151 func (st *cellStore) plainSlot(c *Cell) (slot, bool) {132- if c.Note != "" || c.derived || c.spilled || !c.auto.IsZero() || strings.HasPrefix(c.Input, "=") {152+ switch {153+ case c.Note != "":154+ return slot{}, false155+ case c.derived || c.spilled:156+ return st.derivedSlot(c)157+ case !c.auto.IsZero() || strings.HasPrefix(c.Input, "="):133158 return slot{}, false134159 }135- lk, ok := st.lookID(c.Format, c.Style)160+ lk, ok := st.lookID(look{f: c.Format, st: c.Style})136161 if !ok {137162 return slot{}, false138163 }@@ -171,6 +196,44 @@ func (st *cellStore) plainSlot(c *Cell) (slot, bool) {
171196 return sl, true172197 }173198 199+// derivedSlot is plainSlot for a derived cell without a note: its value,200+// marked with what derived it, its entry being the value's text.201+func (st *cellStore) derivedSlot(c *Cell) (slot, bool) {202+ if c.expr != nil {203+ return slot{}, false204+ }205+ lk, ok := st.lookID(look{c.Format, c.Style, c.auto})206+ if !ok {207+ return slot{}, false208+ }209+ return st.derivedValue(c.Value, lk, c.derived), true210+}211+212+// derivedValue is the slot of a derived cell showing v with look lk: a213+// pivot's result if pivot, else a spilled cell. It adds the string v214+// uses to the table.215+func (st *cellStore) derivedValue(v Value, lk uint16, pivot bool) slot {216+ sl := slot{look: lk, kind: slotSpill}217+ if pivot {218+ sl.kind = slotPivot219+ }220+ switch v.Kind {221+ case Number:222+ sl.kind |= slotNum223+ sl.num = v.Num224+ case Bool:225+ sl.kind |= slotBool226+ sl.num = v.Num227+ case Text:228+ sl.kind |= slotText229+ sl.ref = st.strs.add(v.Str)230+ case Error:231+ sl.kind |= slotErr232+ sl.ref = st.strs.add(v.Str)233+ }234+ return sl235+}236+174237 // numDec is how input prints v, as slot.dec, or false if it isn't v175238 // printed plainly.176239 func numDec(v float64, input string) (uint8, bool) {@@ -253,14 +316,16 @@ func boolText(b bool) string {
253316 return "FALSE"254317 }255318 256-// input is the entry of a plain slot.319+// input is the entry of a plain slot. A derived slot's is its value's320+// text, as derivedInput writes it: a number's shortest text, TRUE or321+// FALSE, the text or the error code.257322 func (st *cellStore) input(sl slot) string {258323 switch {259- case sl.kind == slotBlank:324+ case sl.base() == slotBlank:260325 return ""261326 case sl.ref != 0:262327 return st.strs.strs[sl.ref]263- case sl.kind == slotBool:328+ case sl.base() == slotBool:264329 return boolText(sl.num != 0)265330 }266331 return strconv.FormatFloat(sl.num, 'f', int(sl.dec)-1, 64)@@ -268,13 +333,15 @@ func (st *cellStore) input(sl slot) string {
268333 269334 // slotValue is the value of a plain slot.270335 func (st *cellStore) slotValue(sl slot) Value {271- switch sl.kind {336+ switch sl.base() {272337 case slotNum:273338 return Value{Kind: Number, Num: sl.num}274339 case slotBool:275340 return Value{Kind: Bool, Num: sl.num}276341 case slotText:277342 return Value{Kind: Text, Str: st.strs.strs[sl.ref]}343+ case slotErr:344+ return Value{Kind: Error, Str: st.strs.strs[sl.ref]}278345 }279346 return Value{}280347 }@@ -284,6 +351,10 @@ func (st *cellStore) slotValue(sl slot) Value {
284351 func (st *cellStore) view(sl slot) *Cell {285352 lk := st.lookOf(sl)286353 c := &Cell{Input: st.input(sl), Value: st.slotValue(sl), Format: lk.f, Style: lk.st}354+ if sl.kind&slotDerived != 0 { // a derived cell has no expression355+ c.auto, c.derived, c.spilled = lk.auto, sl.kind&slotPivot != 0, sl.kind&slotSpill != 0356+ return c357+ }287358 switch sl.kind {288359 case slotNum:289360 c.expr = formula.Num{V: sl.num}@@ -295,11 +366,11 @@ func (st *cellStore) view(sl slot) *Cell {
295366 296367 // releaseSlot counts one fewer use of what sl refers to.297368 func (st *cellStore) releaseSlot(sl slot) {298- switch sl.kind {369+ switch sl.base() {299370 case slotRich:300371 st.rich[sl.ref] = richCell{}301372 st.richFree = append(st.richFree, sl.ref)302- case slotNum, slotBool, slotText:373+ case slotNum, slotBool, slotText, slotErr:303374 st.strs.release(sl.ref)304375 }305376 }@@ -176,8 +176,11 @@ func (s *Sheet) spillArea(a Addr, arr *functions.Array) (Rect, string) {
176176 return area, "Array result was not expanded because it would overwrite merged cells in " + ms[0].String()177177 }178178 old := s.spills[a]179- for at, c := range s.cells.anyInRange(area) {180- if at == a || c.Blank() || c.spilled && old != nil && old.why == "" && old.area.Contains(at) {179+ for at := range s.cells.anyKeysIn(area) {180+ if at == a || !s.cells.filledAt(at) {181+ continue182+ }183+ if s.cells.derivedAt(at) == slotSpill && old != nil && old.why == "" && old.area.Contains(at) {181184 continue182185 }183186 return area, "Array result was not expanded because it would overwrite data in " + at.String()@@ -188,20 +191,25 @@ func (s *Sheet) spillArea(a Addr, arr *functions.Array) (Rect, string) {
188191 // writeSpilled makes the cell at a show v as a spilled cell, keeping its189192 // formatting and note, and reports whether it changed.190193 func (s *Sheet) writeSpilled(a Addr, v Value, auto Format) bool {191- old := s.cells.get(a)194+ was, l, kind := s.cells.derivedOf(a)195+ spilled := kind == slotSpill192196 if v.Kind == Empty {193- if old == nil || !old.spilled {197+ if !spilled {194198 return false195199 }196- s.setDerived(a, old.leftover())200+ s.setDerived(a, s.cells.get(a).leftover())197201 return true198202 }199- if old != nil && old.spilled && old.Value == v && old.auto == auto {203+ if spilled && was == v && l.auto == auto {200204 return false // its input is its value's201205 }202- c := &Cell{Input: derivedInput(v), Value: v, auto: auto, spilled: true}203- if old != nil {204- c.Format, c.Style, c.Note = old.Format, old.Style, old.Note206+ if s.cells.setSpilled(a, v, auto) { // formatting kept, no formula to unlink207+ s.version++208+ return true209+ }210+ c := &Cell{Input: derivedInput(v), Value: v, Format: l.f, Style: l.st, auto: auto, spilled: true}211+ if old := s.cells.richAt(a); old != nil {212+ c.Note = old.Note205213 }206214 s.setDerived(a, c)207215 return true@@ -212,8 +220,8 @@ func (s *Sheet) writeSpilled(a Addr, v Value, auto Format) bool {
212220 func (s *Sheet) clearSpilled(area, keep Rect) []loc {213221 var changed []loc214222 var gone []Addr215- for at, c := range s.cells.anyInRange(area) {216- if c.spilled && !keep.Contains(at) {223+ for at := range s.cells.anyKeysIn(area) {224+ if s.cells.derivedAt(at) == slotSpill && !keep.Contains(at) {217225 gone = append(gone, at)218226 }219227 }@@ -18,9 +18,10 @@ import (
1818 // occupancy index (occupancy.go) has a bitmap per block of the rows1919 // holding a cell, and each block keeps their slots (slot.go) in row2020 // order, so a cell is found by indexing a column, a block and a popcount,21-// without hashing. A plain number costs its 16-byte slot. Rich cells22-// (formulas, notes, pivots' and spills' results) are whole Cells in a23-// side table the slot points at.21+// without hashing. A plain number costs its 16-byte slot, and so does a22+// pivot's or a spill's result (a derived cell, marked in its slot). Rich23+// cells (formulas, notes) are whole Cells in a side table the slot24+// points at.2425 //2526 // get returns a rich cell's own Cell, which recalculation updates in2627 // place, as ever, and a plain cell as a Cell made for the caller (a@@ -47,6 +48,12 @@ type cellStore struct {
4748 lookIdx map[look]uint16 // looks' entries4849 rich []richCell // rich cells, by slot.ref4950 richFree []uint32 // entries of rich not in use51+52+ // pivots counts the pivot's results stored (Cell.derived), and53+ // pivotArea holds them all while there are any, so they are found54+ // without visiting every cell.55+ pivots int56+ pivotArea Rect5057 }5158 5259 func newCellStore() cellStore { return cellStore{} }@@ -111,8 +118,8 @@ func (st *cellStore) has(a Addr) bool { return st.stored.has(a) }
111118 // filledAt reports whether the cell at a has contents: !get(a).Blank().112119 func (st *cellStore) filledAt(a Addr) bool { return st.filled.has(a) }113120 114-// richAt returns the cell at a if it is rich (a formula, a note, a115-// pivot's or a spill's result), or nil.121+// richAt returns the cell at a if it is rich (a formula or a note, or a122+// cell a slot can't hold), or nil.116123 func (st *cellStore) richAt(a Addr) *Cell {117124 b, i := st.find(a)118125 if b == nil || b.vals[i].kind != slotRich {@@ -148,9 +155,15 @@ func (st *cellStore) holds(a Addr, c *Cell) bool {
148155 if sl.kind == slotRich {149156 return st.rich[sl.ref].c == c150157 }151- if l := st.lookOf(sl); c.Note != "" || c.derived || c.spilled || !c.auto.IsZero() || c.Format != l.f || c.Style != l.st {158+ if l := st.lookOf(sl); c.Note != "" || c.auto != l.auto || c.Format != l.f || c.Style != l.st {159+ return false160+ }161+ if c.derived != (sl.kind&slotPivot != 0) || c.spilled != (sl.kind&slotSpill != 0) {152162 return false153163 }164+ if sl.kind&slotDerived != 0 {165+ return c.expr == nil && c.Value == st.slotValue(sl)166+ }154167 switch sl.kind {155168 case slotText:156169 return c.expr == nil && c.Input == st.strs.strs[sl.ref]@@ -201,6 +214,7 @@ func (st *cellStore) set(a Addr, c *Cell) {
201214 b.vals = slices.Insert(b.vals, i, slot{kind: slotBlank})202215 }203216 old := b.vals[i]217+ st.countPivot(a, st.isPivot(old), c.derived)204218 sl, ok := st.plainSlot(c) // before releasing old, which may share its strings205219 switch {206220 case ok:@@ -226,6 +240,7 @@ func (st *cellStore) setSlot(a Addr, sl slot) {
226240 i, _ = b.index(a.Row & (blockRows - 1))227241 b.vals = slices.Insert(b.vals, i, slot{kind: slotBlank})228242 }243+ st.countPivot(a, st.isPivot(b.vals[i]), false)229244 st.releaseSlot(b.vals[i])230245 b.vals[i] = sl231246 st.filledAs(a, st.filled.has(a), sl.kind != slotBlank)@@ -251,6 +266,7 @@ func (st *cellStore) delete(a Addr) {
251266 }252267 st.touch(a)253268 old := b.vals[i]269+ st.countPivot(a, st.isPivot(old), false)254270 if st.filled.has(a) {255271 st.filled.unmark(a)256272 }@@ -0,0 +1,143 @@
1+package sheet2+3+import (4+ "iter"5+ "slices"6+)7+8+// The store's side of derived cells (slot.go): finding the pivot's9+// results, reading what a derived cell shows without making a Cell, and10+// writing a spilled value straight into its slot.11+12+// isPivot reports whether sl holds a pivot's result.13+func (st *cellStore) isPivot(sl slot) bool {14+ if sl.kind == slotRich {15+ return st.rich[sl.ref].c.derived16+ }17+ return sl.kind&slotPivot != 018+}19+20+// countPivot counts the cell at a, which was a pivot's result or not and21+// becomes one or not.22+func (st *cellStore) countPivot(a Addr, was, is bool) {23+ switch {24+ case is && !was:25+ if st.pivots++; st.pivots == 1 {26+ st.pivotArea = Rect{From: a, To: a}27+ } else {28+ st.pivotArea = union(st.pivotArea, Rect{From: a, To: a})29+ }30+ case was && !is:31+ st.pivots--32+ }33+}34+35+// pivotKeys yields the addresses of the pivot's results stored.36+func (st *cellStore) pivotKeys() iter.Seq[Addr] {37+ return func(yield func(Addr) bool) {38+ if st.pivots == 0 {39+ return40+ }41+ for a := range st.anyKeysIn(st.pivotArea) {42+ if b, i := st.find(a); b != nil && st.isPivot(b.vals[i]) && !yield(a) {43+ return44+ }45+ }46+ }47+}48+49+// derivedOf returns what the cell at a holds as a derived cell would:50+// its value, format, style and inferred format, and slotPivot or51+// slotSpill for a pivot's result or a spilled cell, 0 for any other52+// cell (none included). It makes no Cell, so the derived cells being53+// rewritten are compared cheaply; the look mustn't be changed.54+func (st *cellStore) derivedOf(a Addr) (v Value, l *look, kind uint8) {55+ b, i := st.find(a)56+ if b == nil {57+ return Value{}, &noLook, 058+ }59+ sl := b.vals[i]60+ if sl.kind != slotRich {61+ return st.slotValue(sl), st.lookOf(sl), sl.kind & slotDerived62+ }63+ c := st.rich[sl.ref].c64+ return c.Value, &look{c.Format, c.Style, c.auto}, richDerived(c)65+}66+67+// richDerived is derivedOf's kind for a rich cell.68+func richDerived(c *Cell) uint8 {69+ switch {70+ case c.derived:71+ return slotPivot72+ case c.spilled:73+ return slotSpill74+ }75+ return 076+}77+78+// derivedAt is derivedOf's kind alone.79+func (st *cellStore) derivedAt(a Addr) uint8 {80+ b, i := st.find(a)81+ switch {82+ case b == nil:83+ return 084+ case b.vals[i].kind == slotRich:85+ return richDerived(st.rich[b.vals[i].ref].c)86+ }87+ return b.vals[i].kind & slotDerived88+}89+90+// autoAt is the format the cell at a infers: a formula's or a spilled91+// cell's.92+func (st *cellStore) autoAt(a Addr) Format {93+ b, i := st.find(a)94+ if b == nil {95+ return Format{}96+ }97+ if sl := b.vals[i]; sl.kind == slotRich {98+ return st.rich[sl.ref].c.auto99+ } else {100+ return st.lookOf(sl).auto101+ }102+}103+104+// setSpilled makes the cell at a, blank, formatting only or spilled, a105+// spilled cell showing v with the inferred format auto, keeping its106+// formatting, straight in its slot. It reports false, changing nothing,107+// for a cell a slot can't hold (one with a note), which the caller108+// stores as a Cell.109+func (st *cellStore) setSpilled(a Addr, v Value, auto Format) bool {110+ b, i := st.find(a)111+ var old slot112+ if b != nil {113+ old = b.vals[i]114+ }115+ if old.kind == slotRich {116+ return false117+ }118+ lk := old.look119+ if l := st.lookOf(old); l.auto != auto {120+ var ok bool121+ if lk, ok = st.lookID(look{l.f, l.st, auto}); !ok {122+ return false123+ }124+ }125+ if b == nil {126+ b = st.stored.mark(a)127+ i, _ = b.index(a.Row & (blockRows - 1))128+ b.vals = slices.Insert(b.vals, i, slot{kind: slotBlank})129+ }130+ st.countPivot(a, st.isPivot(old), false)131+ sl := st.derivedValue(v, lk, false) // before releasing old, which may share its string132+ st.releaseSlot(old)133+ b.vals[i] = sl134+ st.filledAs(a, st.filled.has(a), !derivedBlank(v))135+ st.touch(a)136+ return true137+}138+139+// derivedBlank reports whether a derived cell showing v is blank: its140+// entry, the value's text, is empty.141+func derivedBlank(v Value) bool {142+ return v.Kind == Empty || v.Kind == Text && v.Str == ""143+}@@ -39,6 +39,10 @@ type chartHost interface {
3939 chartAt(x, y int) int4040 chartCellAt(x, y int) sheet.Addr4141 setShape(shape string) tea.Cmd42+ // holdsKeys reports whether keys can be held, and zoomed is chart c43+ // spread over the grid, as Space held shows it (keyboard.go).44+ holdsKeys() bool45+ zoomed(c sheet.Chart) sheet.Chart4246 showChartMenu(x, y int)4347 // say puts msg on the context line.4448 say(msg string)@@ -200,7 +200,9 @@ func (m *Model) chartBoxes() []overlay.Box {
200200 var boxes []overlay.Box201201 sel := m.selectedChart()202202 top, bottom := gridTop, gridTop+m.visibleRows()203- for i, c := range m.displayCharts() {203+ charts := m.displayCharts()204+ for _, i := range m.chartOrder(len(charts)) {205+ c := charts[i]204206 x, y := m.chartScreen(c)205207 if x >= m.width || y >= bottom || x+c.W <= m.hdrW() || y+c.H <= top {206208 continue@@ -228,6 +230,9 @@ func (m *Model) drawChart(i int, c sheet.Chart, selected bool) []string {
228230 w, h := chartInner(c)229231 o := m.term.chartOptions()230232 o.Chart = c.ChartOptions233+ if m.term.sixelOn() {234+ o.Image = m.term.six.placed(i) // see sixel.go235+ }231236 if i >= maxImages {232237 o.Image = false233238 }@@ -246,7 +251,7 @@ func (m *Model) drawChart(i int, c sheet.Chart, selected bool) []string {
246251 lines = append(lines, border.Render("┌─")+m.th.Title.Render(title)+247252 border.Render(strings.Repeat("─", max(c.W-3-ansi.StringWidth(title), 0))+"┐"))248253 for y := range h {249- lines = append(lines, border.Render("│")+" "+chartRow(&m.th, g, y, firstImageID+i, o.Image)+" "+border.Render("│"))254+ lines = append(lines, border.Render("│")+" "+chartRow(&m.th, g, y, firstImageID+i, o.Image, m.term.kitty)+" "+border.Render("│"))250255 }251256 corner := "┘"252257 if selected {@@ -262,8 +267,9 @@ func (m *Model) drawChart(i int, c sheet.Chart, selected bool) []string {
262267 }263268 264269 // chartRow renders row y of a drawn chart with the theme; with images,265-// the plot area is the image's placeholders.266-func chartRow(th *theme.Theme, g *chart.Grid, y, imageID int, image bool) string {270+// the plot area is the image's kitty placeholders, or blank cells for a271+// sixel image to cover.272+func chartRow(th *theme.Theme, g *chart.Grid, y, imageID int, image, kitty bool) string {267273 var b, run strings.Builder268274 var style lipgloss.Style269275 styled := false@@ -283,8 +289,10 @@ func chartRow(th *theme.Theme, g *chart.Grid, y, imageID int, image bool) string
283289 c := g.At(x, y)284290 key, st, isStyled := 0, lipgloss.Style{}, true285291 text := c.Text286- switch {287- case image && y >= g.Plot.Min.Y && y < g.Plot.Max.Y && x >= g.Plot.Min.X && x < g.Plot.Max.X:292+ switch inPlot := y >= g.Plot.Min.Y && y < g.Plot.Max.Y && x >= g.Plot.Min.X && x < g.Plot.Max.X; {293+ case image && inPlot && !kitty:294+ key, isStyled, text = 2, false, " "295+ case image && inPlot:288296 key, st = 1, th.ImageID(imageID)289297 text = chart.Placeholder(y-g.Plot.Min.Y, x-g.Plot.Min.X)290298 case c.Fg == chart.None && c.Bg == chart.None:@@ -9,15 +9,17 @@ import (
99 )1010 1111 // chartSel is a selected chart. Arrows move it a cell at a time, Shift12-// and arrows resize it, Enter edits it and Del deletes it; the mouse13-// drags it or its corner. Other keys deselect it and act as usual.12+// and arrows resize it, Enter edits it, Del deletes it, and Space held13+// shows it across the grid (keyboard.go); the mouse drags it or its14+// corner. Other keys deselect it and act as usual.1415 type chartSel struct {1516 m chartHost // the model, through what a selected chart needs of it1617 i int1718 drag int // chartDragNone, chartDragMove or chartDragResize1819 grabX int // where the chart was grabbed, relative to its corner1920 grabY int20- preview *sheet.Chart // the chart while being dragged21+ preview *sheet.Chart // the chart while being dragged, or held open22+ zoom bool // Space is held: preview is the chart across the grid2123 }2224 2325 func (s *chartSel) Indicator() string { return "CHART" }@@ -49,6 +51,9 @@ func (s *chartSel) Key(k tea.KeyPressMsg) tea.Cmd {
4951 m.closeOverlay()5052 return nil5153 }54+ if s.holdKey(m, c, k) {55+ return nil56+ }5257 moved := c5358 switch k.String() {5459 case "esc":@@ -117,6 +122,9 @@ func (s *chartSel) press(x, y int) {
117122 }118123 119124 func (s *chartSel) Mouse(e overlay.MouseEvent) tea.Cmd {125+ if e.Kind != overlay.MouseMotion {126+ s.unzoom()127+ }120128 switch e.Kind {121129 case overlay.MousePress:122130 return s.pressAt(e)@@ -227,6 +235,9 @@ func (s *chartSel) Status() (string, string) {
227235 return "", ""228236 }229237 desc := th.Muted.Render("Chart of " + c.Data.String())238+ if s.zoom {239+ return desc, th.KeyHints("Space", "let go to go back")240+ }230241 pairs := []string{"Enter", "edit", "Del", "delete", "Arrows", "move", "Shift+arrows", "resize", "Esc", "done"}231242 for {232243 keys := th.KeyHints(pairs...)@@ -250,8 +261,12 @@ func (s *chartSel) ContextLine() (string, string) {
250261 if !ok {251262 return "", ""252263 }264+ how := " drag to move, drag the corner to resize"265+ if s.m.holdsKeys() { // Space held enlarges the chart: keyboard.go266+ how += ", hold Space to zoom"267+ }253268 return th.Key.Render(c.Type.Title()+" chart") + th.Muted.Render(" of ") + c.Data.String() +254- th.Muted.Render(" drag to move, drag the corner to resize"), ""269+ th.Muted.Render(how), ""255270 }256271 257272 const (@@ -48,7 +48,7 @@ func (m *Model) runCmdLine(text string) (tea.Cmd, bool) {
4848 bang := strings.HasSuffix(word, "!")4949 switch strings.TrimSuffix(word, "!") {5050 case "w":51- return m.writeTo(arg), true51+ return m.writeTo(arg, bang), true5252 case "q":5353 if bang {5454 return m.exit(), true@@ -59,10 +59,12 @@ func (m *Model) runCmdLine(text string) (tea.Cmd, bool) {
5959 return m.exit(), true6060 }6161 m.quitAfterSave = true62- return m.writeTo(arg), true62+ return m.writeTo(arg, bang), true6363 case "e":6464 return m.editFile(arg, bang), true6565 }66+ back := m.vim.back67+ m.jumped() // going to a cell or row is a jump '' comes back from6668 if n, err := strconv.Atoi(text); err == nil {6769 m.clearSelection()6870 m.cur.Row = m.visibleRow(clamp(n-1, 0, sheet.MaxRows-1))@@ -71,6 +73,7 @@ func (m *Model) runCmdLine(text string) (tea.Cmd, bool) {
7173 if m.gotoText(text) {7274 return nil, true7375 }76+ m.vim.back = back7477 id, ok := commandNamed(text)7578 if !ok {7679 return nil, false@@ -101,13 +104,21 @@ func commandNamed(text string) (string, bool) {
101104 // writeTo is :w. Without a name it saves, as File > Save; with one it102105 // saves as that name, as File > Save as, or downloads when the name is103106 // another format's (:w out.csv), as File > Download. It goes the menus'104-// ways, so files are named, checked and replaced the same.105-func (m *Model) writeTo(name string) tea.Cmd {107+// ways, so files are named, checked and replaced the same. Forced (:w!),108+// it answers their questions as Overwrite and Replace would: it writes109+// over the open file even if it changed on disk since it was opened, and110+// over a file of the name given.111+func (m *Model) writeTo(name string, force bool) tea.Cmd {106112 if name == "" {113+ if force && m.filename != "" {114+ return m.saveAs(m.filename, false)115+ }107116 return m.runCommand("file.save")108117 }109118 k, ok := fileio.KindOf(name)110119 switch {120+ case !ok && force:121+ return m.saveAs(withExt(name), false)111122 case !ok:112123 return m.saveAsFile(name)113124 case !k.CanExport():@@ -116,6 +127,12 @@ func (m *Model) writeTo(name string) tea.Cmd {
116127 return nil117128 }118129 m.quitAfterSave = false // a download isn't the sheet's file130+ if force && !k.HasTables() {131+ if path, ok := m.path("download to", name); ok {132+ return m.download(name, path, k, sheet.Rect{}, "")133+ }134+ return nil135+ }119136 return m.downloadFile(k, sheet.Rect{}, name)120137 }121138 @@ -32,6 +32,8 @@ type Host interface {
3232 Run(text string) (tea.Cmd, bool)3333 // Fail shows an error.3434 Fail(msg string)35+ // History is the lines run this session, for Up and Down.36+ History() *History3537 }3638 3739 // Item is a completion: a file command or a registered command.@@ -46,6 +48,7 @@ type Item struct {
4648 // FileWords are vim's file commands.4749 var FileWords = []Item{4850 {Word: "w", Title: "Write", Desc: "Save the sheet; :w name saves it as name, :w name.csv downloads it"},51+ {Word: "w!", Title: "Write anyway", Desc: "Save even if the file changed on disk since it was opened; :w! name replaces name"},4952 {Word: "q", Title: "Quit", Desc: "Close 012, asking about unsaved changes"},5053 {Word: "q!", Title: "Quit without saving", Desc: "Close 012, discarding unsaved changes"},5154 {Word: "wq", Title: "Write and quit", Desc: "Save the sheet, then close 012"},@@ -62,6 +65,7 @@ type Line struct {
6265 overlay.List6366 shown []Item6467 tabbed bool // the text is a completion Tab put there; Tab again moves on68+ hist browse6569 }6670 6771 // New returns a command line with the edit line cleared.@@ -122,20 +126,38 @@ func (c *Line) Key(k tea.KeyPressMsg) tea.Cmd {
122126 c.complete(1)123127 case key == "shift+tab":124128 c.complete(-1)125- case key == "up", key == "ctrl+p":129+ case key == "up", key == "down":130+ c.recall(key)131+ case key == "ctrl+p":126132 c.Move(-1, len(c.shown))127- case key == "down", key == "ctrl+n":133+ case key == "ctrl+n":128134 c.Move(1, len(c.shown))129135 default:130136 before := line.Text()131137 line.Key(k)132138 if line.Text() != before {139+ c.hist = browse{}133140 c.Changed()134141 }135142 }136143 return nil137144 }138145 146+// recall is Up and Down: an older or newer line from the history, among147+// those starting with what was typed.148+func (c *Line) recall(key string) {149+ d := -1150+ if key == "down" {151+ d = 1152+ }153+ text, ok := c.hist.step(c.h.History(), c.h.Line().Text(), d)154+ if !ok {155+ return156+ }157+ c.h.Line().Set(text)158+ c.Changed()159+}160+139161 // complete puts the highlighted completion on the line; pressed again,140162 // it moves on to the next one (d = 1) or the previous one (d = -1).141163 func (c *Line) complete(d int) {@@ -162,6 +184,7 @@ func (c *Line) run() tea.Cmd {
162184 if text == "" {163185 return nil164186 }187+ c.h.History().Add(text)165188 if cmd, ok := c.h.Run(text); ok {166189 return cmd167190 }@@ -183,7 +206,7 @@ func (c *Line) Cursor() (int, int) {
183206 }184207 185208 func (c *Line) Status() (string, string) {186- keys := c.h.Theme().KeyHints("Tab", "complete", "Enter", "run", "Esc", "cancel")209+ keys := c.h.Theme().KeyHints("Tab", "complete", "Up", "history", "Enter", "run", "Esc", "cancel")187210 if c.Sel >= len(c.shown) {188211 return "", keys189212 }@@ -19,6 +19,7 @@ type fakeHost struct {
1919 closed int2020 ran []string2121 failed string22+ hist History2223 }2324 2425 func newHost() *fakeHost { return &fakeHost{th: theme.New(true)} }@@ -28,6 +29,7 @@ func (f *fakeHost) Size() (width, height int) { return 80, 24 }
2829 func (f *fakeHost) Line() *lineedit.Line { return &f.line }2930 func (f *fakeHost) Close() { f.closed++ }3031 func (f *fakeHost) Fail(msg string) { f.failed = msg }32+func (f *fakeHost) History() *History { return &f.hist }3133 3234 func (f *fakeHost) Commands() []Item {3335 return []Item{@@ -75,7 +77,7 @@ func TestCompletes(t *testing.T) {
7577 }7678 h.line.Clear()7779 typeText(c, "w")78- if got := words(c); !strings.HasPrefix(got, "w,wq,") {80+ if got := words(c); !strings.HasPrefix(got, "w,w!,wq,") {7981 t.Errorf("w completes %s", got)8082 }8183 typeText(c, " out.csv")@@ -156,3 +158,35 @@ func TestLayoutStatusAndMouse(t *testing.T) {
156158 t.Error("a click outside didn't close the line")157159 }158160 }161+162+// Up and Down walk the history, among the lines starting with what was163+// typed; a line run goes to its end, once.164+func TestHistory(t *testing.T) {165+ h := newHost()166+ for _, l := range []string{"w", "B12", "edit.fill_down", "B12"} {167+ c := New(h)168+ typeText(c, l)169+ c.Key(tea.KeyPressMsg{Code: tea.KeyEnter})170+ }171+ if got := strings.Join(h.hist.Lines(), ","); got != "w,edit.fill_down,B12" {172+ t.Fatalf("history %s", got)173+ }174+ c := New(h)175+ up, down := tea.KeyPressMsg{Code: tea.KeyUp}, tea.KeyPressMsg{Code: tea.KeyDown}176+ c.Key(up)177+ c.Key(up)178+ if h.line.Text() != "edit.fill_down" || words(c) != "edit.fill_down" {179+ t.Errorf("Up Up: %q, completions %s", h.line.Text(), words(c))180+ }181+ c.Key(down)182+ c.Key(down)183+ if h.line.Text() != "" {184+ t.Errorf("Down Down: %q", h.line.Text())185+ }186+ typeText(c, "e")187+ c.Key(up)188+ c.Key(up)189+ if h.line.Text() != "edit.fill_down" {190+ t.Errorf("e Up Up: %q", h.line.Text())191+ }192+}@@ -0,0 +1,61 @@
1+package cmdline2+3+import "strings"4+5+// maxHistory is how many lines the history keeps.6+const maxHistory = 1007+8+// History is the lines run on the command line, oldest first. It lasts9+// as long as the session: each `012 serve` session has its own.10+type History struct {11+ lines []string12+}13+14+// Add puts line at the end of the history, moving it there if it's15+// already in it.16+func (h *History) Add(line string) {17+ if line == "" {18+ return19+ }20+ for i, l := range h.lines {21+ if l == line {22+ h.lines = append(h.lines[:i], h.lines[i+1:]...)23+ break24+ }25+ }26+ h.lines = append(h.lines, line)27+ if len(h.lines) > maxHistory {28+ h.lines = h.lines[len(h.lines)-maxHistory:]29+ }30+}31+32+// Lines are the lines in the history, oldest first.33+func (h *History) Lines() []string { return h.lines }34+35+// browse is a walk through the history with Up and Down, as vim's: only36+// lines starting with what was typed before the first Up.37+type browse struct {38+ on bool39+ at int // the line shown, len(lines) for what was typed40+ prefix string // what was typed41+}42+43+// step moves d lines through h (-1 older, 1 newer) from what's shown,44+// skipping lines that don't start with the prefix, and returns the text45+// to show and whether there was a line to move to.46+func (b *browse) step(h *History, typed string, d int) (string, bool) {47+ if !b.on {48+ *b = browse{on: true, at: len(h.lines), prefix: typed}49+ }50+ for i := b.at + d; i >= 0 && i <= len(h.lines); i += d {51+ if i == len(h.lines) {52+ b.at = i53+ return b.prefix, true54+ }55+ if strings.HasPrefix(h.lines[i], b.prefix) {56+ b.at = i57+ return h.lines[i], true58+ }59+ }60+ return "", false61+}@@ -121,7 +121,7 @@ func (m *Model) openFile(text string) tea.Cmd {
121121 // session rather than the sheet carries over: the window, theme, terminal122122 // state, served directory and the JEV connection.123123 func (m *Model) reset(s *sheet.Sheet, filename string) {124- *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, root: m.root, charts: chartState{last: -1}, prefs: m.prefs,124+ *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, root: m.root, charts: chartState{last: -1}, prefs: m.prefs, session: m.session,125125 macros: macroState{machine: m.macros.machine, editor: m.macros.editor}, spans: m.spans}126126 s.Book().SetTrace(m.spans)127127 if m.jev != nil {@@ -4,6 +4,7 @@ import (
44 "fmt"55 "image/color"66 "log/slog"7+ "slices"78 "strconv"89 "strings"910 @@ -21,24 +22,30 @@ import (
2122 // replies to queries sent at startup. It survives File New and Open.2223 //2324 // Charts become images when the terminal answers a kitty graphics query24-// (kitty, Ghostty, WezTerm and others); everywhere else they stay text.25-// Images use the terminal's own palette, asked for with OSC 4 once26-// graphics are known to work, so bars match the text legend.25+// (kitty, Ghostty, WezTerm and others), or else when its primary device26+// attributes (DA1) list sixel graphics (foot, xterm, mlterm, Windows27+// Terminal; see sixel.go); everywhere else they stay text. Images use28+// the terminal's own palette, asked for with OSC 4 at startup, so bars29+// match the text legend.2730 type terminal struct {2831 noImages bool // chart-images = false in the config2932 noNotify bool // notifications = false in the config3033 kitty bool // answers kitty graphics queries34+ sixel bool // lists sixel in DA1, without kitty graphics, outside tmux35+ registers int // sixel color registers, as XTSMGRAPHICS reports them3136 tmux bool // inside tmux: sequences for the outer terminal need passthrough3237 cellW, cellH int // cell size in pixels, 0 until reported3338 palette map[int]color.RGBA39+ bg color.Color // the terminal's background, nil until reported3440 sent map[int]string // image id -> what was sent, to send only changes41+ six sixelState // the sixel images drawn and to draw3542 blurred bool // the terminal window doesn't have focus3643 }3744 3845 // newTerminal starts with what getenv, the terminal's environment, says:3946 // the process's own locally, the client's when served over SSH.4047 func newTerminal(getenv func(string) string) terminal {41- return terminal{tmux: getenv("TMUX") != "", palette: map[int]color.RGBA{}, sent: map[int]string{}}48+ return terminal{tmux: getenv("TMUX") != "", palette: map[int]color.RGBA{}, sent: map[int]string{}, registers: 256}4249 }4350 4451 // wrap prepares a sequence for the outer terminal.@@ -50,10 +57,13 @@ func (t *terminal) wrap(seq string) string {
5057 }5158 5259 // probes are the startup queries: kitty graphics support, live light53-// and dark changes (mode 2031), and the 16 palette colors (OSC 4), which54-// chart images and color scales draw with.60+// and dark changes (mode 2031), the 16 palette colors (OSC 4), which61+// chart images and color scales draw with, the sixel color registers62+// (XTSMGRAPHICS) and, last, the primary device attributes. Terminals63+// answer in order, so the kitty and XTSMGRAPHICS replies, if any, come64+// before DA1's, which then decides between kitty images, sixel and text.5565 func (t *terminal) probes() tea.Cmd {56- return tea.Raw(t.wrap(chart.Query()) + ansi.SetModeLightDark + paletteQuery())66+ return tea.Raw(t.wrap(chart.Query()) + ansi.SetModeLightDark + paletteQuery() + sixelRegistersQuery + ansi.RequestPrimaryDeviceAttributes)5767 }5868 5969 // paletteQuery asks for the 16 ANSI colors; terminals that don't answer@@ -84,6 +94,15 @@ func (t *terminal) handle(msg tea.Msg) tea.Cmd {
8494 // was asked for at startup.8595 return tea.Raw(ansi.WindowOp(16)) // 16: report the cell size in pixels8696 }97+ case uv.PrimaryDeviceAttributesEvent:98+ if slices.Contains(msg, 4) && !t.kitty && !t.tmux && !t.sixel {99+ t.sixel = true100+ return tea.Raw(ansi.WindowOp(16)) // sixel images are drawn at the cell size101+ }102+ case uv.UnknownCsiEvent:103+ if n, ok := parseRegisters(string(msg)); ok {104+ t.registers = n105+ }87106 case uv.CellSizeEvent:88107 if msg.Width > 0 && msg.Height > 0 {89108 t.cellW, t.cellH = msg.Width, msg.Height@@ -156,10 +175,12 @@ func (t *terminal) chartPalette(th *theme.Theme) chart.Palette {
156175 return p157176 }158177 159-// images reports whether charts are drawn as images.178+// images reports whether charts are drawn as kitty images.160179 func (t *terminal) images() bool { return t.kitty && !t.noImages }161180 162-// chartOptions are the drawing options for charts on this terminal.181+// chartOptions are the drawing options for charts on this terminal. For182+// sixel, whether a chart is an image depends on where it is: see183+// Model.drawChart.163184 func (t *terminal) chartOptions() chart.Options {164185 return chart.Options{Image: t.images(), CellW: t.cellW, CellH: t.cellH}165186 }@@ -127,12 +127,14 @@ func (h host) Commands() []cmdline.Item {
127127 128128 func (h host) Run(text string) (tea.Cmd, bool) { return h.m.runCmdLine(text) }129129 func (h host) Fail(msg string) { h.m.fail(msg) }130+func (h host) History() *cmdline.History { return &h.m.session.history }130131 131132 // The theme picker.132133 133134 func (h host) Current() string { return h.m.prefs.Config.Theme().Pick(!h.m.prefs.light) }134135 func (h host) ThemesDir() string { return h.m.prefs.ThemesDir }135136 func (h host) Keep(name string) { h.m.keepTheme(name) }137+func (h host) KeyReleases() bool { return h.m.session.releases }136138 137139 func (h host) Preview(name string) {138140 h.m.prefs.preview = name@@ -0,0 +1,114 @@
1+package ui2+3+import (4+ tea "charm.land/bubbletea/v2"5+6+ "github.com/FelineStateMachine/012/internal/sheet"7+ "github.com/FelineStateMachine/012/internal/ui/cmdline"8+)9+10+// The kitty keyboard protocol. Bubble Tea asks every terminal for its11+// first level, which tells apart the keys legacy encodings merge:12+// Shift+Enter from Enter (it accepts an entry and moves up, as in13+// Sheets), Ctrl+I from Tab (italic), Ctrl+M from Enter, and Alt+letter14+// from Esc followed by the letter. 012 also asks for key events15+// (View.KeyboardEnhancements.ReportEventTypes), which say when a key is16+// let go; holding a key then previews something until it is released:17+// Space held on a selected chart shows it across the grid, and in the18+// theme picker hides the list to show the sheet in the highlighted19+// theme. A terminal without the protocol sends neither, and those keys20+// do what they do there: Shift+Enter is Enter, Ctrl+I is Tab, and Space21+// is typed.22+23+// session is what lasts as long as the program, across files opened.24+type session struct {25+ history cmdline.History // lines run on the : line26+ // releases is whether the terminal reports key releases, so a key27+ // can be held.28+ releases bool29+}30+31+// keyboard notes what the terminal said of the keyboard protocol.32+func (m *Model) keyboard(msg tea.KeyboardEnhancementsMsg) {33+ m.session.releases = msg.SupportsEventTypes()34+}35+36+// releaser is an overlay that acts when a key it holds is let go.37+type releaser interface {38+ Release(k tea.KeyReleaseMsg) tea.Cmd39+}40+41+// held reports whether an open overlay is waiting for a release.42+func (m *Model) held() bool {43+ _, ok := m.overlay.(releaser)44+ return ok45+}46+47+// keyReleased passes a key's release to the overlay holding it.48+func (m *Model) keyReleased(k tea.KeyReleaseMsg) tea.Cmd {49+ if r, ok := m.overlay.(releaser); ok {50+ return r.Release(k)51+ }52+ return nil53+}54+55+// holdKey starts holding Space on a selected chart, on a terminal that56+// reports releases, and reports whether it took k. Repeats while it's57+// held do nothing; any other key lets go first.58+func (s *chartSel) holdKey(m chartHost, c sheet.Chart, k tea.KeyPressMsg) bool {59+ if k.String() != "space" || !m.holdsKeys() {60+ s.unzoom()61+ return false62+ }63+ if !s.zoom {64+ z := m.zoomed(c)65+ s.preview, s.zoom = &z, true66+ }67+ return true68+}69+70+// Release ends a hold of Space.71+func (s *chartSel) Release(k tea.KeyReleaseMsg) tea.Cmd {72+ if k.String() == "space" {73+ s.unzoom()74+ }75+ return nil76+}77+78+func (s *chartSel) unzoom() {79+ if s.zoom {80+ s.preview, s.zoom = nil, false81+ }82+}83+84+// holdsKeys reports whether the terminal reports keys let go, so a key85+// can be held.86+func (m *Model) holdsKeys() bool { return m.session.releases }87+88+// zoomed is chart c spread over the scrolling part of the grid.89+func (m *Model) zoomed(c sheet.Chart) sheet.Chart {90+ c.At = sheet.Addr{Col: m.left, Row: m.top}91+ x, y := m.chartScreen(c)92+ c.W = min(max(m.width-x, sheet.MinChartW), sheet.MaxChartW)93+ c.H = min(max(gridTop+m.visibleRows()-y, sheet.MinChartH), sheet.MaxChartH)94+ return c95+}96+97+// chartOrder is the order charts are drawn in, bottom first: as they98+// are, but with a chart held open on top.99+func (m *Model) chartOrder(n int) []int {100+ order := make([]int, 0, n)101+ z := -1102+ if s, ok := m.overlay.(*chartSel); ok && s.zoom {103+ z = s.i104+ }105+ for i := range n {106+ if i != z {107+ order = append(order, i)108+ }109+ }110+ if z >= 0 && z < n {111+ order = append(order, z)112+ }113+ return order114+}@@ -0,0 +1,123 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// keyEvents is the terminal's answer when it reports key releases.14+var keyEvents = tea.KeyboardEnhancementsMsg{Flags: 1 | ansi.KittyReportEventTypes}15+16+func space() tea.Key { return tea.Key{Code: tea.KeySpace, Text: " "} }17+18+// The view asks for key releases; Shift+Enter accepts an entry and moves19+// up, and Ctrl+I, which only the protocol tells from Tab, is italic.20+func TestKeyboardProtocol(t *testing.T) {21+ m := newModel()22+ if !m.View().KeyboardEnhancements.ReportEventTypes {23+ t.Error("the view doesn't ask for key releases")24+ }25+ press(t, m, "<down>", "<down>", "5", "<shift+enter>")26+ if input(m, "A3") != "5" || m.cur != addr("A2") {27+ t.Errorf("Shift+Enter: A3 %q at %v", input(m, "A3"), m.cur)28+ }29+ press(t, m, "<down>", "<ctrl+i>")30+ if c := m.sheet.Cell(addr("A3")); m.cur != addr("A3") || c == nil || !c.Style.Italic {31+ t.Errorf("Ctrl+I: at %v", m.cur)32+ }33+ press(t, m, "<tab>")34+ if m.cur != addr("B3") {35+ t.Errorf("Tab: at %v", m.cur)36+ }37+ // A release with nothing held does nothing.38+ if _, cmd := m.Update(tea.KeyReleaseMsg{Code: 'x', Text: "x"}); cmd != nil || m.mode != modeReady {39+ t.Errorf("release: mode %v", m.mode)40+ }41+}42+43+// Holding Space on a selected chart shows it across the grid until44+// Space is let go, on a terminal that reports releases.45+func TestHoldSpaceEnlargesChart(t *testing.T) {46+ m := wideModel()47+ chartTable(t, m)48+ m.runCommand("insert.chart")49+ press(t, m, "<enter>")50+ c := m.sheet.Charts()[0]51+ if strings.Contains(line(m, contextLine), "hold") {52+ t.Errorf("hint without releases: %q", line(m, contextLine))53+ }54+ send(m, keyEvents)55+ if !strings.Contains(line(m, contextLine), "hold Space to zoom") {56+ t.Errorf("context: %q", line(m, contextLine))57+ }58+ send(m, tea.KeyPressMsg(space()))59+ z := m.displayCharts()[0]60+ if z.W <= c.W || z.H <= c.H || z.At != (sheet.Addr{Col: m.left, Row: m.top}) {61+ t.Fatalf("held: %+v", z)62+ }63+ if !strings.Contains(line(m, m.height-1), "let go") {64+ t.Errorf("held status: %q", line(m, m.height-1))65+ }66+ repeat := space()67+ repeat.IsRepeat = true68+ send(m, tea.KeyPressMsg(repeat))69+ send(m, tea.KeyReleaseMsg(space()))70+ if got := m.displayCharts()[0]; got.W != c.W || got.At != c.At || m.sheet.Charts()[0].W != c.W {71+ t.Errorf("after release: shown %+v, stored %+v", got, m.sheet.Charts()[0])72+ }73+ if _, ok := m.overlay.(*chartSel); !ok {74+ t.Errorf("release left %T", m.overlay)75+ }76+ // Any other key lets go first.77+ send(m, tea.KeyPressMsg(space()))78+ press(t, m, "<right>")79+ if got := m.sheet.Charts()[0]; got.W != c.W || got.At.Col != c.At.Col+1 {80+ t.Errorf("Right while held: %+v", got)81+ }82+}83+84+// Without releases, Space on a selected chart deselects it and starts85+// an entry, as it always has.86+func TestSpaceOnChartWithoutReleases(t *testing.T) {87+ m := wideModel()88+ chartTable(t, m)89+ m.runCommand("insert.chart")90+ press(t, m, "<enter>")91+ send(m, tea.KeyPressMsg(space()))92+ if _, ok := m.overlay.(*chartSel); ok || m.mode != modeEnter {93+ t.Errorf("Space: %T mode %v", m.overlay, m.mode)94+ }95+}96+97+// Holding Space in the theme picker hides the list, showing the sheet98+// in the highlighted theme, until it's let go.99+func TestHoldSpaceInThemePicker(t *testing.T) {100+ m, _, _ := configured(t, "theme = Nord\n")101+ send(m, keyEvents)102+ press(t, m, "<alt+f>")103+ openSettingsItem(t, m, "Theme")104+ press(t, m, "<down>")105+ name := m.th.Name106+ if !strings.Contains(screen(m), "Type a name") || !strings.Contains(line(m, m.height-1), "hold to peek") {107+ t.Fatalf("picker: %q\n%s", line(m, m.height-1), screen(m))108+ }109+ send(m, tea.KeyPressMsg(space()))110+ if s := screen(m); strings.Contains(s, "Type a name") || m.th.Name != name {111+ t.Fatalf("held: theme %q\n%s", m.th.Name, s)112+ }113+ send(m, tea.KeyReleaseMsg(space()))114+ if s := screen(m); !strings.Contains(s, "Type a name") || m.overlay == nil {115+ t.Fatalf("released:\n%s", s)116+ }117+ // With a search typed, Space is typed into it.118+ press(t, m, "no")119+ send(m, tea.KeyPressMsg(space()))120+ if m.line.Text() != "no " {121+ t.Errorf("search: %q", m.line.Text())122+ }123+}@@ -111,6 +111,7 @@ type Model struct {
111111 jev *jevRunner // answers JEV functions; nil without an API key: jev.go112112 term terminal // what the terminal supports: graphics.go113113 prefs prefs // the settings in effect and the theme chosen: prefs.go114+ session session // what outlasts the file open: the : history, the keyboard: keyboard.go114115 115116 vim vimState // a vim key sequence in progress: vim.go116117 rec *recorder // a macro being recorded: macrorec.go@@ -149,6 +150,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
149150 if _, key := msg.(tea.KeyPressMsg); key && m.keyAt.IsZero() && telemetry.Enabled() {150151 m.keyAt = time.Now()151152 }153+ if _, release := msg.(tea.KeyReleaseMsg); release && !m.held() {154+ return m, nil // a release matters only to a key held: keyboard.go155+ }152156 if cmd, ok := m.importing(msg); ok {153157 return m, cmd154158 }@@ -160,6 +164,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
160164 }161165 before, beforeMode := *m.focus(), m.mode162166 state := m.beginUpdate(msg)167+ m.vimCapture(msg)163168 var cmd tea.Cmd164169 if mouse, ok := msg.(tea.MouseMsg); ok {165170 var handled bool@@ -210,6 +215,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
210215 cmd = m.finishMacro(msg.r, msg.done)211216 case macroEditedMsg:212217 m.macroEdited(msg)218+ case tea.KeyReleaseMsg:219+ cmd = m.keyReleased(msg)220+ case tea.KeyboardEnhancementsMsg:221+ m.keyboard(msg)222+ case tea.ClipboardMsg:223+ if m.vim.clipPaste {224+ m.pasteClipboard(msg.Content)225+ }213226 default:214227 if !m.handlePrefs(msg) {215228 cmd = m.term.handle(msg)@@ -217,6 +230,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
217230 }218231 // Scroll only when the focus moves, so the mouse wheel can look around219232 // without the view snapping back, as in Sheets.233+ m.vimSettle()220234 m.endUpdate(state)221235 if f := *m.focus(); f != before || m.mode != beforeMode {222236 m.scrollTo(f)@@ -224,7 +238,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
224238 m.clampView()225239 // Any edit may have queued JEV questions.226240 // Chart images follow any change, see graphics.go.227- return m, tea.Batch(cmd, m.jev.send(m.spans.Parent()), m.term.syncImages(m.sheet, m.displayCharts, &m.th, m.spans))241+ return m, tea.Batch(cmd, m.jev.send(m.spans.Parent()), m.term.syncImages(m.sheet, m.displayCharts, &m.th, m.spans), m.syncSixel(msg))228242 }229243 230244 // beginUpdate prepares for an input event and returns the sheet's state@@ -49,10 +49,13 @@ func (m *Model) View() tea.View {
4949 content = m.compose(content, boxes)5050 }5151 content = m.fillScreen(content)52+ m.term.six.noteFrame(content)5253 v := tea.NewView(content)5354 v.AltScreen = true5455 v.MouseMode = tea.MouseModeAllMotion // hover feedback; see mouse.go5556 v.ReportFocus = true // notifications only when the window is in the background57+ // Key releases, for keys held to preview: keyboard.go.58+ v.KeyboardEnhancements.ReportEventTypes = true5659 v.WindowTitle = "012 - " + m.displayName()5760 if m.jev.busy() != "" {5861 // Terminals that support it (OSC 9;4) show activity in the tab.@@ -219,6 +219,7 @@ func (m *Model) handlePrefs(msg tea.Msg) bool {
219219 switch msg := msg.(type) {220220 case tea.BackgroundColorMsg:221221 m.prefs.light = !msg.IsDark()222+ m.term.bg = msg.Color222223 if p := m.applyTheme(); p != "" && m.note == "" {223224 m.note = m.th.Warning.Render(p)224225 }@@ -0,0 +1,293 @@
1+package ui2+3+import (4+ "fmt"5+ "hash/fnv"6+ "image"7+ "image/color"8+ "log/slog"9+ "slices"10+ "strconv"11+ "strings"12+ "time"13+14+ tea "charm.land/bubbletea/v2"15+ "github.com/charmbracelet/x/ansi"16+17+ "github.com/FelineStateMachine/012/internal/chart"18+)19+20+// Sixel chart images, for terminals without kitty graphics. A sixel21+// image is pixels the terminal draws at the cursor, which Bubble Tea's22+// renderer knows nothing of: it keeps drawing cells, and a cell it23+// writes over the image punches a hole in it. So:24+//25+// - A chart is an image only where the whole of its plot area shows: in26+// the grid, under no chart drawn later and no floating box (a menu,27+// a dialog, formula suggestions, a note). Elsewhere it is text. The28+// frame keeps blank cells where an image goes, so the renderer has29+// nothing to write there.30+// - Images are drawn after the frame: a sixelDrawMsg comes once the31+// frame has been still for sixelSettle, longer than a frame, and32+// draws each image with the cursor saved and restored around it.33+// - When an image moves, changes or goes (scrolling, resizing, an edit34+// of its data, an overlay opening over it), the screen is cleared and35+// redrawn whole, which erases the images, and they are drawn again.36+// When a row an image crosses changes, the images are drawn again.37+// - Never inside tmux: tmux answers DA1 for itself, and an image passed38+// through to the outer terminal lands where tmux doesn't know, so its39+// redraws would erase it.40+41+// sixelRegistersQuery asks for the number of sixel color registers42+// (XTSMGRAPHICS, read).43+const sixelRegistersQuery = "\x1b[?1;1;0S"44+45+// parseRegisters parses the XTSMGRAPHICS reply "CSI ? 1 ; 0 ; n S".46+func parseRegisters(s string) (int, bool) {47+ s, ok := strings.CutPrefix(s, "\x1b[?1;0;")48+ if !ok {49+ return 0, false50+ }51+ s, ok = strings.CutSuffix(s, "S")52+ n, err := strconv.Atoi(s)53+ if !ok || err != nil || n < 2 {54+ return 0, false55+ }56+ return min(n, 256), true57+}58+59+// sixelSettle is how long the frame stays still before images are drawn60+// over it: more than a frame at 60 fps, so the frame is out first.61+const sixelSettle = 25 * time.Millisecond62+63+// sixelState is what the sixel images are and where.64+type sixelState struct {65+ cache map[int]sixelImage // by chart66+ plan []sixelPlace // the images the frame keeps room for67+ drawn []sixelPlace // the images on screen68+ frame uint64 // the rows of the latest frame the plan crosses, hashed69+ drawnFrame uint64 // frame when the images were drawn70+ changed time.Time // when frame last changed71+ pending bool // a sixelDrawMsg is on its way72+ now func() time.Time // the clock; time.Now when nil73+ tick func() tea.Cmd // what sends sixelDrawMsg; sixelTick when nil74+}75+76+// sixelImage is a chart's image encoded, and where its plot area is in77+// the chart's contents.78+type sixelImage struct {79+ key string80+ plot image.Rectangle81+ seq string // "" when there is nothing to plot82+}83+84+// sixelPlace is chart's image at screen cells at.85+type sixelPlace struct {86+ chart int87+ at image.Rectangle88+ key string89+}90+91+// sixelDrawMsg draws the images once the frame has settled.92+type sixelDrawMsg struct{}93+94+func sixelTick() tea.Cmd {95+ return tea.Tick(sixelSettle, func(time.Time) tea.Msg { return sixelDrawMsg{} })96+}97+98+// later asks for a sixelDrawMsg.99+func (s *sixelState) later() tea.Cmd {100+ s.pending = true101+ if s.tick != nil {102+ return s.tick()103+ }104+ return sixelTick()105+}106+107+func (s *sixelState) clock() time.Time {108+ if s.now != nil {109+ return s.now()110+ }111+ return time.Now()112+}113+114+// sixelOn reports whether charts are drawn as sixel images: the115+// terminal has sixel and has said its cell size, and chart-images isn't116+// off.117+func (t *terminal) sixelOn() bool { return t.sixel && !t.noImages && t.cellW > 0 && t.cellH > 0 }118+119+// placed reports whether chart i is an image in the current frame.120+func (s *sixelState) placed(i int) bool {121+ return slices.ContainsFunc(s.plan, func(p sixelPlace) bool { return p.chart == i })122+}123+124+// syncSixel follows msg, after Update has handled it: it encodes the125+// charts that changed, works out where images go, clears the screen when126+// one drawn moved, changed or went, and asks for the images to be drawn127+// once the frame settles.128+func (m *Model) syncSixel(msg tea.Msg) tea.Cmd {129+ t := &m.term130+ if _, ok := msg.(sixelDrawMsg); ok {131+ return m.drawSixels()132+ }133+ var cmds []tea.Cmd134+ if _, ok := msg.(tea.WindowSizeMsg); ok && t.sixel {135+ cmds = append(cmds, tea.Raw(ansi.WindowOp(16))) // the font may have changed size too136+ }137+ if !t.sixelOn() {138+ t.six.plan, t.six.cache = nil, nil139+ if len(t.six.drawn) > 0 {140+ t.six.drawn = nil141+ cmds = append(cmds, tea.ClearScreen)142+ }143+ return tea.Batch(cmds...)144+ }145+ m.encodeSixels()146+ t.six.plan = m.sixelPlan()147+ if t.six.gone() {148+ t.six.drawn = nil149+ cmds = append(cmds, tea.ClearScreen)150+ }151+ if len(t.six.plan) > 0 && !t.six.pending {152+ cmds = append(cmds, t.six.later())153+ }154+ return tea.Batch(cmds...)155+}156+157+// gone reports whether an image drawn isn't in the plan as it is.158+func (s *sixelState) gone() bool {159+ for _, d := range s.drawn {160+ if !slices.Contains(s.plan, d) {161+ return true162+ }163+ }164+ return false165+}166+167+// drawSixels draws the planned images over the settled frame, unless168+// they are drawn already over the same rows.169+func (m *Model) drawSixels() tea.Cmd {170+ s := &m.term.six171+ s.pending = false172+ if !m.term.sixelOn() || len(s.plan) == 0 || s.frame == s.drawnFrame && slices.Equal(s.drawn, s.plan) {173+ return nil174+ }175+ if s.clock().Sub(s.changed) < sixelSettle {176+ return s.later()177+ }178+ var b strings.Builder179+ for _, p := range s.plan {180+ b.WriteString(ansi.SaveCursor + ansi.CursorPosition(p.at.Min.X+1, p.at.Min.Y+1))181+ b.WriteString(s.cache[p.chart].seq)182+ b.WriteString(ansi.RestoreCursor)183+ }184+ s.drawn, s.drawnFrame = slices.Clone(s.plan), s.frame185+ return tea.Raw(b.String())186+}187+188+// noteFrame records the rows of the frame content that the planned189+// images cross, so images are drawn again when those rows change.190+func (s *sixelState) noteFrame(content string) {191+ if len(s.plan) == 0 {192+ s.frame = 0193+ return194+ }195+ h := fnv.New64a()196+ lines := strings.Split(content, "\n")197+ for _, p := range s.plan {198+ fmt.Fprintf(h, "%d %v %s\n", p.chart, p.at, p.key)199+ for y := p.at.Min.Y; y < p.at.Max.Y && y < len(lines); y++ {200+ h.Write([]byte(lines[y]))201+ }202+ }203+ if f := h.Sum64(); f != s.frame {204+ s.frame, s.changed = f, s.clock()205+ }206+}207+208+// encodeSixels encodes the image of each chart whose data, size, colors209+// or cell size changed.210+func (m *Model) encodeSixels() {211+ t := &m.term212+ if t.six.cache == nil {213+ t.six.cache = map[int]sixelImage{}214+ }215+ o := t.chartOptions()216+ o.Image = true217+ pal, bg := t.chartPalette(&m.th), m.sixelBackground()218+ charts := m.displayCharts()219+ for i, c := range charts {220+ if i >= maxImages {221+ break222+ }223+ w, h := chartInner(c)224+ d := m.sheet.ChartData(c)225+ o.Chart = c.ChartOptions226+ key := fmt.Sprintf("%v %v %d %d %v %v %v %d", c.Type, d, w, h, o, pal, bg, t.registers)227+ if t.six.cache[i].key == key {228+ continue229+ }230+ span := m.spans.Start("chart.sixel", slog.String("type", c.Type.String()), slog.Int("w", w), slog.Int("h", h))231+ g := chart.Draw(c.Type, d, w, h, o)232+ si := sixelImage{key: key, plot: g.Plot}233+ if img := chart.Image(c.Type, d, w, h, o, pal); img != nil && !g.Plot.Empty() {234+ si.seq = chart.Sixel(img, bg, t.registers)235+ }236+ span.End(slog.Int("bytes", len(si.seq)))237+ t.six.cache[i] = si238+ }239+ for i := range t.six.cache {240+ if i >= len(charts) || i >= maxImages {241+ delete(t.six.cache, i)242+ }243+ }244+}245+246+// sixelBackground is the color images are composited over: the theme's247+// background, or the terminal's, or black.248+func (m *Model) sixelBackground() color.RGBA {249+ c := m.term.bg250+ if m.th.Palette != nil && m.th.Palette.Background != nil {251+ c = m.th.Palette.Background252+ }253+ if c == nil {254+ return color.RGBA{0, 0, 0, 255}255+ }256+ r, g, b, _ := c.RGBA()257+ return color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), 255}258+}259+260+// sixelPlan is where images go: the charts whose plot area shows whole261+// in the grid, under no chart drawn later and no floating box.262+func (m *Model) sixelPlan() []sixelPlace {263+ var over []image.Rectangle264+ for _, b := range m.floating() {265+ if len(b.Lines) > 0 {266+ over = append(over, image.Rect(b.X, b.Y, b.X+b.Width(), b.Y+b.Height()))267+ }268+ }269+ charts := m.displayCharts()270+ boxes := make([]image.Rectangle, len(charts))271+ for i, c := range charts {272+ x, y := m.chartScreen(c)273+ boxes[i] = image.Rect(x, y, x+c.W, y+c.H)274+ }275+ grid := image.Rect(m.hdrW(), gridTop, m.width, gridTop+m.visibleRows())276+ var plan []sixelPlace277+ for i := range charts {278+ img, ok := m.term.six.cache[i]279+ if !ok || img.seq == "" {280+ continue281+ }282+ at := img.plot.Add(boxes[i].Min.Add(image.Pt(2, 1))) // the border and a column of padding283+ if !at.In(grid) || overlapsAny(at, over) || overlapsAny(at, boxes[i+1:]) {284+ continue285+ }286+ plan = append(plan, sixelPlace{i, at, img.key})287+ }288+ return plan289+}290+291+func overlapsAny(r image.Rectangle, rs []image.Rectangle) bool {292+ return slices.ContainsFunc(rs, r.Overlaps)293+}@@ -0,0 +1,159 @@
1+package ui2+3+import (4+ "fmt"5+ "strings"6+ "testing"7+ "time"8+9+ tea "charm.land/bubbletea/v2"10+ uv "github.com/charmbracelet/ultraviolet"11+ "github.com/charmbracelet/x/ansi/kitty"12+)13+14+// ticked stands for a sixelDrawMsg asked for: tests deliver15+// sixelDrawMsg themselves, with the clock where they want it.16+type ticked struct{}17+18+// sixelModel is a wide model with a chart, on a terminal that has said19+// what a sixel terminal says: sixel in DA1, 16 color registers and its20+// cell size. Its clock stands still unless the test moves it.21+func sixelModel(t *testing.T) (*Model, *time.Time) {22+ t.Helper()23+ m := wideModel()24+ m.term.tmux = false // wherever the tests run25+ clock := time.Unix(1000, 0)26+ m.term.six.now = func() time.Time { return clock }27+ m.term.six.tick = func() tea.Cmd { return func() tea.Msg { return ticked{} } }28+ chartTable(t, m)29+ m.runCommand("insert.chart")30+ press(t, m, "<enter>", "<esc>")31+ if !strings.Contains(screen(m), "█") {32+ t.Fatalf("no bars in the text chart:\n%s", screen(m))33+ }34+ send(m, uv.UnknownCsiEvent("\x1b[?1;0;16S"))35+ if got := sixelOut(m, uv.PrimaryDeviceAttributesEvent{62, 4, 22}); !m.term.sixel || got != "\x1b[16t" {36+ t.Fatalf("sixel not detected, or no cell size asked for: %q", got)37+ }38+ if m.term.registers != 16 {39+ t.Errorf("%d registers, want 16", m.term.registers)40+ }41+ if got := sixelOut(m, uv.CellSizeEvent{Width: 8, Height: 16}); got != "<tick>" {42+ t.Fatalf("after the cell size: %q, want images asked for", got)43+ }44+ return m, &clock45+}46+47+// sixelOut updates m with msg and returns what the commands write to the48+// terminal, with "<clear>" for a screen cleared and "<tick>" for images49+// asked for.50+func sixelOut(m *Model, msg tea.Msg) string {51+ _, cmd := m.Update(msg)52+ m.View()53+ var b strings.Builder54+ for _, msg := range flatten(cmd) {55+ switch msg := msg.(type) {56+ case tea.RawMsg:57+ b.WriteString(msg.Msg.(string))58+ case ticked:59+ b.WriteString("<tick>")60+ default:61+ if fmt.Sprintf("%T", msg) == "tea.clearScreenMsg" {62+ b.WriteString("<clear>")63+ }64+ }65+ }66+ return b.String()67+}68+69+func TestSixelImages(t *testing.T) {70+ m, clock := sixelModel(t)71+ if len(m.term.six.plan) != 1 || strings.Contains(screen(m), "█") {72+ t.Fatalf("plan %v; the plot area isn't blank:\n%s", m.term.six.plan, screen(m))73+ }74+ if !strings.Contains(screen(m), "1,500") {75+ t.Error("the axes aren't text around the image")76+ }77+ // Not drawn until the frame has been still for sixelSettle.78+ if got := sixelOut(m, sixelDrawMsg{}); got != "<tick>" {79+ t.Errorf("drawn over a frame still changing: %q", got)80+ }81+ *clock = clock.Add(sixelSettle)82+ p := m.term.six.plan[0]83+ got := sixelOut(m, sixelDrawMsg{})84+ at := fmt.Sprintf("\x1b7\x1b[%d;%dH\x1bP0;1;0q", p.at.Min.Y+1, p.at.Min.X+1)85+ if !strings.HasPrefix(got, at) || !strings.HasSuffix(got, "\x1b\\\x1b8") {86+ t.Fatalf("image drawn as %.60q, want it at %q with the cursor saved", got, at)87+ }88+ if n := strings.Count(got, ";2;"); n == 0 || n > 16 {89+ t.Errorf("%d colors defined for 16 registers", n)90+ }91+ // Drawn once: a frame that doesn't change draws nothing.92+ if got := sixelOut(m, sixelDrawMsg{}); got != "" {93+ t.Errorf("drawn again over the same frame: %.40q", got)94+ }95+ // A row the image crosses changes: drawn again.96+ press(t, m, "<f5>", fmt.Sprintf("A%d", p.at.Min.Y-gridTop+1), "<enter>")97+ m.View() // as Bubble Tea does after every update98+ *clock = clock.Add(time.Second)99+ if got := sixelOut(m, sixelDrawMsg{}); !strings.HasPrefix(got, at) {100+ t.Errorf("not drawn again after its rows changed: %.40q", got)101+ }102+}103+104+// Scrolling moves the image: the screen is cleared, erasing it, and it is105+// drawn where it went. The command palette over it makes it text.106+func TestSixelMovesAndHides(t *testing.T) {107+ m, clock := sixelModel(t)108+ *clock = clock.Add(time.Second)109+ sixelOut(m, sixelDrawMsg{})110+ was := m.term.six.plan[0].at111+ if got := sixelOut(m, tea.MouseWheelMsg{X: 50, Y: gridTop + 2, Button: tea.MouseWheelDown}); !strings.Contains(got, "<clear>") {112+ t.Fatalf("scrolled without clearing: %q", got)113+ }114+ if len(m.term.six.plan) == 1 && m.term.six.plan[0].at == was {115+ t.Fatalf("the image didn't move: %v", m.term.six.plan)116+ }117+ *clock = clock.Add(time.Second)118+ if got := sixelOut(m, sixelDrawMsg{}); len(m.term.six.plan) == 1 && !strings.Contains(got, "\x1bP") {119+ t.Error("not drawn where it went")120+ }121+ sixelOut(m, tea.MouseWheelMsg{X: 50, Y: gridTop + 2, Button: tea.MouseWheelUp})122+ *clock = clock.Add(time.Second)123+ sixelOut(m, sixelDrawMsg{})124+ if got := sixelOut(m, tea.KeyPressMsg{Code: 'k', Mod: tea.ModCtrl}); len(m.term.six.plan) != 0 || !strings.Contains(got, "<clear>") {125+ t.Errorf("palette open: plan %v, %q", m.term.six.plan, got)126+ }127+}128+129+// Sixel is used only when kitty graphics aren't, never inside tmux, and130+// not with chart-images off.131+func TestSixelDetection(t *testing.T) {132+ m := wideModel()133+ m.term.tmux = false134+ m.Update(uv.PrimaryDeviceAttributesEvent{62, 22})135+ if m.term.sixel {136+ t.Error("sixel without attribute 4")137+ }138+ m.Update(uv.KittyGraphicsEvent{Options: kitty.Options{ID: 31}, Payload: []byte("OK")})139+ m.Update(uv.PrimaryDeviceAttributesEvent{62, 4})140+ if m.term.sixel {141+ t.Error("sixel on a terminal with kitty graphics")142+ }143+ m = wideModel()144+ m.term.tmux = true145+ m.Update(uv.PrimaryDeviceAttributesEvent{62, 4})146+ if m.term.sixel {147+ t.Error("sixel inside tmux")148+ }149+ m, _ = sixelModel(t)150+ m.term.noImages = true151+ if got := sixelOut(m, nil); m.term.sixelOn() || len(m.term.six.plan) != 0 || got != "" {152+ t.Errorf("sixel images with chart-images = false: %q", got)153+ }154+ for in, want := range map[string]int{"\x1b[?1;0;256S": 256, "\x1b[?1;0;1024S": 256, "\x1b[?1;3;0S": 0, "\x1b[?2;0;4S": 0} {155+ if n, _ := parseRegisters(in); n != want {156+ t.Errorf("%q: %d registers, want %d", in, n, want)157+ }158+ }159+}@@ -28,6 +28,9 @@ type Host interface {
2828 Preview(name string)2929 // Keep closes the picker and makes name the theme, saving it.3030 Keep(name string)31+ // KeyReleases reports whether the terminal says when a key is let32+ // go (the kitty keyboard protocol), so Space can be held to peek.33+ KeyReleases() bool3134 }3235 3336 // Picker is the open theme picker.@@ -36,6 +39,7 @@ type Picker struct {
3639 h Host3740 open bool // not yet closed or kept3841 shown string // the theme being previewed42+ peek bool // Space is held: the list is hidden to show the sheet3943 }4044 4145 // New returns a picker of every theme with the current one highlighted@@ -120,7 +124,57 @@ func (c closing) Close() {
120124 c.Host.Close()121125 }122126 127+// Holding Space, on a terminal that reports key releases, hides the list128+// so the whole sheet shows in the highlighted theme; letting go brings129+// the list back. It needs an empty search, so a space can still be typed130+// into one. Elsewhere Space is typed, as any other key.131+132+// holdsSpace reports whether k, a press of Space, starts or continues a133+// hold rather than typing.134+func (tp *Picker) holdsSpace(k tea.KeyPressMsg) bool {135+ return k.String() == "space" && tp.h.KeyReleases() && (tp.peek || tp.h.Line().Text() == "")136+}137+138+// Release ends a hold of Space.139+func (tp *Picker) Release(k tea.KeyReleaseMsg) tea.Cmd {140+ if k.String() == "space" {141+ tp.peek = false142+ }143+ return nil144+}145+146+func (tp *Picker) Layout() []overlay.Box {147+ if tp.peek {148+ return nil149+ }150+ return tp.Picker.Layout()151+}152+153+func (tp *Picker) Cursor() (int, int) {154+ if tp.peek {155+ return -1, 0156+ }157+ return tp.Picker.Cursor()158+}159+160+func (tp *Picker) Status() (string, string) {161+ desc, keys := tp.Picker.Status()162+ th := tp.h.Theme()163+ switch {164+ case tp.peek:165+ return "Theme " + tp.shown, th.KeyHints("Space", "let go to go back")166+ case tp.h.KeyReleases() && tp.h.Line().Text() == "":167+ keys = th.KeyHints("Space", "hold to peek") + " " + keys168+ }169+ return desc, keys170+}171+123172 func (tp *Picker) Key(k tea.KeyPressMsg) tea.Cmd {173+ if tp.holdsSpace(k) {174+ tp.peek = true175+ return nil176+ }177+ tp.peek = false124178 if k.String() == "esc" {125179 tp.h.Preview("")126180 }@@ -20,6 +20,7 @@ type fakeHost struct {
2020 previews []string2121 kept string2222 closed int23+ releases bool2324 }2425 2526 func (f *fakeHost) Theme() *theme.Theme { return &f.th }@@ -31,6 +32,7 @@ func (f *fakeHost) Current() string { return f.current }
3132 func (f *fakeHost) ThemesDir() string { return "" }3233 func (f *fakeHost) Preview(name string) { f.previews = append(f.previews, name) }3334 func (f *fakeHost) Keep(name string) { f.kept = name }35+func (f *fakeHost) KeyReleases() bool { return f.releases }3436 func (f *fakeHost) last() string { return f.previews[len(f.previews)-1] }3537 func newHost(current string) *fakeHost { return &fakeHost{th: theme.New(true), current: current} }3638 func down() tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeyDown} }@@ -18,6 +18,8 @@ import (
1818 // vim doesn't claim keep their Sheets meaning, so arrows, Ctrl+S, Alt1919 // menus and F-keys work as always. Every action but moving and selecting2020 // runs a registered command; the bindings are tables in vimkeys.go.21+// Registers are in vimregs.go, marks in vimmarks.go and . in22+// vimrepeat.go.2123 2224 // vimState is where a vim key sequence has got to.2325 type vimState struct {@@ -26,6 +28,18 @@ type vimState struct {
2628 visual visualKind2729 // rows is the clip dd or yy last copied: pasting it inserts rows.2830 rows *sheet.Clip31+ reg rune // the register named with " for the next operator, 0 for none32+ // typed are the keys of the sequence so far, after any count and33+ // register, kept for . to repeat.34+ typed []tea.Msg35+ regs map[rune]vimRegister // named, numbered and small-delete registers36+ marks map[rune]mark37+ back *mark // where the last jump left from, for '' and ``38+ // last is the change . repeats; inserting is one still taking the39+ // keys of the entry it started (i, o, cc).40+ last, inserting *vimChange41+ replaying bool // . is replaying last42+ clipPaste bool // "+p asked the terminal for the clipboard2943 }3044 3145 type visualKind int@@ -42,14 +56,25 @@ const maxCount = 9999
4256 // n is the count, 1 when none was typed.4357 func (v *vimState) n() int { return max(v.count, 1) }4458 45-func (v *vimState) reset() { v.count, v.keys = 0, "" }59+func (v *vimState) reset() { v.count, v.keys, v.reg, v.typed = 0, "", 0, nil }4660 47-// pending is what's been typed of an unfinished sequence, e.g. "3d".61+// pending is what's been typed of an unfinished sequence, e.g. "3d" or62+// "a2y.4863 func (v *vimState) pending() string {49- if v.count == 0 {50- return v.keys64+ p := v.keys65+ if v.count > 0 {66+ p = strconv.Itoa(v.count) + p5167 }52- return strconv.Itoa(v.count) + v.keys68+ if v.reg != 0 {69+ p = `"` + string(v.reg) + p70+ }71+ return p72+}73+74+// argPrefixes are the keys whose next key is a name rather than a75+// command: a register after ", a mark after m, ' and `.76+var argPrefixes = map[string]string{77+ `"`: "register", "m": "set a mark", "'": "go to a mark's row", "`": "go to a mark",5378 }5479 5580 // vimKey names a key press as the vim tables do: the character typed,@@ -83,12 +108,23 @@ func (m *Model) vimKeyPress(k tea.KeyPressMsg) (tea.Cmd, bool) {
83108 if key == "esc" {84109 return m.vimEscape()85110 }111+ if _, ok := argPrefixes[v.keys]; ok {112+ return m.vimArgKey(key), true113+ }114+ if _, ok := argPrefixes[key]; ok && v.keys == "" {115+ v.keys = key116+ return nil, true117+ }86118 if d := digit(key); v.keys == "" && (d > 0 || d == 0 && v.count > 0) {87119 v.count = min(v.count*10+d, maxCount)88120 return nil, true89121 }122+ v.typed = append(v.typed, k)90123 seq := v.keys + key91124 if mv, ok := vimMotions[seq]; ok {125+ if jumpMotions[seq] {126+ m.jumped()127+ }92128 m.vimMove(mv)93129 v.reset()94130 return nil, true@@ -98,7 +134,7 @@ func (m *Model) vimKeyPress(k tea.KeyPressMsg) (tea.Cmd, bool) {
98134 table = vimVisual99135 }100136 if b, ok := table[seq]; ok {101- cmd := b.run(m, v.n())137+ cmd := m.runBinding(b)102138 v.reset()103139 return cmd, true104140 }@@ -266,8 +302,15 @@ func (m *Model) vimLine() string {
266302 // vimNext lists the keys that finish the sequence begun, and what they267303 // do, as pairs for KeyHints: after d, "d" and "cut rows".268304 func (m *Model) vimNext() []string {269- if m.vim.keys == "" {270- return nil // just a count: anything may follow305+ switch m.vim.keys {306+ case "":307+ return nil // just a count or a register: anything may follow308+ case `"`:309+ return []string{"a-z", "named", "0-9", "copies, rows cut", "-", "cells cut", "+", "clipboard"}310+ case "m":311+ return []string{"a-z", "mark the cell"}312+ case "'", "`":313+ return []string{"a-z", argPrefixes[m.vim.keys], m.vim.keys, "back"}271314 }272315 table := vimNormal273316 if m.visual() != visualNone {@@ -286,8 +329,10 @@ func (m *Model) vimNext() []string {
286329 var out []string287330 for _, seq := range seqs {288331 what := "first row"289- if b, ok := table[seq]; ok {332+ if b, ok := table[seq]; ok && b.id != "" {290333 what = strings.ToLower(commands[b.id].title)334+ } else if ok {335+ what = strings.ToLower(b.label[:1]) + b.label[1:]291336 }292337 out = append(out, seq[len(m.vim.keys):], what)293338 }@@ -26,6 +26,32 @@ type vimBinding struct {
2626 // do, when set, acts instead of running id: for keys that change2727 // mode or choose between commands.2828 do func(m *Model, n int) tea.Cmd29+ // change marks a change that . repeats; yank a copy that goes to30+ // the registers ("0), and cut a delete that does ("1 to "9 or "-).31+ change, yank, cut bool32+ jump bool // a jump that '' comes back from (n, N, /)33+}34+35+// runBinding carries out b with the count typed, remembering a change36+// for . and what it copied in the registers.37+func (m *Model) runBinding(b vimBinding) tea.Cmd {38+ v := &m.vim39+ var c *vimChange40+ if b.change && !v.replaying {41+ c = &vimChange{keys: v.typed, count: v.count, reg: v.reg}42+ }43+ if b.jump {44+ m.jumped()45+ }46+ copied := m.copied.clip47+ cmd := b.run(m, v.n())48+ if (b.yank || b.cut) && m.copied.clip != copied {49+ m.storeRegister(b.cut)50+ }51+ if c != nil {52+ m.vimChanged(c)53+ }54+ return cmd2955 }3056 3157 // run carries out the binding with count n.@@ -62,27 +88,31 @@ func (b vimBinding) run(m *Model, n int) tea.Cmd {
6288 // vimNormal binds key sequences in NORMAL mode, in the order help lists6389 // them.6490 var vimNormal = map[string]vimBinding{65- "i": {id: "edit", label: "Edit the cell, caret at the start", do: func(m *Model, _ int) tea.Cmd {91+ "i": {id: "edit", label: "Edit the cell, caret at the start", change: true, do: func(m *Model, _ int) tea.Cmd {6692 cmd := m.runCommand("edit")6793 m.line.Pos = 06894 return cmd6995 }},70- "a": {id: "edit", span: spanOnce},71- "=": {label: "Start a formula", do: func(m *Model, _ int) tea.Cmd { m.startEntry(modeEnter, "="); return nil }},72- "o": {id: "row.open_below", span: spanOnce},73- "O": {id: "row.open_above", span: spanOnce},74- "x": {id: "clear", span: spanCols},75- "dd": {id: "row.cut", span: spanRows},76- "yy": {id: "row.yank", span: spanRows},77- "p": {id: "row.paste_below", label: "Paste rows below, or cells here", do: putAfter},78- "P": {id: "row.paste_above", label: "Paste rows above, or cells here", do: putBefore},96+ "a": {id: "edit", span: spanOnce, change: true},97+ "=": {label: "Start a formula", change: true, do: func(m *Model, _ int) tea.Cmd { m.startEntry(modeEnter, "="); return nil }},98+ "o": {id: "row.open_below", span: spanOnce, change: true},99+ "O": {id: "row.open_above", span: spanOnce, change: true},100+ "x": {id: "clear", label: "Clear the cell, keeping a copy", change: true, cut: true, do: deleteCells},101+ "s": {label: "Clear the cell and start typing", change: true, cut: true, do: substitute},102+ "dd": {id: "row.cut", span: spanRows, change: true, cut: true},103+ "cc": {label: "Clear the row and start typing", change: true, cut: true, do: changeRows},104+ "S": {label: "Clear the row and start typing", change: true, cut: true, do: changeRows},105+ "yy": {id: "row.yank", span: spanRows, yank: true},106+ "p": {id: "row.paste_below", label: "Paste rows below, or cells here", change: true, do: putAfter},107+ "P": {id: "row.paste_above", label: "Paste rows above, or cells here", change: true, do: putBefore},108+ ".": {label: "Repeat the last change"}, // do is repeatChange, set in vimrepeat.go79109 "u": {id: "edit.undo"},80110 "ctrl+r": {id: "edit.redo"},81111 "v": {label: "Select cells (VISUAL)", do: func(m *Model, _ int) tea.Cmd { m.startVisual(visualCells); return nil }},82112 "V": {label: "Select rows (VISUAL)", do: func(m *Model, _ int) tea.Cmd { m.startVisual(visualRows); return nil }},83- "/": {id: "edit.find", span: spanOnce},84- "n": {id: "edit.find_next"},85- "N": {id: "edit.find_prev"},113+ "/": {id: "edit.find", span: spanOnce, jump: true},114+ "n": {id: "edit.find_next", jump: true},115+ "N": {id: "edit.find_prev", jump: true},86116 ":": {id: "vim.command", span: spanOnce},87117 "gt": {id: "sheet.next"},88118 "gT": {id: "sheet.prev"},@@ -90,9 +120,9 @@ var vimNormal = map[string]vimBinding{
90120 91121 // vimVisual binds keys in VISUAL mode; motions extend the selection.92122 var vimVisual = map[string]vimBinding{93- "d": {label: "Delete the selection, keeping a copy", do: visualDelete},94- "x": {label: "Delete the selection, keeping a copy", do: visualDelete},95- "y": {label: "Copy the selection", do: func(m *Model, _ int) tea.Cmd {123+ "d": {label: "Delete the selection, keeping a copy", cut: true, do: visualDelete},124+ "x": {label: "Delete the selection, keeping a copy", cut: true, do: visualDelete},125+ "y": {label: "Copy the selection", yank: true, do: func(m *Model, _ int) tea.Cmd {96126 id := "edit.copy"97127 if m.vim.visual == visualRows {98128 id = "row.yank"@@ -101,7 +131,7 @@ var vimVisual = map[string]vimBinding{
101131 }},102132 "p": {id: "edit.paste", label: "Paste over the selection", do: func(m *Model, _ int) tea.Cmd {103133 m.vim.visual = visualNone104- return m.runCommand("edit.paste")134+ return m.fromRegister(func() tea.Cmd { return m.runCommand("edit.paste") })105135 }},106136 "o": {label: "Go to the other corner", do: func(m *Model, _ int) tea.Cmd {107137 m.cur, m.ext = m.ext, m.cur@@ -113,7 +143,7 @@ var vimVisual = map[string]vimBinding{
113143 }114144 115145 // vimHelpOrder lists the NORMAL and VISUAL bindings for help, grouped.116-var vimHelpOrder = []string{"i", "a", "=", "o", "O", "x", "dd", "yy", "p", "P", "u", "ctrl+r", "v", "V", "/", "n", "N", ":", "gt", "gT"}146+var vimHelpOrder = []string{"i", "a", "=", "o", "O", "x", "s", "dd", "cc", "S", "yy", "p", "P", ".", "u", "ctrl+r", "v", "V", "/", "n", "N", ":", "gt", "gT"}117147 118148 // visualDelete deletes the selection, keeping a copy to paste: whole119149 // rows with V, the cells' contents with v.@@ -126,6 +156,45 @@ func visualDelete(m *Model, _ int) tea.Cmd {
126156 return m.endVisual(cmd)127157 }128158 159+// deleteCells is x: clear n cells from the active one rightwards,160+// keeping a copy to paste, as vim's x keeps the characters it deletes.161+func deleteCells(m *Model, n int) tea.Cmd {162+ if c := commands["clear"]; !c.available(m) {163+ m.note = c.title + " isn't available now"164+ return nil165+ }166+ m.spanCols(n)167+ cmd := m.runCommand("edit.copy")168+ m.runCommand("clear")169+ if m.mode == modeReady {170+ m.clearSelection()171+ }172+ return cmd173+}174+175+// substitute is s: x, then an entry in the cleared cell.176+func substitute(m *Model, n int) tea.Cmd {177+ cmd := deleteCells(m, n)178+ if m.mode == modeReady {179+ m.startEntry(modeEnter, "")180+ }181+ return cmd182+}183+184+// changeRows is cc and S: clear the contents of n rows from the active185+// cell's, keeping a copy of them to paste as rows, and start an entry in186+// the active cell. The rows and their formats stay.187+func changeRows(m *Model, n int) tea.Cmd {188+ m.spanRows(n)189+ cmd := m.runCommand("row.yank")190+ m.runCommand("clear")191+ if m.mode == modeReady {192+ m.clearSelection()193+ m.startEntry(modeEnter, "")194+ }195+ return cmd196+}197+129198 // endVisual goes back to NORMAL mode after an operator.130199 func (m *Model) endVisual(cmd tea.Cmd) tea.Cmd {131200 m.vim.visual = visualNone@@ -147,20 +216,24 @@ func (m *Model) switchVisual(kind visualKind) tea.Cmd {
147216 148217 // putAfter is p: rows copied with yy or dd go in as new rows below the149218 // active one; cells copied any other way are pasted at the active cell.219+// A register named with " is pasted rather than the clipboard.150220 func putAfter(m *Model, _ int) tea.Cmd {151- if !m.rowsCopied() {152- return m.runCommand("edit.paste")153- }154- return m.runCommand("row.paste_below") // it reads the count, n155-221+ return m.fromRegister(func() tea.Cmd {222+ if !m.rowsCopied() {223+ return m.runCommand("edit.paste")224+ }225+ return m.runCommand("row.paste_below") // it reads the count, n226+ })156227 }157228 158229 // putBefore is P: as p, but rows go in above the active one.159230 func putBefore(m *Model, _ int) tea.Cmd {160- if !m.rowsCopied() {161- return m.runCommand("edit.paste")162- }163- return m.runCommand("row.paste_above")231+ return m.fromRegister(func() tea.Cmd {232+ if !m.rowsCopied() {233+ return m.runCommand("edit.paste")234+ }235+ return m.runCommand("row.paste_above")236+ })164237 }165238 166239 // vimKeysFor returns the vim keys bound to a command, NORMAL mode first.@@ -213,7 +286,11 @@ func vimHelpRows() []helpRow {
213286 {keys: []string{"5j", "3dd"}, action: "A count repeats a move or sizes an operator"},214287 }215288 rows = append(rows, bindingRows(vimNormal, vimHelpOrder)...)216- rows = append(rows, helpRow{heading: "Vim keys: VISUAL"},289+ rows = append(rows,290+ helpRow{keys: []string{`"a`, `"0`, `"+`}, action: "Name the register the next operator uses"},291+ helpRow{keys: []string{"ma", "`a", "'a"}, action: "Mark the cell; go to the mark, or its row"},292+ helpRow{keys: []string{"''"}, action: "Back to where the last jump left from"},293+ helpRow{heading: "Vim keys: VISUAL"},217294 helpRow{keys: []string{"Motions"}, action: "Extend the selection"})218295 rows = append(rows, bindingRows(vimVisual, vimVisualOrder)...)219296 return append(rows, helpRow{keys: []string{"Esc"}, action: "Back to NORMAL"})@@ -0,0 +1,117 @@
1+package ui2+3+import (4+ "unicode/utf8"5+6+ tea "charm.land/bubbletea/v2"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// Vim marks: m{a-z} marks the active cell, `{a-z} goes back to it (on12+// its sheet) and '{a-z} to its row, in column A. Jumps (gg, G, H, M, L,13+// /, n, N, a mark, a cell or row from the : line) remember where they14+// left from, and '' or `` goes back there. A mark stays on its address:15+// rows inserted above it don't move it. Marks live with the workbook.16+17+// mark is a marked cell.18+type mark struct {19+ sheet *sheet.Sheet20+ at sheet.Addr21+}22+23+// jumpMotions are the motions that count as jumps.24+var jumpMotions = map[string]bool{"gg": true, "G": true, "H": true, "M": true, "L": true}25+26+// jumped remembers the active cell before a jump, to go back to with a27+// quote typed twice.28+func (m *Model) jumped() {29+ if m.visual() == visualNone {30+ m.vim.back = &mark{sheet: m.sheet, at: m.cur}31+ }32+}33+34+// vimArgKey finishes a sequence that takes a name: the register after ",35+// the mark after m, ' or `.36+func (m *Model) vimArgKey(key string) tea.Cmd {37+ v := &m.vim38+ prefix := v.keys39+ v.keys = ""40+ r, size := utf8.DecodeRuneInString(key)41+ if size != len(key) {42+ r = 0 // a named key, such as "up"43+ }44+ switch prefix {45+ case `"`:46+ if !isRegister(r) {47+ m.note = `No register "` + key + `: a-z, 0-9, -, " and +`48+ v.reset()49+ return nil50+ }51+ v.reg = r // the count typed before it stays, and more may follow52+ case "m":53+ v.reset()54+ m.setMark(r, key)55+ default:56+ v.reset()57+ m.jumpToMark(r, key, prefix == "`")58+ }59+ return nil60+}61+62+// setMark is m{a-z}.63+func (m *Model) setMark(r rune, key string) {64+ if r < 'a' || r > 'z' {65+ m.note = "Marks are a to z, not " + key66+ return67+ }68+ if m.vim.marks == nil {69+ m.vim.marks = map[rune]mark{}70+ }71+ m.vim.marks[r] = mark{sheet: m.sheet, at: m.cur}72+ m.note = "Mark " + key + " at " + m.cur.String() + "; `" + key + " comes back"73+}74+75+// jumpToMark is `{a-z} (the cell) and '{a-z} (its row, in column A);76+// after ' or `, back to where the last jump left from. In VISUAL mode it77+// moves the selection's corner, on the sheet shown.78+func (m *Model) jumpToMark(r rune, key string, exact bool) {79+ var to mark80+ switch mk, ok := m.vim.marks[r]; {81+ case r == '\'' || r == '`':82+ if m.vim.back == nil {83+ m.note = "No jump to go back from yet"84+ return85+ }86+ to = *m.vim.back87+ case ok:88+ to = mk89+ case r >= 'a' && r <= 'z':90+ m.note = "Mark " + key + " isn't set: m" + key + " sets it"91+ return92+ default:93+ m.note = "Marks are a to z, not " + key94+ return95+ }96+ if !to.sheet.Live() {97+ m.note = "Mark " + key + " was on a deleted sheet"98+ return99+ }100+ if !exact {101+ to.at.Col = 0102+ }103+ if m.visual() != visualNone {104+ if to.sheet != m.sheet {105+ m.note = "Mark " + key + " is on " + to.sheet.Name()106+ return107+ }108+ m.ext = clampAddr(to.at)109+ m.ext.Row = m.visibleRow(m.ext.Row)110+ return111+ }112+ m.jumped()113+ m.showSheet(to.sheet)114+ m.clearSelection()115+ m.cur = clampAddr(to.at)116+ m.cur.Row = m.visibleRow(m.cur.Row)117+}@@ -0,0 +1,323 @@
1+package ui2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+ "time"9+10+ tea "charm.land/bubbletea/v2"11+)12+13+// . repeats the last change with its count, or the count typed before14+// it; an edit begun with i, a, o or = repeats with what was typed.15+func TestVimRepeat(t *testing.T) {16+ m := vimModel(t)17+ press(t, m, ".")18+ if !strings.Contains(line(m, contextLine), "No change to repeat") {19+ t.Errorf(". first: %q", line(m, contextLine))20+ }21+ press(t, m, "x", "j", ".")22+ if input(m, "A1") != "" || input(m, "A2") != "" || input(m, "A3") != "A3" {23+ t.Fatalf("x j .: A1 %q A2 %q", input(m, "A1"), input(m, "A2"))24+ }25+ // A count before . replaces the change's own.26+ press(t, m, "l", "2x", "j", ".")27+ if input(m, "B3") != "" || input(m, "C3") != "" {28+ t.Fatalf("2x .: B3 %q C3 %q", input(m, "B3"), input(m, "C3"))29+ }30+ press(t, m, "j", "h", "3.")31+ if input(m, "A4") != "" || input(m, "B4") != "" || input(m, "C4") != "" {32+ t.Fatalf("3.: %q %q %q", input(m, "A4"), input(m, "B4"), input(m, "C4"))33+ }34+ // An edit repeats with its text and the key that accepted it.35+ press(t, m, "gg", "0", "a", "!", "<enter>")36+ if input(m, "A1") != "!" || m.cur != addr("A2") {37+ t.Fatalf("a!: A1 %q at %v", input(m, "A1"), m.cur)38+ }39+ press(t, m, "j", "j", "j", ".")40+ if input(m, "A5") != "A5!" || m.cur != addr("A6") {41+ t.Fatalf("a . : A5 %q at %v", input(m, "A5"), m.cur)42+ }43+ // o repeats the row it opened and what was typed in it.44+ press(t, m, "G", "o", "new", "<enter>")45+ press(t, m, ".")46+ if input(m, "A7") != "new" || input(m, "A8") != "" || input(m, "A9") != "new" {47+ t.Fatalf("o .: A7 %q A9 %q", input(m, "A7"), input(m, "A9"))48+ }49+ // dd repeats with its count; moves and copies don't replace the change.50+ press(t, m, "gg", "2dd", "yy", "j", ".")51+ if input(m, "A1") != "A3" || input(m, "A2") != "A6" {52+ t.Fatalf("2dd yy .: A1 %q A2 %q", input(m, "A1"), input(m, "A2"))53+ }54+ // Each change undoes on its own.55+ press(t, m, "u")56+ if input(m, "A3") != "A5!" {57+ t.Errorf("u after .: A3 %q", input(m, "A3"))58+ }59+}60+61+// Every sequence begun shows what finishes it.62+func TestVimPendingLines(t *testing.T) {63+ m := vimModel(t)64+ for _, prefix := range []string{"d", "y", "c", "g", `"`, "m", "'", "`", `"a`, "3"} {65+ press(t, m, prefix)66+ if l := line(m, contextLine); !strings.HasPrefix(l, prefix) || !strings.Contains(l, "Esc") {67+ t.Errorf("%s: %q", prefix, l)68+ }69+ press(t, m, "<esc>")70+ }71+ press(t, m, "V", "3")72+ if l := line(m, contextLine); !strings.HasPrefix(l, "3") {73+ t.Errorf("V 3: %q", l)74+ }75+}76+77+// Registers: "a keeps its own copy, "0 the last copy, "1 to "9 deleted78+// rows and "- deleted cells, while the clipboard follows every copy.79+func TestVimRegisters(t *testing.T) {80+ m := vimModel(t)81+ press(t, m, `"ayy`)82+ if r := m.vim.regs['a']; r.clip == nil || !r.rows {83+ t.Fatalf(`"ayy: %+v`, r)84+ }85+ press(t, m, "j", "yy", "j", "dd") // "0 row 2, "1 row 3; A3 is now A486+ press(t, m, "l", "x") // "- is B3, which now holds B487+ for name, want := range map[rune]string{'a': "A1", '0': "A2", '1': "A3", '-': "B4"} {88+ r := m.vim.regs[name]89+ if r.clip == nil {90+ t.Fatalf(`"%c is empty`, name)91+ }92+ if got := formatTSV(r.clip.TextIn(m.locale()))[:2]; got != want {93+ t.Errorf(`"%c holds %q, want %q`, name, got, want)94+ }95+ }96+ // "ap pastes row 1 as a new row below; the clipboard stays "-.97+ press(t, m, "G", `"ap`)98+ if input(m, "A6") != "A1" || input(m, "C6") != "C1" {99+ t.Fatalf(`"ap: A6 %q C6 %q`, input(m, "A6"), input(m, "C6"))100+ }101+ press(t, m, "0", "p")102+ if input(m, "A6") != "B4" {103+ t.Errorf("p after \"ap pastes the clipboard: A6 %q", input(m, "A6"))104+ }105+ // A second delete of rows moves "1 to "2.106+ press(t, m, "gg", "dd")107+ if formatTSV(m.vim.regs['2'].clip.TextIn(m.locale()))[:2] != "A3" {108+ t.Errorf(`"2 after dd: %q`, formatTSV(m.vim.regs['2'].clip.TextIn(m.locale())))109+ }110+ press(t, m, `"bp`)111+ if !strings.Contains(line(m, contextLine), `Register "b is empty`) {112+ t.Errorf(`"bp: %q`, line(m, contextLine))113+ }114+ press(t, m, `"A`)115+ if !strings.Contains(line(m, contextLine), `No register "A`) || m.vim.pending() != "" {116+ t.Errorf(`"A: %q, pending %q`, line(m, contextLine), m.vim.pending())117+ }118+ // The register shows while the sequence is typed.119+ press(t, m, `"a2`)120+ if l := line(m, contextLine); !strings.HasPrefix(l, `"a2`) {121+ t.Errorf("pending register: %q", l)122+ }123+ press(t, m, "<esc>")124+ // VISUAL y into a register, and p from one over a selection.125+ press(t, m, "gg", "0", "v", "l", `"cy`)126+ if r := m.vim.regs['c']; r.clip == nil || r.rows || r.clip.Src.String() != "A1:B1" {127+ t.Fatalf(`v "cy: %+v`, r)128+ }129+ press(t, m, "G", "v", `"cp`)130+ if at := m.cur.String(); input(m, at) != input(m, "A1") {131+ t.Errorf(`v "cp: %s %q`, at, input(m, at))132+ }133+}134+135+// "+p asks the terminal for the system clipboard and pastes its answer.136+func TestVimSystemClipboard(t *testing.T) {137+ m := vimModel(t)138+ press(t, m, "G", "j", `"+p`)139+ if !m.vim.clipPaste {140+ t.Fatal(`"+p didn't ask for the clipboard`)141+ }142+ send(m, tea.ClipboardMsg{Content: "x\ty\n1\t2"})143+ if input(m, "A7") != "x" || input(m, "B8") != "2" || m.vim.clipPaste {144+ t.Fatalf(`"+p block: A7 %q B8 %q`, input(m, "A7"), input(m, "B8"))145+ }146+ press(t, m, "<esc>", "gg", `"+p`)147+ send(m, tea.ClipboardMsg{Content: "one"})148+ if input(m, "A1") != "one" || m.mode != modeReady {149+ t.Fatalf(`"+p value: A1 %q mode %v`, input(m, "A1"), m.mode)150+ }151+ // An answer nobody asked for is ignored.152+ send(m, tea.ClipboardMsg{Content: "two"})153+ if input(m, "A1") != "one" {154+ t.Errorf("unasked clipboard pasted: %q", input(m, "A1"))155+ }156+}157+158+// cc and S clear the row and start typing; s clears the cell. Both keep159+// what they cleared to paste.160+func TestVimChange(t *testing.T) {161+ m := vimModel(t)162+ press(t, m, "j", "l", "c")163+ if l := line(m, contextLine); !strings.Contains(l, "clear the row and start typing") {164+ t.Errorf("c: %q", l)165+ }166+ press(t, m, "c")167+ if m.mode != modeEnter || m.cur != addr("B2") || input(m, "A2") != "" || input(m, "C2") != "" {168+ t.Fatalf("cc: mode %v at %v, A2 %q", m.mode, m.cur, input(m, "A2"))169+ }170+ press(t, m, "new", "<enter>")171+ if input(m, "B2") != "new" || input(m, "A3") != "A3" || m.sheet.Len() != 16 {172+ t.Fatalf("cc new: B2 %q, %d cells", input(m, "B2"), m.sheet.Len())173+ }174+ if r := m.vim.regs['1']; r.clip == nil || !r.rows {175+ t.Errorf(`cc didn't keep the row in "1`)176+ }177+ press(t, m, "2s")178+ if m.mode != modeEnter || input(m, "B3") != "" || input(m, "C3") != "" || input(m, "A3") != "A3" {179+ t.Fatalf("2s: mode %v B3 %q C3 %q", m.mode, input(m, "B3"), input(m, "C3"))180+ }181+ press(t, m, "<esc>", "p")182+ if input(m, "B3") != "B3" || input(m, "C3") != "C3" {183+ t.Errorf("p after s: B3 %q C3 %q", input(m, "B3"), input(m, "C3"))184+ }185+ press(t, m, "gg", "2S", "top", "<enter>")186+ if input(m, "B1") != "top" || input(m, "A2") != "" || input(m, "A3") != "A3" {187+ t.Errorf("2S: B1 %q A2 %q", input(m, "B1"), input(m, "A2"))188+ }189+ press(t, m, "j", ".")190+ if input(m, "B3") != "top" || input(m, "A3") != "" {191+ t.Errorf("S .: B3 %q A3 %q", input(m, "B3"), input(m, "A3"))192+ }193+}194+195+// Marks: m sets one, ` goes back to the cell and ' to its row, across196+// sheets; a quote typed twice goes back from a jump.197+func TestVimMarks(t *testing.T) {198+ m := vimModel(t)199+ press(t, m, "j", "l", "ma")200+ if !strings.Contains(line(m, contextLine), "Mark a at B2") {201+ t.Errorf("ma: %q", line(m, contextLine))202+ }203+ press(t, m, "G", "$", "`a")204+ if m.cur != addr("B2") {205+ t.Fatalf("`a: at %v", m.cur)206+ }207+ press(t, m, "l", "'a")208+ if m.cur != addr("A2") {209+ t.Fatalf("'a: at %v", m.cur)210+ }211+ press(t, m, "``")212+ if m.cur != addr("C2") {213+ t.Fatalf("``: at %v", m.cur)214+ }215+ press(t, m, "G", "``")216+ if m.cur != addr("C2") {217+ t.Fatalf("G ``: at %v", m.cur)218+ }219+ press(t, m, ":B12", "<enter>", "''")220+ if m.cur != addr("A2") {221+ t.Fatalf(":B12 '' goes to the row: at %v", m.cur)222+ }223+ press(t, m, "'b")224+ if !strings.Contains(line(m, contextLine), "Mark b isn't set") {225+ t.Errorf("'b: %q", line(m, contextLine))226+ }227+ // A mark on another sheet shows that sheet.228+ first := m.sheet229+ m.runCommand("sheet.new")230+ press(t, m, "`a")231+ if m.sheet != first || m.cur != addr("B2") {232+ t.Errorf("`a from another sheet: %s at %v", m.sheet.Name(), m.cur)233+ }234+ // In VISUAL mode a mark moves the selection's corner.235+ press(t, m, "gg", "0", "v", "`a")236+ if indicator(m) != "VISUAL" || m.selection().String() != "A1:B2" {237+ t.Errorf("v `a: %q %v", indicator(m), m.selection())238+ }239+}240+241+// Up and Down on the : line go through the lines run before, those242+// starting with what's typed; the history outlasts the file.243+func TestVimCommandHistory(t *testing.T) {244+ m := vimModel(t)245+ press(t, m, ":B5", "<enter>", ":fill down", "<enter>", ":B7", "<enter>")246+ press(t, m, ":", "<up>")247+ if m.line.Text() != "B7" {248+ t.Fatalf("Up: %q", m.line.Text())249+ }250+ press(t, m, "<up>", "<up>")251+ if m.line.Text() != "B5" {252+ t.Fatalf("Up Up Up: %q", m.line.Text())253+ }254+ press(t, m, "<up>")255+ if m.line.Text() != "B5" {256+ t.Errorf("Up past the oldest: %q", m.line.Text())257+ }258+ press(t, m, "<down>", "<down>", "<down>")259+ if m.line.Text() != "" {260+ t.Errorf("Down back to what was typed: %q", m.line.Text())261+ }262+ press(t, m, "<esc>", ":B", "<up>")263+ if m.line.Text() != "B7" {264+ t.Errorf("B Up: %q", m.line.Text())265+ }266+ press(t, m, "<up>", "<enter>")267+ if m.cur != addr("B5") {268+ t.Errorf("recalled :B5 at %v", m.cur)269+ }270+ // Ctrl+N moves the completions.271+ press(t, m, ":fill", "<ctrl+n>")272+ if s := screen(m); !strings.Contains(line(m, m.height-1), "Copy the") || !strings.Contains(s, "Fill right") {273+ t.Errorf("Ctrl+N: %q", line(m, m.height-1))274+ }275+ press(t, m, "<esc>")276+ m.runCommand("file.new")277+ press(t, m, ":", "<up>")278+ if m.line.Text() != "B5" {279+ t.Errorf("history after File > New: %q", m.line.Text())280+ }281+}282+283+// :w! writes over a file changed on disk since it was opened, and over a284+// file of the name given, without asking.285+func TestVimWriteForced(t *testing.T) {286+ dir := t.TempDir()287+ m := vimModel(t)288+ name := filepath.Join(dir, "book.012")289+ press(t, m, ":w "+name, "<enter>")290+ if m.filename != name {291+ t.Fatalf(":w: %q", m.filename)292+ }293+ // Another program writes the file.294+ later := time.Now().Add(time.Minute)295+ if err := os.WriteFile(name, []byte("{}"), 0o644); err != nil {296+ t.Fatal(err)297+ }298+ os.Chtimes(name, later, later)299+ press(t, m, "x", ":w", "<enter>")300+ if !strings.Contains(line(m, contextLine), "changed on disk") {301+ t.Fatalf(":w after a change on disk: %q", line(m, contextLine))302+ }303+ press(t, m, "<esc>", ":w!", "<enter>")304+ if data, _ := os.ReadFile(name); string(data) == "{}" || m.changed {305+ t.Fatalf(":w! didn't write: changed %v", m.changed)306+ }307+ other := filepath.Join(dir, "other.012")308+ os.WriteFile(other, []byte("{}"), 0o644)309+ press(t, m, ":w "+other, "<enter>")310+ if !strings.Contains(line(m, contextLine), "exists") {311+ t.Fatalf(":w other: %q", line(m, contextLine))312+ }313+ press(t, m, "<esc>", ":w! "+other, "<enter>")314+ if data, _ := os.ReadFile(other); string(data) == "{}" || m.filename != other {315+ t.Errorf(":w! other: %q, filename %q", data, m.filename)316+ }317+ csv := filepath.Join(dir, "out.csv")318+ os.WriteFile(csv, []byte("old"), 0o644)319+ press(t, m, ":w! "+csv, "<enter>")320+ if data, _ := os.ReadFile(csv); !strings.Contains(string(data), "B1") {321+ t.Errorf(":w! out.csv: %q", data)322+ }323+}@@ -0,0 +1,104 @@
1+package ui2+3+import (4+ tea "charm.land/bubbletea/v2"5+6+ "github.com/FelineStateMachine/012/internal/sheet"7+)8+9+// Vim registers. The unnamed register is the clipboard Ctrl+V pastes, so10+// y, d and x in vim keys and Ctrl+C share it. A register named with "11+// before an operator ("ayy, "bx, "ap) holds a copy of its own:12+//13+// - "a to "z: named, kept until written again;14+// - "0: the last copy made without naming a register;15+// - "1 to "9: the last rows deleted (dd, cc, V d), newest in "1;16+// - "-: the last cells deleted (x, s, v d);17+// - "+: the system clipboard, written with OSC 52 as every copy is,18+// and read back from the terminal for "+p;19+// - "": the unnamed register itself.20+//21+// Registers live with the workbook, as the clipboard does.22+23+// vimRegister is what a vim register holds: cells, or whole rows that24+// paste as new rows.25+type vimRegister struct {26+ clip *sheet.Clip27+ sheet *sheet.Sheet // where it was copied from28+ rows bool29+}30+31+// isRegister reports whether r names a register.32+func isRegister(r rune) bool {33+ return r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '"' || r == '+'34+}35+36+// storeRegister puts what an operator just copied (the clipboard) in the37+// register it named, or when it named none, in "0 for a copy, "1 for38+// deleted rows (moving "1 to "8 along to "2 to "9) and "- for deleted39+// cells.40+func (m *Model) storeRegister(deleted bool) {41+ v := &m.vim42+ r := vimRegister{clip: m.copied.clip, sheet: m.copied.sheet, rows: m.rowsCopied()}43+ if v.regs == nil {44+ v.regs = map[rune]vimRegister{}45+ }46+ switch name := v.reg; {47+ case name >= 'a' && name <= 'z', name >= '0' && name <= '9', name == '-':48+ v.regs[name] = r49+ case name == '+' || name == '"':50+ // The clipboard and the system clipboard have it already.51+ case !deleted:52+ v.regs['0'] = r53+ case r.rows:54+ for i := '9'; i > '1'; i-- {55+ v.regs[i] = v.regs[i-1]56+ }57+ v.regs['1'] = r58+ default:59+ v.regs['-'] = r60+ }61+}62+63+// fromRegister runs put, a paste of the clipboard, with the register64+// named with " in the clipboard's place, leaving the clipboard as it was.65+// "+ asks the terminal for the system clipboard, which is pasted when it66+// answers (pasteClipboard).67+func (m *Model) fromRegister(put func() tea.Cmd) tea.Cmd {68+ name := m.vim.reg69+ switch name {70+ case 0, '"':71+ return put()72+ case '+':73+ m.vim.clipPaste = true74+ return tea.ReadClipboard75+ }76+ r, ok := m.vim.regs[name]77+ if !ok || r.clip == nil {78+ m.note = `Register "` + string(name) + ` is empty`79+ return nil80+ }81+ saved, savedRows := m.copied, m.vim.rows82+ m.copied = clipboard{clip: r.clip, sheet: r.sheet}83+ m.vim.rows = nil84+ if r.rows {85+ m.vim.rows = r.clip86+ }87+ cmd := put()88+ m.copied, m.vim.rows = saved, savedRows89+ return cmd90+}91+92+// pasteClipboard pastes the system clipboard's text, read for "+p, at93+// the active cell: a block of cells for tab-separated or multi-line94+// text, as a terminal paste, or one value entered in the cell.95+func (m *Model) pasteClipboard(text string) {96+ m.vim.clipPaste = false97+ if m.mode != modeReady || text == "" {98+ return99+ }100+ m.handlePaste(text)101+ if m.mode == modeEnter {102+ m.commit()103+ }104+}@@ -0,0 +1,87 @@
1+package ui2+3+import (4+ tea "charm.land/bubbletea/v2"5+)6+7+// . repeats the last change made in NORMAL mode, as vim does: the keys8+// of the operator (x, dd, cc, s, p, o, i, a, = and the rest marked9+// change in vimkeys.go) with its count and register, and when it started10+// an entry, the keys typed into it up to the one that finished it. A11+// count before . replaces the change's own (3. after dd deletes three12+// rows).13+14+// The binding gets repeatChange here rather than in its table, which15+// repeatChange reads through the key handlers.16+func init() {17+ b := vimNormal["."]18+ b.do = repeatChange19+ vimNormal["."] = b20+}21+22+// vimChange is a change . can repeat.23+type vimChange struct {24+ keys []tea.Msg // key presses and pastes, after any count or register25+ count int26+ reg rune27+}28+29+// vimChanged remembers c as the change . repeats, once any entry it30+// started is finished.31+func (m *Model) vimChanged(c *vimChange) {32+ if m.editing() {33+ m.vim.inserting = c34+ return35+ }36+ m.vim.last = c37+}38+39+// vimCapture adds a key or paste to the entry a change started, before40+// the model handles it.41+func (m *Model) vimCapture(msg tea.Msg) {42+ v := &m.vim43+ if v.inserting == nil || v.replaying {44+ return45+ }46+ switch msg.(type) {47+ case tea.KeyPressMsg, tea.PasteMsg:48+ v.inserting.keys = append(v.inserting.keys, msg)49+ }50+}51+52+// vimSettle finishes the change taking an entry's keys once the entry53+// is accepted or cancelled.54+func (m *Model) vimSettle() {55+ if v := &m.vim; v.inserting != nil && !m.editing() {56+ v.last, v.inserting = v.inserting, nil57+ }58+}59+60+// repeatChange is .: it replays the last change's keys, with the count61+// typed before . when there is one.62+func repeatChange(m *Model, _ int) tea.Cmd {63+ v := &m.vim64+ c := v.last65+ if c == nil {66+ m.note = "No change to repeat yet"67+ return nil68+ }69+ count := c.count70+ if v.count > 0 {71+ count = v.count72+ }73+ v.reset()74+ v.count, v.reg = count, c.reg75+ v.replaying = true76+ defer func() { v.replaying = false }()77+ cmds := make([]tea.Cmd, 0, len(c.keys))78+ for _, msg := range c.keys {79+ switch msg := msg.(type) {80+ case tea.KeyPressMsg:81+ cmds = append(cmds, m.handleKey(msg))82+ case tea.PasteMsg:83+ m.handlePaste(msg.Content)84+ }85+ }86+ return tea.Batch(cmds...)87+}