cat / 012
clone

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

Merge main: shipped list, restructured files.md, demos

Dami committed 0428290
Arrays go in the roadmap's Shipped list as one line, and files.md's
Excel section gains Arrays in Excel and the rows for array formulas and
functions only Sheets has.

Co-Authored-By: Claude <noreply@anthropic.com>
commit
04282907989a6fb6b2ac98e6e73e810a60cef830
parent
55d028d78e5cf48833f9dc118c3a76512d88ad88
parent
943b6ef938e58f12f04a12ca15a9114ca19a424b
author
Dami <dami.etoile@gmail.com>
43 files changed, +1500 -465
M.gitignore1+
MCLAUDE.md10+-
MREADME.md114++++--------
MROADMAP.md214++++++++-------------
Mdemos/charts.tape11+-
Ademos/data/tasks.01227+++
Ademos/macros.tape54+++++
Ademos/pivot.tape49+++++
Ademos/rules.tape51+++++
Ademos/themes.tape31+++
Ademos/vim.tape45+++++
Mdocs/README.md4+-
Mdocs/UX.md5+-
Mdocs/architecture.md8+-
Mdocs/config.md21++
Mdocs/data.md21++-
Mdocs/extending.md2+-
Mdocs/files.md299++++++++++++++++++------------
Mdocs/formulas.md2+
Mdocs/keys.md252++++++++++++++++---------
Mdocs/macros.md2+
Mdocs/media/charts.gifbin
Adocs/media/decimal-on.pngbin
Mdocs/media/find-replace.gifbin
Mdocs/media/first-steps.gifbin
Mdocs/media/freeze-sort-filter.gifbin
Adocs/media/import-picker.pngbin
Mdocs/media/jev.gifbin
Adocs/media/macros.gifbin
Mdocs/media/menus-palette.gifbin
Adocs/media/pivot.gifbin
Adocs/media/rules.gifbin
Adocs/media/selection-stats.pngbin
Adocs/media/theme-classic.pngbin
Adocs/media/vim.gifbin
Mdocs/testing.md61++++--
Mdocs/themes.md2+
Minternal/config/docs.go26+++
Ainternal/config/docs_test.go92+++++++++
Ainternal/sheet/file_doc_test.go56++++++
Ainternal/ui/docs_test.go210+++++++++++++++++++++
Ascripts/doccheck/main.go271+++++++++++++++++++++++++++
Mscripts/lint.sh24++
M .gitignore +1 -0
@@ -7,3 +7,4 @@ e2e/testdata/screens/gallery.html
77 .env88 .env.*99 demos/out/10+*.test
M CLAUDE.md +7 -3
@@ -8,8 +8,8 @@ Read [docs/UX.md](docs/UX.md), [docs/extending.md](docs/extending.md) and
88 Noise that stays becomes invisible, and then real problems hide in it.99 1010 - `make check` passes before every push: gofmt, vet, `make lint`11- (staticcheck in every module, doclint, cognitive complexity at most 25,12- Go files at most 500 lines), unit tests, the excelize oracle and the11+ (staticcheck in every module, doclint, doccheck, cognitive complexity12+ at most 25, Go files at most 500 lines), unit tests, the excelize oracle and the1313 libghostty e2e tests. A red check is fixed first, not worked around.1414 - A warning is fixed where it points, or turned off in its tool's config1515 with the reason written next to it (`staticcheck.conf`). Nothing is left
@@ -19,5 +19,9 @@ Noise that stays becomes invisible, and then real problems hide in it.
1919 or merge, who did it) belongs in commit messages; doclint enforces this.2020 Saying why the code stays compatible with a planned change is fine: that2121 explains the code today. Plans go in ROADMAP.md.22-- No scratch files, debug tests or temporary samples in commits.22+- Each doc has one job and one home per fact: change the section a23+ reader would look in, link to it from elsewhere, and restructure when24+ appending would make a wall of text. Shipped roadmap items move to its25+ Shipped list as one line.26+- No scratch files, debug tests, temporary samples or build outputs (`*.test`, binaries) in commits; `make lint` refuses compiled files and anything over 2 MB.2327 - Visual changes are reviewed in the golden gallery (`make screens`).
M README.md +37 -77
@@ -29,97 +29,57 @@ Requires Go 1.27. Or from a clone: `make build` puts the binary in `bin/012`.
2929 F1 shows every shortcut, F10 or Alt+letter opens the menus, and Ctrl+K3030 searches every command. `012 config edit` opens the settings file.3131 32-## What it does33-34-- **Sheets-style editing.** Formulas with 120 Sheets-compatible functions,35- autocomplete and argument hints, pointing at cells with the arrows or the36- mouse, named ranges, undo and redo for everything, copy and paste with37- reference adjustment and the system clipboard, fill series, insert and38- delete rows and columns.39-- **Formats and styles.** Currency, percent, dates and times detected as you40- type; number formats, bold, italic, underline, strikethrough, alignment.41-- **Several sheets.** Sheet tabs on the status line, references between42- sheets (`=Sheet2!A1`) that follow renames, pointing into another sheet43- while typing a formula, hiding sheets, and Sheets' keys for moving44- between them.45-- **Data tools.** Freeze rows and columns, multi-column sort, filters with46- value pickers and conditions, find and replace with regular expressions,47- tracing precedents and dependents, live pivot tables and frequency48- tables on sheets of their own.49-- **Charts.** Column, bar, line, area, pie and scatter charts (stacked,50- with trend lines, log and fixed axes) that float over the grid and51- update live; real images in terminals with the kitty graphics protocol52- (kitty, Ghostty, WezTerm), text elsewhere.53-- **Files.** A diff-friendly JSON format, plus import from CSV, TSV, XLSX,54- SQLite, Parquet and Lotus 1-2-3 `.wk1`, and export to CSV, TSV, XLSX and55- SQLite.56-- **Macros.** Record what you do with absolute or relative references and57- replay it with Ctrl+Alt+Shift+digit, as one undo step; macros are readable58- Starlark scripts saved in the file, with step limits and no file or59- network access ([docs](docs/macros.md)).60-- **JEV functions.** `JEV.TEST`, `JEV.PROB`, `JEV.CLASSIFY` and `JEV.SCORE`61- ask TypeSafe's hosted JEV model about your data, answered in the background62- and cached ([docs](docs/jev.md)).63-- **Made for terminals.** Mouse with hover and resize handles, hyperlinks,64- light and dark themes that follow the terminal, desktop notifications,65- menus and a command palette styled like terminal software, not a GUI,66- and optional vim keys (hjkl, counts, dd/yy/p, visual selection, `:`67- commands) in File > Settings.68-- **Themes and a config file.** Your terminal's colors by default, or any69- of 349 color schemes (or your own Ghostty theme) with solid menu and70- status bars; one Ghostty-style config file for every setting, and the71- JEV API key kept in the OS keychain ([config](docs/config.md),72- [themes](docs/themes.md)).73-74-## Demos32+## A short tour33+34+- **Formulas as in Sheets**, with [Sheets' functions](docs/functions.md),35+ suggestions and argument hints, pointing at cells with the arrows or the36+ mouse, named ranges, references between sheets, optional decimal37+ arithmetic for money, and undo for everything38+ ([formulas](docs/formulas.md)).39+- **Data tools**: freeze, sort, filter, find and replace, conditional40+ formatting, dropdowns and checkboxes, notes, protected ranges, and live41+ pivot tables ([working with data](docs/data.md)).42+- **Charts** that float over the grid and follow their data, drawn as real43+ images in kitty, Ghostty and WezTerm and as text elsewhere44+ ([charts](docs/charts.md)).45+- **Files**: a diff-friendly JSON format, import from CSV, TSV, XLSX,46+ SQLite, Parquet and Lotus 1-2-3, export to CSV, TSV, XLSX and SQLite47+ ([files](docs/files.md)).48+- **Macros**, recorded or written as Starlark scripts saved with the sheet49+ ([macros](docs/macros.md)).50+- **Made for terminals**: the mouse, hyperlinks, the system clipboard over51+ SSH, your terminal's colors or any of hundreds of schemes, optional vim52+ keys, and `012 serve` to reach your sheets over SSH53+ ([keys](docs/keys.md), [themes](docs/themes.md), [SSH](docs/ssh.md)).54+- **JEV functions** ask TypeSafe's hosted model about your data from a55+ formula ([JEV](docs/jev.md)).7556 7657 | | |7758 |---|---|7859 | ![Menus and the command palette](docs/media/menus-palette.gif) | ![Inserting a chart that follows its data](docs/media/charts.gif) |7960 | Menus and the command palette (Ctrl+K) | Charts that float over the grid and follow their data |61+| ![A task list with a color scale, a dropdown and checkboxes](docs/media/rules.gif) | ![A pivot table of sales by region and quarter](docs/media/pivot.gif) |62+| Conditional formatting and data validation | Pivot tables, live |8063 | ![Freezing, sorting and filtering](docs/media/freeze-sort-filter.gif) | ![Find and replace](docs/media/find-replace.gif) |8164 | Freeze, sort and filter | Find and replace |8265 | ![JEV functions classifying reviews](docs/media/jev.gif) | ![Currency formats and totals](docs/media/formats-budget.png) |8366 | JEV functions in formulas | Formats detected as you type |8467 8568 The recordings are [VHS](https://github.com/charmbracelet/vhs) tapes in86-[`demos/`](demos); `make demos` renders them in a terminal using the87-Catppuccin Mocha palette, which 012 follows by default ([themes](docs/themes.md)).88-Charts show as text here; in kitty, Ghostty and WezTerm they are real images.89-90-## Documentation91-92-- [Keys and mouse](docs/keys.md)93-- [Entries and formulas](docs/formulas.md) and [functions](docs/functions.md)94-- [Working with data](docs/data.md)95-- [Charts, links and the terminal](docs/charts.md)96-- [Files](docs/files.md)97-- [JEV functions](docs/jev.md)98-- [Serving over SSH](docs/ssh.md): `012 serve`, public-key only, confined to one directory99-- [Configuration](docs/config.md) and [themes](docs/themes.md)100-- [Macros](docs/macros.md): recording, running, and the scripting API101-- [Architecture](docs/architecture.md), [UX bar](docs/UX.md) and [testing](docs/testing.md)102-- [Limits](docs/limits.md): how big a sheet 012 handles and where it slows down103-- [Observability](docs/observability.md): event logs, DuckDB, and a local Collector, ClickHouse and Grafana stack104-- [Roadmap](ROADMAP.md)105-106-## Development107-108-```sh109-make build # pure Go, CGO_ENABLED=0110-make test # unit tests111-make e2e # the real binary in libghostty, Ghostty's terminal core (needs Zig 0.16+ and pkg-config)112-make screens # golden screens and the review gallery113-make oracle # formulas and formats against excelize114-make stress # benchmarks on synthetic and real data; make stress-report compares runs115-```69+[`demos/`](demos), rendered by `make demos` in the Catppuccin Mocha70+palette; 012 draws in your terminal's own colors by default71+([themes](docs/themes.md)). Charts show as text here.11672 117-See [docs/testing.md](docs/testing.md).73+## More11874 119-## Status75+- [Documentation](docs/README.md): every guide, for using 012 and for76+ working on it77+- [Roadmap](ROADMAP.md)78+- Working on 012: `make build`, then `make check` before a push; see79+ [testing](docs/testing.md) and [CLAUDE.md](CLAUDE.md)12080 121-Young and moving quickly. The file format is versioned and older files keep122-loading; the Go packages are internal and may change at any time.81+Young and moving quickly. The file format is versioned and older files82+keep loading; the Go packages are internal and may change at any time.12383 12484 ## License12585
M ROADMAP.md +82 -132
@@ -1,172 +1,122 @@
1-# Roadmap: past 1-2-31+# Roadmap22 33 Principles: inside the grid 012 behaves like Google Sheets; 1-2-3 is the4-visual identity around it. The main binary stays pure Go (`CGO_ENABLED=0`), and every feature ships with unit5-tests, a libghostty e2e test and reviewed golden screens. Visual and UX6-quality keep pace with features: see [docs/UX.md](docs/UX.md).4+visual identity around it. The main binary stays pure Go5+(`CGO_ENABLED=0`), and every feature ships with unit tests, a libghostty6+e2e test and reviewed golden screens. Visual and UX quality keep pace7+with features: see [docs/UX.md](docs/UX.md).78 8-## Goals9-10-| Phase | Goal | Done when |11-|---|---|---|12-| 1 | The engine does what Sheets users reach for daily, and every change can be undone | Undo/redo (Ctrl+Z/Ctrl+Y) covers every mutation; copy, cut and paste (Ctrl+C/X/V, paste values) and insert/delete rows and columns adjust relative and `$absolute` references; number formats (Format menu, auto-detected `$` and `%` on entry) and named ranges work and persist; Sheets' everyday functions, dates and finance functions match excelize on a differential test suite |13-| 2 | Finding and using features is faster than in any terminal spreadsheet | A Sheets-structured menu bar (File Edit View Insert Format Data Help) and a searchable command palette reach every command with its key shown; formulas autocomplete with signature hints; precedents and dependents are visible and jumpable; rows and columns freeze; find/replace and filter work; fill down (Ctrl+D) and series fill work; an optional vim keymap exists |14-| 3 | 012 uses the terminal it's running in to the fullest, and degrades gracefully | Copy and paste ranges through the system clipboard; charts render as images in Ghostty/kitty and as text elsewhere; URLs are clickable; error cells are marked beyond color; cursor shape reflects the mode; the theme follows light and dark |15-| 4 | Data moves in and out of 012 without friction or cgo | CSV/TSV, XLSX, SQLite, Parquet and Lotus .wk1 import, with export where it makes sense; large imports show progress; the binary still builds with `CGO_ENABLED=0` |16-| 5 | 012 goes where 1-2-3 couldn't | Multiple sheets with cross-sheet references; derived frequency and pivot sheets; recorded and Starlark macros; an SSH server mode; opt-in decimal arithmetic; VHS demo tapes |17-18-## Phase 1: engine foundations (unblocks everything else)19-20-| Item | Why | Size |21-|---|---|---|22-| Command log: every mutation is a command with an inverse | Multi-level undo/redo, macro recording, later sync | M |23-| /Copy and /Move with relative and `$absolute` ref adjustment | 1-2-3 parity; `$` is parsed today but ignored | M |24-| Insert/Delete rows and columns with reference rewriting | Parity | M |25-| /Range Format: Fixed, Currency, Percent, Comma, Date | Parity, and the biggest visual gap today | S |26-| Range names (`@SUM(SALES)`) | Parity | S |27-| @DATE, @NOW, @PMT, @NPV, @IRR, @VLOOKUP | Lotus serial dates (with the 1900 leap-day quirk) done in-house | M |28-| Differential tests against `excelize` `CalcCellValue` | Test oracle: translate our formulas to Excel syntax, compare results | S |29-30-## Phase 2: modern UX31-32-| Item | Source of the idea | Building block | Size |33-|---|---|---|---|34-| Command palette (Ctrl+P), shows each command's key | VisiData, Quadratic, MS Edit | lipgloss v2 `Compositor` layers, `sahilm/fuzzy` | S-M |35-| Formula autocomplete and signature hint while typing `@SU` | Excel, Sheets | third panel line, function table | M |36-| Live SUM/AVG/COUNT of the pointed range in the status line | Excel | engine `aggregate` | S |37-| Trace precedents/dependents: highlight, `[` and `]` to jump | Excel | dependency graph already exists | M |38-| One-key frozen titles | csvlens, sc-im, 1-2-3 /Worksheet Titles | viewport split | S-M |39-| Search (`n`/`N`) and non-destructive row filter | csvlens | | M |40-| Fill down / fill series (1,2,3, Jan, Feb) | Excel, Sheets | needs Copy ref adjustment | S-M |41-| Optional vim keymap (`hjkl`, counts, `:` commands). Done: File > Settings > Vim keys, [keys.md](docs/keys.md#vim-keys) | sc-im, csvlens | tables of command bindings, the command registry for `:` | M |42-| Frequency table of a column as a derived sheet | VisiData Shift+F | a preset pivot table | M, done: Data > Frequency table, Alt+Shift+F |43-44-## Phase 3: terminal-native features45-46-| Item | API | Notes |47-|---|---|---|48-| Copy range as TSV to the system clipboard | `tea.SetClipboard` (OSC 52) | Works over SSH |49-| Paste TSV/CSV into a range | `tea.PasteMsg` (bracketed paste, already on) | |50-| Text charts in a popup (/Graph) | `ntcharts/v2` | Works everywhere |51-| Real image charts | x/ansi `kitty` Unicode placeholders + `tea.Raw` | Ghostty/kitty; survives the diff renderer and tmux. Sixel fallback redrawn on resize |52-| Clickable URLs in cells | lipgloss `Style.Hyperlink` (OSC 8) | |53-| Red undercurl on ERR cells | `UnderlineStyle(UnderlineCurly).UnderlineColor` | |54-| Bar cursor in EDIT, block elsewhere | `View.Cursor.Shape` | |55-| Light/dark palette | `tea.RequestBackgroundColor` | |56-| Progress bar and notification for long imports | `View.ProgressBar`, `ansi.Notify` | |57-| Hold-to-preview, shift+enter | `View.KeyboardEnhancements` | kitty protocol terminals |58-59-Bubble Tea v2 already turns on synchronized output (2026) and grapheme width60-(2027) when the terminal supports them.61-62-## Phase 4: data in and out (all pure Go)63-64-| Format | Library |65-|---|---|66-| CSV/TSV | stdlib `encoding/csv` plus a small dialect sniffer |67-| XLSX | `github.com/xuri/excelize/v2` |68-| SQLite | `modernc.org/sqlite` |69-| Parquet | `github.com/parquet-go/parquet-go` |70-| Lotus .wk1 | in-house (no Go reader exists), from the LibreOffice and Gnumeric filters |71-72-Avoid in the main binary: DuckDB and automerge (both need cgo).73-74-## Phase 5: beyond75-76-- Multiple sheets with references between them, in Sheets' style (`Sheet2!A1`) rather than 1-2-3 R3's `B:A1`, and sheet tabs. Done.77-- Pivot tables as derived sheets. Done: Data > Pivot table and its editor, live results the engine owns, saved as their definition (file version 5), values in XLSX; see [docs/data.md](docs/data.md#pivot-tables).78-- Macros: replay the command log; Starlark (`go.starlark.net`) for scripts, with step limits. Done: Data > Macros records (absolute or relative references), saves, runs (Ctrl+Alt+Shift+digit, the palette) and manages macros, stored as Starlark in the file; see [docs/macros.md](docs/macros.md).79-- Serve over SSH with `charm.land/wish/v2`. Done: `012 serve [dir]`, a 012 per session confined to the directory, public-key auth only ([docs/ssh.md](docs/ssh.md)). Not shared editing: see below.80-- Decimal mode for currency (`cockroachdb/apd/v3`), opt-in. Done: File > Settings > Decimal arithmetic, boundary in the README.81-- Demos with VHS tapes. Done: `make demos` renders `demos/` locally and the README shows them. Not run in CI: the GitHub repo is hosting only.82-83-## Next: vast data84-85-The grid is Excel's, 1,048,576 x 16,384, since step A; memory is bounded86-by the `max-cells` budget rather than the grid. Raise that budget in87-measured steps (see [docs/limits.md](docs/limits.md)):88-89-| Step | What | Result |90-|---|---|---|91-| A | Make every operation cost what the data costs, not the grid: clip whole-column and whole-row ranges to the used area, sparse range reads for aggregates, column and row formats instead of per-cell formatting, a range index sized to the used columns, three-letter columns (to XFD), wider row headers. Then raise the grid to 1,048,576 x 16,384 with a `max-cells` budget in the config file, and a stress test that fails when any command on an empty full-grid selection costs more than its data. **Done**: occupancy indexes beside the cell map, sparse and clipped range reads, running aggregates shared per recalculation and interval trees of range users (1000 full-column SUMs: 174 ms to 0.5 ms per edit; 8192 running totals: 0.73 s to 2.6 ms), line formats for columns, rows and the sheet, `A:C` and `2:5` references, `max-cells` for imports and pastes, and `TestCommandsCostTheDataNotTheGrid` | Excel-sized grids, memory still bounded |92-| B | Compact column storage behind `cellStore`: typed value blocks, formulas and formats in side tables (about 20 to 40 B per cell instead of 300) | A budget of 10 M+ cells in a few hundred MB |93-| C | A streaming or binary file format next to the readable JSON one | Open and save scale with the data |94-| Later | Linked, paged read-only ranges over Parquet and SQLite that feed pivots and formulas by streaming | Sources too big for any grid |9+This page is what's ahead. What has shipped is listed once, at the end,10+with the doc that describes it; when an item ships, it moves there as11+one line.9512 9613 ## Ahead9714 98-Collected after phase 5 from the docs, the limits, and what each piece of99-work left open. Sizes: S (a day or two), M (about a week), L (weeks).15+Sizes: S (a day or two), M (about a week), L (weeks).10016 101-### 1. Finish and harden what shipped17+### 1. Harden what shipped10218 10319 | Item | Why | Size |10420 |---|---|---|105-| Open 012's XLSX output in a real spreadsheet app before releases. Done for Google Sheets: sheets, hidden sheets, named ranges, implicit intersection, column and cell formats, widths, frozen panes, filters, links and missing-sheet values all match; repeat with Excel and LibreOffice when available | The writer is otherwise checked by excelize and 012's reader | S |106-| Column and row formats travel with copy, cut and move; formulas reading blanks of a newly formatted column re-infer at once. Done: pastes and moves carry what cells show, whole lines their line formats; see [data.md](docs/data.md#copy-paste-and-fill) | Left open by step A | S |107-| Save as onto another existing file asks before replacing (local and `012 serve`). Done: Save as, `:w name` and `:wq name` ask on the context line ([files.md](docs/files.md)) | Replacing a file by mistake loses it | S |108-| XLSX sheet names Excel can't take: formulas and named ranges that name a renamed sheet use the written name. Done ([files.md](docs/files.md)) | They'd otherwise go out as values | S |109-| Replace current sheet keeps its charts; Insert new sheet(s) goes after the current tab. Done: charts of a whole table are re-pointed to the new one when it has as many series, and the note says which; see [files.md](docs/files.md) | Import location details | S |110-| Precedent tracing explains when every precedent is on a hidden sheet. Done, for dependents too | A generic "no formula" hides why nothing shows | S |111-| Settings > JEV API key checks the key with a test call. Done: one fixed question, "Key saved and checked" or why the check failed ([jev.md](docs/jev.md#setup)) | A mistyped key otherwise shows only as `#ERROR!` in cells | S |112-| `012 serve`: open a file from the ssh command line, and save unsaved work to a recovery file on shutdown or idle timeout. Done: `ssh -t host file.012` (a one-word exec request taken as a file name inside the served directory, never run) and `.012-recovery/`, offered back on the next open ([ssh.md](docs/ssh.md#unsaved-work)) | Sessions reach a file in one step, and stopping the server or idling out keeps work | M |113-| Vim `p`/`P` after `yy` carries the rows' line formats, as a whole-row paste does. Done: `yy` and `dd` copy whole rows ([data.md](docs/data.md#copy-paste-and-fill)) | Only cell formats go with it | S |114-| Cutting a block clears the source cells to plain, as Sheets does. Done, over formatted columns, rows and the sheet ([data.md](docs/data.md#copy-paste-and-fill)) | They keep showing their column or row format | S |115-| Replace current sheet removes charts whose range no longer fits the new data, or asks. Done: removed, listed first in the note, and back on undo ([files.md](docs/files.md)) | They stay, pointing at empty cells, with a note | S |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 |22+| Conditional formatting and validation, what's left: data bars and icon sets, "top 10", "duplicate values", date periods (this week, last month), validation on pastes and fills, rules moving with cut and paste, a dropdown's chip look, and custom checkbox values | Excel's other rule types come in as notes | M |23+| Remaining overlays get narrow hosts: filter picker, sort and choice bars, chart editor, shortcuts, named ranges, cell entry and prompts | Components testable without the model ([architecture.md](docs/architecture.md#the-ui)) | M |11624 11725 ### 2. Spreadsheet features Sheets users reach for11826 11927 | Item | Notes | Size |12028 |---|---|---|121-| Dynamic arrays: FILTER, SORT, UNIQUE, SEQUENCE, spill ranges, `#SPILL!`; then LET and LAMBDA. Done: arrays in array contexts and ARRAYFORMULA, spills owned by their formula (`#REF!` with Sheets' explanation when blocked), FILTER, SORT, SORTN, UNIQUE, SEQUENCE, TRANSPOSE, FLATTEN, CHOOSECOLS and CHOOSEROWS, LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY, and XLSX's dynamic array formulas; see [formulas.md](docs/formulas.md#arrays-and-spills) | The biggest gap in the function library; spills need engine support for ranges a formula owns | L |122-| Text and regex: TEXTJOIN, SPLIT, REGEXMATCH, REGEXEXTRACT, REGEXREPLACE. Done, on RE2 as Sheets ([formulas.md](docs/formulas.md#regular-expressions)) | Sheets staples | S |123-| Conditional formatting (color scales, rules on values and formulas), drawn in theme roles. Done: single-color rules on Sheets' conditions and custom formulas, 2 and 3 point color scales, in named colors on the ANSI slots with readable ink checked under every scheme, a rules panel, and XLSX both ways; see [data.md](docs/data.md#conditional-formatting) | Visual; must read under all 349 schemes | M |124-| Data validation: dropdown lists (a picker in the cell), number and date rules, checkboxes. Done: dropdowns from items or a range (Alt+Down or a click), checkboxes (Space or a click), number, date and text length bounds, custom formulas, reject or warn with Sheets' help text, and XLSX both ways; see [data.md](docs/data.md#data-validation) | Pairs with the filter picker | M |125-| Conditional formatting and validation, what's left: data bars and icon sets, "top 10", "duplicate values", date periods (this week, last month), validation on pastes and fills, rules moving with cut and paste, a dropdown's chip look, and custom checkbox values | Excel's other rule types come in as notes today | M |12629 | Wrap text, row heights, borders, merged cells | Layout changes in the grid renderer | M to L |127-| Notes on cells (shown on hover and in the context line). Done: Insert > Note (Shift+F2), a corner mark, the context line and a hover box, saved and in XLSX as Excel's notes; see [data.md](docs/data.md#notes) | | S |12830 | Locale: decimal comma, date order, list separator in formulas | Sheets' File > Settings > Locale | M |129-| Charts: scatter, area, stacked columns and bars, axis options, a legend position. Done: area and scatter (with trend lines) types, stacked and 100% stacked columns, bars and areas, value axis minimum, maximum and log scale, gridlines on and off, the legend at the bottom, right or none; see [charts.md](docs/charts.md) | Registry entries in `internal/chart` | M |130-| Pivot tables: column subtotals, renaming value columns, check the "(blank)" label against Sheets. Done: a subtotal column per outer column group, R renames a value in the editor, and blank groups have a blank label as in Sheets ([data.md](docs/data.md#pivot-tables)) | Left open by pivots | S |131-| Protected ranges and sheets (warn on edit). Done: Data > Protect sheets and ranges, edits ask on the context line; an XLSX sheet's protection is noted on import; see [data.md](docs/data.md#protected-sheets-and-ranges) | | S |13231 133-### 3. Scale (see "Next: vast data" above and docs/limits.md)32+### 3. Scale33+34+The grid is Excel's, 1,048,576 x 16,384, and memory is bounded by the35+`max-cells` budget rather than the grid. The budget rises in measured36+steps; see [docs/limits.md](docs/limits.md#what-would-raise-the-bounds).13437 13538 | Item | Result | Size |13639 |---|---|---|137-| Step B: compact column storage behind `cellStore` | 10 M+ cells in a few hundred MB, cell reads 2 to 3 ns | L |138-| Step C: streaming and compact `.012` format | Open and save scale with the data | M |40+| Compact column storage behind `cellStore`: typed value blocks, formulas and formats in side tables (about 20 to 40 B per cell instead of 300) | A budget of 10 M+ cells in a few hundred MB, cell reads 2 to 3 ns | L |41+| A streaming or binary `.012` format next to the readable JSON one | Open and save scale with the data | M |13942 | Smaller undo steps: formatting changes as diffs | More history in the same memory | S |140-| Linked, paged read-only ranges over Parquet and SQLite feeding pivots and formulas | Sources too big for any grid | L |43+| Linked, paged read-only ranges over Parquet and SQLite that feed pivots and formulas by streaming | Sources too big for any grid | L |14144 142-### 4. Macros and keys45+### 4. Macros, keys and the terminal14346 14447 | Item | Size |14548 |---|---|14649 | Record dialog choices (sort bar, filter picker, find and replace, chart editor) and chart drags; let scripts run commands that open dialogs, with answers | M |147-| Vim: `.` repeat, registers, marks, `cc`/`s`, command-line history, `:w!`, `:wq file` quitting | M |148-| Remaining overlays get narrow hosts: filter picker, sort and choice bars, chart editor, shortcuts, named ranges, cell entry and prompts | M |50+| Vim: `.` repeat, registers, marks, `cc`/`s`, command-line history, `:w!` | M |51+| Hold-to-preview and Shift+Enter on terminals with the kitty keyboard protocol (`View.KeyboardEnhancements`) | S |52+| Sixel chart images, redrawn on resize, for terminals without kitty graphics | M |14953 15054 ### 5. Distribution and upkeep15155 15256 | Item | Size |15357 |---|---|154-| Version tags and release notes; prebuilt binaries attached to tags (built locally, since the repo is hosting only). Tags done from v0.1.0, with notes in the tag message | S |155-| `make check`: vet, lint, tests, oracle, e2e in one target, run before every push. Done | S |58+| Prebuilt binaries attached to version tags, built locally (the GitHub repo is hosting only); tags carry release notes in their message | S |15659 | `make stress-report` thresholds that flag regressions over a set percentage against the last release | S |15760 | Grafana: a trace panel for the nested spans | S |158-| Accessibility: a high-contrast theme, and every state readable without color (already a UX rule; audit it) | S |61+| Accessibility: a high-contrast theme, and an audit that every state reads without color (a [UX rule](docs/UX.md#visual-rules)) | S |15962 16063 ## Later: sharing a live sheet (shelved)16164 16265 Explored, not scheduled. `012 serve` gives each SSH session its own16366 spreadsheet; saving over a file another session saved asks first, and164-that is all the sessions know of each other. One session host that runs a Bubble Tea program165-for any byte stream with window-size events, fed by SSH166-(`charm.land/wish/v2`), iroh tickets (the Go transport in67+that is all the sessions know of each other. One session host that runs68+a Bubble Tea program for any byte stream with window-size events, fed by69+SSH (`charm.land/wish/v2`), iroh tickets (the Go transport in16770 `FelineStateMachine/allons` `local/transport/iroh`, which needs cgo and a168-prebuilt iroh-ffi archive, so it would sit behind a build tag), and the web169-(`NimbleMarkets/go-booba` serves Bubble Tea over WebSocket/WebTransport with170-ghostty-web; Bubble Tea v2 support unverified). Open questions: per-user171-sessions on one sheet versus mirroring one session, and whether the web page172-is served by the host or by a gateway dialing the iroh ticket.71+prebuilt iroh-ffi archive, so it would sit behind a build tag), and the72+web (`NimbleMarkets/go-booba` serves Bubble Tea over73+WebSocket/WebTransport with ghostty-web; Bubble Tea v2 support74+unverified). Open questions: per-user sessions on one sheet versus75+mirroring one session, and whether the web page is served by the host or76+by a gateway dialing the iroh ticket.77+78+## Shipped79+80+**Engine**81+82+- Undo and redo for every change, across sheets: [data.md](docs/data.md#undo)83+- Copy, cut, paste and fill with relative and `$absolute` references; insert and delete rows and columns: [data.md](docs/data.md#copy-paste-and-fill)84+- Entries detected as typed (currency, percent, dates, times) and number formats: [formulas.md](docs/formulas.md#what-you-type)85+- Named ranges; several sheets with references between them, hidden sheets: [formulas.md](docs/formulas.md#references)86+- Sheets' everyday, math, text, lookup, date and finance functions, checked against excelize: [functions.md](docs/functions.md), [testing.md](docs/testing.md#the-excelize-oracle)87+- Dynamic arrays that spill (FILTER, SORT, UNIQUE, SEQUENCE and more, ARRAYFORMULA), LET and LAMBDA, SPLIT and the REGEX functions: [formulas.md](docs/formulas.md#arrays-and-spills)88+- Opt-in decimal arithmetic for money: [formulas.md](docs/formulas.md#decimal-arithmetic)89+- An Excel-sized grid with a `max-cells` budget; operations cost the data, not the grid: [limits.md](docs/limits.md#sheet-size)90+91+**Finding and using features**92+93+- Sheets-structured menus, a command palette and a key for every command: [keys.md](docs/keys.md)94+- Formula suggestions, argument hints and pointing at cells and sheets: [formulas.md](docs/formulas.md#building-formulas)95+- Tracing precedents and dependents, hidden sheets explained: [formulas.md](docs/formulas.md#building-formulas)96+- An optional vim keymap with a `:` command line: [keys.md](docs/keys.md#vim-keys)97+98+**Data tools**99+100+- Freeze, multi-column sort, filters with value pickers and conditions: [data.md](docs/data.md#freeze)101+- Find and replace, with regular expressions, across sheets: [data.md](docs/data.md#find-and-replace)102+- Conditional formatting (single-color rules, color scales) and data validation (dropdowns, checkboxes, bounds): [data.md](docs/data.md#conditional-formatting)103+- Pivot tables and frequency tables, live, with subtotals and renamed values: [data.md](docs/data.md#pivot-tables)104+- Notes on cells: [data.md](docs/data.md#notes)105+- Protected sheets and ranges that warn on edit: [data.md](docs/data.md#protected-sheets-and-ranges)106+- Charts (column, bar, line, area, pie, scatter; stacking, trend lines, axis and legend options), as images or text: [charts.md](docs/charts.md)107+- Macros, recorded or written in Starlark: [macros.md](docs/macros.md)108+- JEV functions, with the API key in the OS keychain: [jev.md](docs/jev.md)109+110+**The terminal**111+112+- System clipboard, hyperlinks, curly error underlines, cursor shapes, progress and notifications, light and dark: [charts.md](docs/charts.md#terminal-features-012-uses)113+- Color schemes and a config file: [themes.md](docs/themes.md), [config.md](docs/config.md)114+- `012 serve` over SSH, with files on the ssh command line and recovery of unsaved work: [ssh.md](docs/ssh.md)115+116+**Files**117+118+- Import CSV, TSV, XLSX, SQLite, Parquet and Lotus `.wk1`; export CSV, TSV, XLSX and SQLite; import locations; save-as and overwrite checks: [files.md](docs/files.md)119+120+**Upkeep**121+122+- `make check` before every push; demo tapes (`make demos`); version tags from v0.1.0: [testing.md](docs/testing.md)
M demos/charts.tape +5 -6
A demos/data/tasks.012 +27 -0
A demos/macros.tape +54 -0
A demos/pivot.tape +49 -0
A demos/rules.tape +51 -0
A demos/themes.tape +31 -0
A demos/vim.tape +45 -0
M docs/README.md +2 -2
M docs/UX.md +4 -1
M docs/architecture.md +4 -4
M docs/config.md +21 -0
M docs/data.md +18 -3
@@ -9,6 +9,8 @@ select whole columns and rows. The active cell stays distinct from the rest
99 of the selection, and the status line shows Sum, Avg and Count, dropping the1010 ones that don't fit on a narrow terminal.1111 12+![A column selected, with its Sum, Avg and Count on the status line](media/selection-stats.png)13+1214 ## Copy, paste and fill1315 1416 Ctrl+C, Ctrl+X and Ctrl+V work as in Sheets: relative references shift,
@@ -54,6 +56,14 @@ right-click menus, or Ctrl+Alt+= and Ctrl+Alt+- (rows, or columns when whole
5456 columns are selected). Formulas, names, charts, filters and widths follow.5557 Drag a column header's edge to resize it, or use Format > Column width.5658 59+Formatting whole columns or rows (select them with Ctrl+Space or60+Shift+Space, or click their headers) keeps the format on the column or61+row, as Sheets does, rather than on each of their million cells: every62+cell of it shows the format unless it has its own, and a cell typed into63+later takes it. A cell's format comes from the cell, else its row, else64+its column, else the whole sheet's (Ctrl+A twice, then a format); the65+number format and the text style fall back separately.66+5767 ## Freeze5868 5969 View > Freeze keeps rows or columns on screen while the rest scrolls, marked
@@ -73,8 +83,9 @@ then text ignoring case, then booleans, then errors, with blanks last.
7383 7484 Data > Create a filter puts a filter on the selection or the table around7585 the active cell; headers show `▾`. Alt+Down or a click on `▾` opens the76-column's filter: check values in a searchable list, or pick a condition such77-as "greater than" or "text contains". Filtered-out rows are hidden, not86+column's filter: check values in a list (Space checks one, typing87+narrows the list), or pick a condition such as "greater than" or "text88+contains". Filtered-out rows are hidden, not7889 deleted: row numbers show the gap, navigation skips them, formulas still7990 count them, and the status line says how many rows are hidden.8091
@@ -88,6 +99,8 @@ Left/Right change a choice, Space flips a check, text lines are typed
8899 into, Enter saves and Esc goes back without saving. Every change is an89100 undo step.90101 102+![Checking a task off, picking an owner from a dropdown, a rejected entry, and the rules panel](media/rules.gif)103+91104 A rule applies to ranges (`A2:A100`, or several: `A2:A9,C2:C9`) and is92105 one of:93106
@@ -169,6 +182,8 @@ picking an item asks as typing does ([protection](#protected-sheets-and-ranges))
169182 170183 ## Pivot tables171184 185+![Building a pivot table of revenue by region and quarter, then changing its data](media/pivot.gif)186+172187 Data > Pivot table summarizes the selection, or the table around the173188 active cell, on a new sheet named `Pivot Table 1`, as Sheets' Insert >174189 Pivot table does. The data's first row names the fields. The pivot editor
@@ -241,7 +256,7 @@ changes it.
241256 Ctrl+F opens a find bar on the context line: matches highlight as you type,242257 the active cell follows the current one, and Enter and Shift+Enter step243258 through them. Ctrl+H adds a replacement field; Enter replaces and moves on,244-Ctrl+Enter replaces all as one undo step. Chips toggle match case (Alt+C),259+Ctrl+Enter or Alt+A replaces all as one undo step. Chips toggle match case (Alt+C),245260 whole cell (Alt+W), regular expressions (Alt+R, with `$1` in replacements),246261 searching formulas (Alt+=). The scope chip says where to search, as Sheets'247262 "Search" choice: this sheet, all sheets, or the range selected when the bar
M docs/extending.md +1 -1
@@ -18,7 +18,7 @@ shows it is derived from the table rather than listing it again.
1818 | `ui.command` | an action: id, title, description, run, enabled, checked, what it edits, and how macros treat it | key bindings (Sheets and vim), menu bar, context menus, palette, shortcuts help, the `:` command line and its completions, macro recording and `run()` in scripts |1919 | `fileio` formats (`formats.go`) | a format: name, extensions, labels, traits, importer, exporter | `Import`, `Export`, detection, import picker, File > Download, command line |2020 | `chart.types`, with `sheet.ChartTypes` | a type: name and order (sheet, saved in files), a layout drawing text and image and the series its legend lists (chart) | `chart.Draw`, `chart.Image`, chart editor, Insert > Chart |21-| theme roles (`theme.Theme`) | a role: dark and light styles on the 16 ANSI colors, with a contrast minimum for schemes (`minContrast`) | every style in the UI, drawn in the terminal's palette or any color scheme (`FromPalette`); `TestEveryThemeReadable` checks each role under all 349 schemes |21+| theme roles (`theme.Theme`) | a role: dark and light styles on the 16 ANSI colors, with a contrast minimum for schemes (`minContrast`) | every style in the UI, drawn in the terminal's palette or any color scheme (`FromPalette`); `TestEveryThemeReadable` checks each role under every built-in scheme |2222 | `config.Options` | an option: name, type, default, environment variables, flag, live or not, description, check | parsing and warnings, flags, `012 config` and its default file, [config.md](config.md), Reload config |2323 2424 Adding a function, command, format, chart type, option or role means
M docs/files.md +175 -124
@@ -1,51 +1,87 @@
11 # Files22 3-Spreadsheets save as `.012` files (JSON, one line per cell), with every4-sheet. Other formats come5-in through File > Import, File > Open or the command line, and go out6-through File > Download. Imports run in the background with a progress7-bar; Esc cancels. Saving an imported sheet asks whether to save it as a8-`.012` file or download it back in its format.3+Spreadsheets save as `.012` files: JSON with one line per cell, every4+sheet in one file. Other formats come in through File > Import, File >5+Open or the command line (`012 sales.xlsx`), and go out through File >6+Download.97 10-File > Import asks where the data goes, as Sheets' Import location does11-(a new, empty spreadsheet is simply replaced):8+![File > Import listing the spreadsheets in a folder](media/import-picker.png)129 13-| Location | Does |14-|---|---|15-| Insert new sheet(s) | Adds the file's sheets after the sheet shown: every sheet of an `.xlsx`, named after the file for other formats. A name already taken gets a number (`Sales 2`) and the file's formulas follow it; named ranges come along unless their name is taken. One undo step, and the spreadsheet stays the file you're editing |16-| Replace current sheet | Puts the data in place of the sheet shown, keeping its name and position, so formulas and named ranges that read it read the new data. Its charts that fit the new data stay: a chart that drew a whole table is re-pointed to the table the file has at the same corner when it has as many columns (rows for a chart by row), so last month's chart draws this month's rows, and one of part of a table, or of whole columns or rows, keeps its range. A chart whose table now has other columns, or whose range is empty now, is removed. The context line lists the removed charts first, then which were re-pointed and which kept their range. One undo step, which brings the removed charts back. Not offered for `.xlsx`, which holds several sheets |17-| Replace spreadsheet | Opens the file instead, as File > Open and the command line do, asking first when there are unsaved changes |10+## Import and download11+12+Imports run in the background with a progress bar; Esc cancels. Saving13+an imported sheet asks whether to save it as a `.012` file or download14+it back in its format.1815 1916 | Format | Import | Download |2017 |---|---|---|21-| CSV, TSV | Delimiter (`,` `;` tab `\|`), UTF-8 BOM, UTF-16 and Windows-1252 detected; entries become numbers, dates, currency and percentages as if typed; formulas stay text | Values as shown, as Sheets' Download does |22-| Excel `.xlsx` | Every sheet, opening on the one Excel showed: values, formulas (references between sheets too), workbook named ranges, number formats, bold, italic, underline, strikethrough, alignment, column widths, column and row styles, frozen panes, filters (see [Filters in XLSX](#filters-in-xlsx)), notes (Excel's notes, its legacy comments; threaded comments aren't read), shared formulas, dates in the 1904 system; sheets Excel hid stay hidden (unless it's the one Excel showed), conditional formatting and data validation (see [Rules in XLSX](#rules-in-xlsx)). Formulas 012 can't read (unknown functions) keep their values; Excel pivot tables come in as the values they showed. Array formulas (Excel 365's dynamic arrays and older `{=...}` ones) come in as formulas that spill again, without the values Excel kept in the cells they spilled into. A sheet Excel protects comes in unprotected, with a note saying so, since Excel's protection locks cells where 012's only warns. Files past the reader's limits (a zip bomb, 1 GB in one part, 2 GB in all, cells past XFD1048576) are refused with a message saying which | Every sheet, the same, with formulas in Excel's syntax and their results cached, column and row formats as column and row styles, frozen rows and columns as frozen panes, a filter as Excel's with the rows it hides hidden, and notes as Excel's notes (which Sheets reads as notes), and conditional formats and data validation as Excel's. Protected ranges aren't written. A sheet whose name Excel can't take as is (spaces at its ends, or the same as another's but for them and case) gets one it can (without the spaces, with a number when two would clash, e.g. `Plan (2)`), and the formulas and named ranges naming it name that. A formula that spills goes out as Excel 365's dynamic array formula, with the values it spilled in the cells, so Excel spills it the same and older Excels show the values; see [Arrays in XLSX](#arrays-in-xlsx). JEV functions and `#AND#` save as values, and so do formulas naming a sheet that doesn't exist (their `#REF!`; Excel would refuse the reference), functions only Sheets has (`SORTN`, `FLATTEN`, `SPLIT`, `REGEXMATCH`, `REGEXEXTRACT`), and pivot tables: Excel gets the results, not a pivot. The download's result counts the formulas saved as values, with an example |18+| CSV, TSV | Delimiter (`,` `;` tab `\|`), UTF-8 BOM, UTF-16 and Windows-1252 detected; entries become numbers, dates, currency and percentages as if typed; formulas stay text | Values as shown, of the sheet shown, as Sheets' Download does; a selection of whole columns or rows downloads their data, not a million blank lines |19+| Excel `.xlsx` | Every sheet, with formulas, formats and most of what a sheet holds: see [Excel files](#excel-files) | The same |2320 | SQLite | Pick a table or view, or type a query; a header row names the columns | The sheet shown or the selection as a table, first row as column names; a table of that name is replaced |2421 | Parquet | Every column, with dates and timestamps; lists joined with commas | |2522 | Lotus 1-2-3 `.wk1`, `.wks` | Numbers, labels with their alignment, formats, column widths, formulas translated (references, operators, `@SUM`, `@AVG`, `@IF`, `@ROUND`, `@PMT` and 60 more) or kept as values | |2623 27-## Arrays in XLSX24+Formats other than XLSX import as one sheet named after the file (or the25+SQLite table).2826 29-A formula whose array spills (see [formulas.md](formulas.md#arrays-and-spills))30-is written as Excel writes a dynamic array formula: an array formula over31-the cells it spills into (`<f t="array" ref="C1:C9">`) on a cell whose32-metadata marks it dynamic (`cm="1"`, defined in `xl/metadata.xml`), with33-the values in the cells below and right of it. Formulas calling array34-functions (`FILTER`, `SORT`, `UNIQUE`, `SEQUENCE`, `LET`, `LAMBDA` and the35-like) or holding an array literal are written that way even when they36-compute one value, so Excel doesn't put its implicit intersection (`@`)37-in front of them. Excel gets its own names for the functions newer than38-Excel 2007 (`_xlfn._xlws.FILTER`, `_xlfn.SEQUENCE`), `_xlpm.` before the39-names LET and LAMBDA bind, and the formula inside an `ARRAYFORMULA` around40-a whole formula, which a dynamic array formula computes over arrays41-anyway. What Excel can't hold goes out as the value it showed, counted in42-the download's note: functions only Sheets has (`SORTN`, `FLATTEN`,43-`SPLIT`, `REGEXMATCH`, and `REGEXEXTRACT`, whose Excel namesake returns44-the whole match where Sheets' returns the capture group), array literals45-holding references (Excel's hold only constants) and `ARRAYFORMULA`46-inside a formula.47-48-## Filters in XLSX27+File > Import asks where the data goes, as Sheets' Import location does28+(a new, empty spreadsheet is simply replaced):29+30+| Location | Does |31+|---|---|32+| Insert new sheet(s) | Adds the file's sheets after the sheet shown. A name already taken gets a number (`Sales 2`) and the file's formulas follow it; named ranges come along unless their name is taken. One undo step, and the spreadsheet stays the file you're editing |33+| Replace current sheet | Puts the data in place of the sheet shown, keeping its name and position, so formulas and named ranges that read it read the new data. One undo step. Not offered for `.xlsx`, which holds several sheets |34+| Replace spreadsheet | Opens the file instead, as File > Open and the command line do, asking first when there are unsaved changes |35+36+Replacing the current sheet keeps the charts that fit the new data. A37+chart that drew a whole table is re-pointed to the table the file has at38+the same corner when it has as many columns (rows for a chart by row),39+so last month's chart draws this month's rows; a chart of part of a40+table, or of whole columns or rows, keeps its range. A chart whose table41+now has other columns, or whose range is empty, is removed. The context42+line lists the removed charts first, then which were re-pointed and43+which kept their range, and undo brings the removed ones back.44+45+**Size.** A sheet is 1,048,576 rows by 16,384 columns (A to XFD), as in46+Excel. Imports keep at most `max-cells` cells ([config.md](config.md#max-cells),47+two million by default, about 600 MB): whole rows, as many as fit, and48+the context line says how many rows were left out, e.g. `only the first49+166,666 rows fit in max-cells (2,000,000 cells); 12,000 rows left out`.50+Data past the grid's edges is left out the same way. WK1 files keep their51+own 8,192 by 256. See [limits.md](limits.md#imports) for speeds.52+53+## Excel files54+55+**Read:** every sheet, opening on the one Excel showed; values, formulas56+(references between sheets too, shared formulas), workbook named ranges,57+number formats, bold, italic, underline, strikethrough, alignment,58+column widths, column and row styles, frozen panes, [filters](#filters-in-excel),59+notes (Excel's notes, its legacy comments; threaded comments aren't60+read), [conditional formatting and data validation](#rules-in-excel),61+[array formulas](#arrays-in-excel),62+dates in the 1904 system, and hidden sheets (unless it's the one Excel63+showed).64+65+**Written:** the same, with formulas in Excel's syntax and their results66+cached, column and row formats as column and row styles, frozen rows and67+columns as frozen panes, a filter as Excel's with the rows it hides68+hidden, notes as Excel's notes (which Sheets reads as notes), and69+formulas that spill as Excel 365's [dynamic array formulas](#arrays-in-excel).70+71+What changes on the way:72+73+| | |74+|---|---|75+| Formulas 012 can't read (unknown functions) | Keep their values |76+| Excel pivot tables | Come in as the values they showed; 012's go out as their results, not a pivot |77+| Excel's sheet protection | Comes in unprotected, with a note saying so: Excel's protection locks cells where 012's only warns. Protected ranges aren't written |78+| Array formulas (Excel 365's dynamic arrays and older `{=...}` ones) | Come in as formulas that spill again, without the values Excel kept in the cells they spill into |79+| Functions only Sheets has (`SORTN`, `FLATTEN`, `SPLIT`, `REGEXMATCH`, `REGEXEXTRACT`) | Go out as values, counted with the others below |80+| JEV functions, `#AND#`, formulas naming a sheet that doesn't exist | Go out as values (their `#REF!`, for a missing sheet: Excel would refuse the reference). The download's result counts the formulas saved as values, with an example |81+| A sheet name Excel can't take as is (spaces at its ends, or the same as another's but for them and case) | Written as one it can: without the spaces, with a number when two would clash (`Plan (2)`); formulas and named ranges naming it name that |82+| Files past the reader's limits (a zip bomb, 1 GB in one part, 2 GB in all, cells past XFD1048576) | Refused, with a message saying which |83+84+### Filters in Excel4985 5086 A sheet's filter goes out as Excel's AutoFilter over the same range, and5187 an AutoFilter comes in as a filter:
@@ -69,13 +105,35 @@ grouped by year or month, and filtering by color or icon. The filter
69105 itself still comes in over its range. Filters of Excel tables (as70106 opposed to the sheet's AutoFilter) aren't read.71107 72-## Rules in XLSX108+### Arrays in Excel73109 74-Conditional formats ([data.md](data.md#conditional-formatting)) go out75-as Excel's conditional formatting and data validation as its data76-validation, and come back:77-78-| 012 | Excel |110+A formula whose array spills (see [formulas.md](formulas.md#arrays-and-spills))111+is written as Excel writes a dynamic array formula: an array formula over112+the cells it spills into (`<f t="array" ref="C1:C9">`) on a cell whose113+metadata marks it dynamic (`cm="1"`, defined in `xl/metadata.xml`), with114+the values in the cells below and right of it, so Excel spills it the115+same and older Excels show the values. Formulas calling array functions116+(`FILTER`, `SORT`, `UNIQUE`, `SEQUENCE`, `LET`, `LAMBDA` and the like) or117+holding an array literal are written that way even when they compute one118+value, so Excel doesn't put its implicit intersection (`@`) in front of119+them. Excel gets its own names for the functions newer than Excel 2007120+(`_xlfn._xlws.FILTER`, `_xlfn.SEQUENCE`), `_xlpm.` before the names LET121+and LAMBDA bind, and the formula inside an `ARRAYFORMULA` around a whole122+formula, which a dynamic array formula computes over arrays anyway.123+What Excel can't hold goes out as the value it showed, counted in the124+download's note: functions only Sheets has (`SORTN`, `FLATTEN`, `SPLIT`,125+`REGEXMATCH`, and `REGEXEXTRACT`, whose Excel namesake returns the whole126+match where Sheets' returns the capture group), array literals holding127+references (Excel's hold only constants) and `ARRAYFORMULA` inside a128+formula.129+130+### Rules in Excel131+132+[Conditional formats](data.md#conditional-formatting) go out as Excel's133+conditional formatting, and [data validation](data.md#data-validation)134+as its data validation, and both come back:135+136+| Conditional format | Excel |79137 |---|---|80138 | Is empty, is not empty | `containsBlanks`, `notContainsBlanks` |81139 | Text contains, does not contain, starts with, ends with | `containsText`, `notContainsText`, `beginsWith`, `endsWith` |
@@ -86,6 +144,9 @@ validation, and come back:
86144 | Custom formula is | `expression`, in Excel's syntax |87145 | Color scale | `colorScale` with its points (`min`, `max`, `num`, `percent`, `percentile`) |88146 | A rule's style | a differential style (dxf): bold, italic, underline, strikethrough, the text color and a solid fill |147+148+| Data validation | Excel |149+|---|---|89150 | Dropdown, from a range | `list` of the items in quotes, or of the range (`Lists!$A$1:$A$20`) |90151 | Checkbox | `list` of `TRUE,FALSE`, which comes back as a checkbox |91152 | Number, date, text length | `decimal`, `date`, `textLength` with the operator; any date as a date greater than 0 |
@@ -110,10 +171,27 @@ formula with no Excel equivalent (`#AND#`) leaves its rule out, and so
110171 do list items with commas or quotes, or longer than Excel's 255111172 characters.112173 113-## The native format174+## Saving175+176+Saves are atomic: 012 writes a temporary file and renames it. Saving over177+the open file when something else wrote it since it was opened or last178+saved (another program, or another session of [012 serve](ssh.md)) asks179+first: Enter overwrites, S saves under another name, Esc cancels. Save180+as (and `:w name`, `:wq name` with vim keys) onto another file that181+exists asks the same way: Enter replaces it, Esc cancels, and cancelling182+`:wq`'s question cancels its quit too.183+184+Under `012 serve`, a session that idles out or is ended by the server185+stopping keeps its unsaved changes in `.012-recovery/` in the served186+directory, and the next session opening that file offers them back: see187+[ssh.md](ssh.md#unsaved-work).188+189+## The .012 format114190 115191 `.012` files are JSON with one line per cell, keyed by address, so diffs116-read naturally and files merge reasonably in version control:192+read naturally and files merge reasonably in version control. A cell193+without formatting is just what was typed; a formatted cell is a small194+object:117195 118196 ```json119197 {
@@ -122,83 +200,65 @@ read naturally and files merge reasonably in version control:
122200 "cells": {123201 "A1": "Rent",124202 "B1": {"input":"1450","format":"currency","decimals":2},203+ "B2": {"input":"95","note":"Due on the 1st"},125204 "B3": "=SUM(B1:B2)"126205 }127206 }128207 ```129208 130-A cell without formatting is just what was typed; a formatted cell is a131-small object. Version 3 adds named ranges, frozen panes and a filter, and is132-only written when a sheet uses one of them, so older builds of 012 can open133-everything else (version 4 adds several sheets and version 5 pivot tables,134-below). Charts are an optional `charts` field that older builds135-ignore; a chart's options are fields left out at their defaults, which136-builds without them draw with the defaults, while builds that chart but137-lack a chart's type (area, scatter) can't open the file. A cell's `note`138-(the cell is then an object, e.g. `{"input":"1450","note":"Due on the 1st"}`)139-and a sheet's `protected` list of protected ranges140-(`{"range":"B2:C9","description":"Totals"}`, or `{"sheet":true}`) are141-optional too and raise no version. Saves are atomic: 012 writes a temporary file and renames it. Saving over142-the open file when something else wrote it since it was opened or last143-saved (another program, or another session of [012 serve](ssh.md)) asks144-first: Enter overwrites, S saves under another name, Esc cancels. Save145-as (and `:w name`, `:wq name` with vim keys) onto another file that146-exists asks the same way: Enter replaces it, Esc cancels, and cancelling147-`:wq`'s question cancels its quit too.148-149-Under [012 serve](ssh.md), a session that idles out or is ended by the150-server stopping keeps its unsaved changes in `.012-recovery/` in the151-served directory, and the next session opening that file offers them152-back: see [ssh.md](ssh.md#unsaved-work).209+A formatted cell has its `input` and the formatting that isn't the210+default: `format` (as `number_format` in [macros](macros.md#cells) names211+it: `currency`, `percent`, `date`, ...), `decimals`, `pattern` for a212+custom format, `bold`, `italic`, `underline`, `strikethrough` and213+`align` (`left`, `center`, `right`).153214 154-Macros are an optional `macros` list, one macro per line with its155-Starlark script as a string, and `macroOrigin`, the computer they were156-made or trusted on; neither raises the version, and older builds ignore157-them. Opening a file never runs its macros. See [macros.md](macros.md#in-the-file).215+### Versions158216 159-Other formats import as one sheet named after the file (or the SQLite160-table); CSV and TSV downloads write the sheet shown, as Sheets' do, and161-a selection of whole columns or rows downloads their data, not a million162-blank lines.217+012 writes the lowest version that holds the workbook, so older builds218+open what they can, and refuses a version newer than it knows rather219+than lose sheets or pivots. Version 1 files (cells as plain strings)220+still load.163221 164-## Size222+| Version | Written when | Adds |223+|---|---|---|224+| 2 | One sheet, using none of the below | `cells`, `widths` |225+| 3 | A sheet has named ranges, frozen panes or a filter | `names` (each name's range, `#REF!` once its cells were deleted), `freeze` (`rows`, `cols`), `filter` (its `range`, and `columns` by letter, each with `hidden` values or a `condition` and its `value`) |226+| 4 | Several sheets, or a formula naming a sheet | `sheets`, a list; see [Several sheets](#several-sheets) |227+| 5 | A pivot table | a sheet's `pivot`; see [Pivot tables](#pivot-tables) |165228 166-A sheet is 1,048,576 rows by 16,384 columns (A to XFD), as in Excel.167-Imports keep at most `max-cells` cells ([config.md](config.md), two168-million by default, about 600 MB): whole rows, as many as fit, and the169-context line says how many rows were left out, e.g. `only the first170-166,666 rows fit in max-cells (2,000,000 cells); 12,000 rows left out`.171-Data past the grid's edges is left out the same way. WK1 files keep their172-own 8,192 by 256. Pastes and fills that would write more than `max-cells`173-cells at once are refused. See [limits.md](limits.md).229+### Fields that need no version174230 175-## Column and row formats231+Builds that don't know these fields ignore them (and drop them if they232+save), so they raise no version:176233 177-Formatting whole columns or rows (select them with Ctrl+Space or178-Shift+Space, or click their headers) keeps the format on the column or179-row, as Sheets does, rather than on each of their million cells: every180-cell of it shows the format unless it has its own, and a cell typed into181-later takes it. A cell's format comes from the cell, else its row, else182-its column, else the whole sheet's (Ctrl+A twice, then a format); the183-number format and the text style fall back separately. The file keeps184-them in a `lines` field after the widths, runs of lines with the same185-formatting together, and older builds ignore it:234+| Field | On | Holds |235+|---|---|---|236+| `note` | a cell | Its [note](data.md#notes) |237+| `own` | a cell | `true` when its formatting is its own, not its column's or row's (Automatic in a currency column) |238+| `lines` | a sheet | [Column and row formats](#column-and-row-formats) |239+| `name`, `hidden` | a sheet | Its name when renamed; `true` when hidden (a file whose sheets are all hidden opens with the first one shown) |240+| `charts` | a sheet | One chart per line: `type` (`column`, `bar`, `line`, `pie`, `area`, `scatter`), `data`, `at`, `width`, `height`, `byRow`, `header`, `labels`, `title`, and options left out at their defaults: `stack` (`stacked`, `percent`), `trend`, `min`, `max`, `log`, `gridlines` (only when off), `legend` (`right`, `none`). A build that charts but lacks a chart's type refuses the file |241+| `protected` | a sheet | [Protected ranges](data.md#protected-sheets-and-ranges): `{"range":"B2:C9","description":"Totals"}`, or `{"sheet":true}` |242+| `conditionalFormats`, `validations` | a sheet | [Rules](#conditional-formats-and-data-validation), one per line |243+| `arithmetic` | the workbook | `decimal` for [decimal arithmetic](formulas.md#decimal-arithmetic) |244+| `macros`, `macroOrigin` | the workbook | Macros as Starlark scripts, and the computer they were made or trusted on: see [macros.md](macros.md#in-the-file). Opening a file never runs them |245+246+### Column and row formats247+248+[Formats of whole columns and rows](data.md#rows-and-columns) are kept249+in a `lines` field after the widths, runs of lines with the same250+formatting together; `A:XFD` is the whole sheet's format:186251 187252 ```json188253 "lines": {"A:XFD": {"italic":true}, "B:D": {"format":"currency","decimals":2}, "1:1": {"bold":true}},189254 ```190255 191-`A:XFD` is the whole sheet's format. A cell whose formatting its lines192-can't express (Automatic in a currency column) has `"own": true`.256+### Several sheets193257 194-## Several sheets195-196-A file of one sheet, whose formulas name no sheet, keeps the single-sheet197-layout above, so older builds of 012 open it; a renamed sheet adds a `name`198-field they ignore. Anything else is version 4: the sheets are a list, each199-with its name and the fields a version 3 file has at the top, and named200-ranges say their sheet. The workbook's settings (named ranges, decimal201-arithmetic, the sheet shown when saved) stay at the top:258+In version 4 the sheets are a list, each with its name and the fields a259+version 3 file has at the top, and named ranges say their sheet. The260+workbook's settings (named ranges, decimal arithmetic, the sheet shown261+when saved as `active`) stay at the top:202262 203263 ```json204264 {
@@ -215,6 +275,7 @@ arithmetic, the sheet shown when saved) stay at the top:
215275 },216276 {217277 "name": "Q3 plan",278+ "hidden": true,218279 "cells": {219280 "A1": "=Budget!B1*3"220281 }
@@ -223,23 +284,14 @@ arithmetic, the sheet shown when saved) stay at the top:
223284 }224285 ```225286 226-Older builds refuse version 4 files rather than lose sheets.227-228-A hidden sheet (Hide sheet on its tab) has `"hidden": true` after its229-name. It needs no version bump: builds without hidden sheets ignore the230-field and show the sheet. A file whose sheets are all hidden opens with231-the first one shown. XLSX downloads write hidden sheets hidden, and232-sheets hidden in Excel import hidden.233-234-## Pivot tables287+### Pivot tables235288 236-A workbook with a pivot table is version 5: version 4 with a `pivot`237-field, on one line after the cells of the pivot's sheet, holding its238-definition. The results are never saved; they are computed again when239-the file opens, so the file stays small and can't disagree with its240-data. Arrays that formulas spill are the same: the file keeps the formula,241-and the cells it spills into only for their formatting. Builds that know only version 4 refuse the file rather than show an242-empty sheet; a workbook without pivots is still written as version 4.289+A pivot's sheet has a `pivot` field, on one line after its cells,290+holding the definition. The results are never saved; they are computed291+again when the file opens, so the file stays small and can't disagree292+with its data.293+Arrays that formulas spill are kept the same way: the file keeps the formula,294+and the cells it spills into only for their formatting and notes.243295 244296 ```json245297 {
@@ -262,11 +314,10 @@ empty sheet; a workbook without pivots is still written as version 4.
262314 `condition` with its `value`.263315 - `rowTotals` and `columnTotals` are the grand total row and column.264316 265-## Conditional formats and data validation317+### Conditional formats and data validation266318 267-A sheet's rules are two optional fields after its cells and charts, one268-rule per line; they need no version, and older builds ignore them269-(losing the rules, as they lose charts):319+A sheet's rules are two lists after its cells and charts, one rule per320+line:270321 271322 ```json272323 "conditionalFormats": [
M docs/formulas.md +2 -0
M docs/keys.md +160 -92
@@ -1,78 +1,138 @@
11 # Keys and mouse22 33 Inside the grid, 012 behaves like Google Sheets: if you know a Sheets4-shortcut, try it. Everything is also reachable from the menus (F10 or5-Alt+letter) and the command palette (Ctrl+K), which shows each command's6-shortcut. F1 or Ctrl+/ lists every binding, generated from the same table7-the app uses, so it never drifts from what the keys do.4+shortcut, try it. Every command is also in the menus (F10 or Alt+letter)5+and the command palette (Ctrl+K), which show each command's key. F1 or6+Ctrl+/ lists every binding, generated from the table the app uses, so it7+never drifts from what the keys do. This page is the same list with the8+mouse and the vim keymap added.9+10+## Moving around11+12+| Key | Action |13+|---|---|14+| Arrows | Move one cell |15+| Ctrl+arrows, End | Jump to the edge of the data (End: rightwards) |16+| Tab, Shift+Tab | Right, left |17+| PgUp, PgDn | A screen up, down |18+| Alt+PgUp, Alt+PgDn | A screen left, right |19+| Home, Ctrl+Home, Ctrl+End | Column A, cell A1, the last used cell |20+| Ctrl+G, F5 | Go to a cell, a range or a named range, on any sheet (`Sheet2!B3`) |21+22+## Entering data23+24+| Key | Action |25+|---|---|26+| Type | Replace the cell: `=` starts a formula, `'` forces text, and `$1,200`, `12%`, `9/26/2026` or `14:30` are numbers that keep their format ([formulas.md](formulas.md#what-you-type)) |27+| Enter, Tab | Accept and move down or right; Enter goes back to the column a run of Tabs began in |28+| Enter, F2, double-click | Edit the cell |29+| Esc | Cancel the entry |30+| Ctrl+Enter | Enter the same entry in every selected cell |31+| Arrows, Shift+arrows | While typing a formula, after an operator: point at a cell, or a range |32+| F4 | While typing a formula: cycle the reference at the caret through `A1`, `$A$1`, `A$1`, `$A1` |33+| Up, Down; Tab, Enter | While suggestions for a function, range or sheet name show: pick one; insert it (a sheet as `Summary!`, then arrows point into it). Esc hides them |34+35+## Selecting36+37+| Key | Action |38+|---|---|39+| Shift+arrows, Ctrl+Shift+arrows | Extend the selection, a cell at a time or to the edge of the data; the status line shows Sum, Avg and Count |40+| Ctrl+Space, Shift+Space | Whole columns, whole rows |41+| Ctrl+A | The data around the active cell, then everything |42+| Esc | Deselect |43+44+## Changing the sheet845 946 | Key | Action |1047 |---|---|11-| Type | Replace the cell. `=` starts a formula, `'` forces text; `$1,200`, `12%`, `9/26/2026` and `14:30` are numbers that keep their format |12-| Enter / Tab | Accept and move down / right (Enter returns to where a run of Tabs began) |13-| Enter or F2, double-click | Edit the cell |14-| Arrows while typing a formula | After an operator, pick a cell; Shift+arrows pick a range |15-| Shift+arrows, drag, Shift+click | Select; the status line shows Sum, Avg and Count |16-| Ctrl+arrows | Jump to the edge of the data (add Shift to select) |17-| Click a header, Ctrl+Space, Shift+Space | Select whole columns or rows |18-| Ctrl+A | Select the data, then everything |19-| Del / Backspace | Clear the selection (formatting stays) |20-| Ctrl+Z, Ctrl+Y or Ctrl+Shift+Z | Undo, redo |48+| Del, Backspace | Clear the selection; formatting stays |49+| Ctrl+Z; Ctrl+Y, Ctrl+Shift+Z | Undo; redo |2150 | Ctrl+C, Ctrl+X, Ctrl+V | Copy, cut, paste; references adjust as in Sheets, and copies also go to the system clipboard |2251 | Ctrl+Shift+V | Paste values only |23-| Paste from the terminal | Tab-separated or multi-line text fills a block of cells |24-| Ctrl+D, Ctrl+R | Fill down, fill right; top rows that start a series (1, 2 over blanks) continue it |25-| Drag the fill handle | The ▟ at the selection's corner, shown on hover: continue a series (1, 2, 3; Jan, Feb; Mon, Tue; dates; Item 1, Item 2) or copy |26-| View > Freeze | Keep rows or columns on screen while the rest scrolls |27-| Data > Sort sheet, Sort range | Sort by the active column A to Z or Z to A, or pick columns and order on a bar (Left/Right column, Space order, Alt+A add, Alt+H header row) |28-| Alt+Down, click ▾ in a header | With a filter (Data > Create a filter): pick the column's values (Space checks, type to search) or a condition |29-| Alt+Down, click ▾ in a cell | Open the cell's dropdown (Data > Data validation, Insert > Dropdown): type to search, Enter picks; see [data.md](data.md#data-validation) |30-| Space, click a checkbox | Check or uncheck the selected checkboxes (Insert > Checkbox); elsewhere Space starts an entry |31-| Format > Conditional formatting, Data > Data validation | The sheet's rules in a panel: Up/Down pick, Enter adds or edits, Del removes, Shift+Up/Down reorders; in a rule, Left/Right change a choice, Space flips a check, Enter saves, Esc goes back; see [data.md](data.md#conditional-formatting) |32-| Data > Pivot table | Summarize the table on a new sheet; in the pivot editor Up/Down pick a line, Space adds a field (or opens a filter, flips a total, changes the data range), Left/Right change a field's order or summary, S its "show as", R or F2 renames a value, Shift+Up/Down move it, Del removes it, Enter keeps, Esc undoes. Data > Edit pivot table reopens it |33-| Alt+Shift+F | Frequency table of the active column on a new sheet (Data > Frequency table), as VisiData's Shift+F |34-| Ctrl+Alt+= / Ctrl+Alt+- | Insert rows above / delete the selected rows (columns when whole columns are selected) |35-| Ctrl+Enter while typing | Enter the same entry in every selected cell |36-| F4 while typing a formula | Cycle the reference at the caret through A1, $A$1, A$1, $A1 |37-| Typing a function, range or sheet name | Suggestions drop down: Up/Down pick, Tab or Enter insert (a sheet as `Summary!`, then arrows point into it), Esc hides them; inside a function's parentheses the context line shows its arguments with the current one marked |38-| Alt+, / Alt+. | Trace precedents / dependents: highlight the cells a formula reads, or the formulas that read the cell, and jump to the first; press again for the next, Esc to go back (Excel's Ctrl+[ and Ctrl+], which terminals send as Esc) |39-| Data > Named ranges | Name ranges for formulas (`=SUM(Sales)`): Enter goes to one, F2 renames or repoints it, Ctrl+D deletes it; Data > Define named range names the selection |40-| Shift+F2, Insert > Note | Add or edit the active cell's note on the context line (Alt+Enter or Shift+Enter starts a new line); see [data.md](data.md#notes) |41-| Data > Protect sheets and ranges | Protect a range or the sheet so edits to it ask first; Enter goes to a protection, Ctrl+D removes it; see [data.md](data.md#protected-sheets-and-ranges) |42-| Ctrl+Shift+1 ... 6 | Number, time, date, currency, percent, scientific format |52+| Paste in the terminal | Tab-separated or multi-line text fills a block of cells |53+| Ctrl+D, Ctrl+R | Fill down, fill right; a series started in the top rows (1, 2 over blanks) continues |54+| Ctrl+Alt+=, Ctrl+Alt+- | Insert rows above, delete the selected rows (columns when whole columns are selected) |55+56+[data.md](data.md#copy-paste-and-fill) says how formats travel with57+copies and which series the fill handle continues.58+59+## Formatting60+61+| Key | Action |62+|---|---|63+| Ctrl+Shift+1 | Number |64+| Ctrl+Shift+2 | Time |65+| Ctrl+Shift+3 | Date |66+| Ctrl+Shift+4 | Currency |67+| Ctrl+Shift+5 | Percent |68+| Ctrl+Shift+6 | Scientific |4369 | Ctrl+B, Ctrl+I, Ctrl+U, Alt+Shift+5 | Bold, italic, underline, strikethrough |44-| Ctrl+Shift+L / E / R | Align left, center, right |70+| Ctrl+Shift+L, Ctrl+Shift+E, Ctrl+Shift+R | Align left, center, right |4571 | Ctrl+\ | Clear formatting |46-| Ctrl+S, Ctrl+O, Ctrl+Q | Save, open (imports other formats), quit |47-| Ctrl+G or F5 | Go to a cell, a range or a named range, on any sheet (`Sheet2!B3`) |48-| Ctrl+PgDn / Ctrl+PgUp, Alt+Right / Alt+Left | Next / previous sheet; while typing a formula, point into it to insert `Sheet2!A1` |49-| Shift+F11 | New sheet (also Insert > Sheet) |72+73+The rest of the formats are in the Format menu.74+75+## Sheets76+77+| Key | Action |78+|---|---|79+| Ctrl+PgDn, Ctrl+PgUp, Alt+Right, Alt+Left | Next, previous sheet; while typing a formula, point into it to insert `Sheet2!A1` |80+| Shift+F11 | New sheet |5081 | Alt+Shift+K | Go to a sheet by name |51-| Right-click a tab > Hide sheet, View > Hidden sheets | Hide a sheet (formulas still read it; next, previous and the tabs skip it), and list the hidden ones to show one again |52-| Alt+F, Alt+E, Alt+V, Alt+I, Alt+O, Alt+H, F10, click a title | Open a menu (arrows move, Enter runs, Esc closes) |82+83+## Data tools84+85+| Key | Action | More |86+|---|---|---|87+| Ctrl+F, Ctrl+H | Find; find and replace | [data.md](data.md#find-and-replace) |88+| Alt+Down | Open the active cell's dropdown (type to search, Enter picks); elsewhere, with a filter on (Data > Create a filter), the column's filter (Space checks values, type to search, or pick a condition) | [validation](data.md#data-validation), [filter](data.md#filter) |89+| Space | Check or uncheck the selected checkboxes (Insert > Checkbox); elsewhere Space starts an entry | [data.md](data.md#data-validation) |90+| Alt+, Alt+. | Trace precedents, dependents: highlight the cells a formula reads, or the formulas that read the cell, and jump to the first; again for the next, Esc to go back (Excel's Ctrl+[ and Ctrl+], which terminals send as Esc) | [formulas.md](formulas.md#building-formulas) |91+| Alt+Shift+F | Frequency table of the active column on a new sheet, as VisiData's Shift+F | [data.md](data.md#frequency-tables) |92+| Shift+F2 | Add or edit the active cell's note; Alt+Enter or Shift+Enter starts a new line | [data.md](data.md#notes) |93+94+Tools with no key of their own have their keys on screen while they're95+open, and in their docs: sorting by several columns96+([data.md](data.md#sort)), conditional formatting and data validation97+([rules panel](data.md#conditional-formatting)), pivot tables98+([pivot editor](data.md#pivot-tables)), protected ranges99+([data.md](data.md#protected-sheets-and-ranges)) and charts100+([charts.md](charts.md)). In Data > Named ranges, Enter goes to a101+range, F2 renames or repoints it and Ctrl+D deletes it.102+103+## Files, menus and help104+105+| Key | Action |106+|---|---|107+| Ctrl+S, Ctrl+O, Ctrl+Q | Save, open (and import other formats), quit |108+| F10, Alt+F, Alt+E, Alt+V, Alt+I, Alt+O, Alt+D, Alt+H | Open a menu; arrows move, Enter runs, Esc closes |53109 | Ctrl+K, Alt+/, Ctrl+Shift+P | Search the menus: find and run any command |54-| Right-click, Shift+F10 | Cell, column or row menu |55-| F1 or Ctrl+/ | Keyboard shortcuts |56-| Insert > Chart | Chart the selection (or the table around the active cell); the editor bar picks the type with Left/Right or 1 to 6, S switches rows and columns, H and L toggle the header row and labels, K stacks columns, bars and areas, E adds a scatter's trend line, R changes the range, T the title, and A opens the axis and legend bar (N minimum, X maximum, L log scale, G gridlines, P legend position) |57-| Click a chart, then Arrows / Shift+arrows / Del | Move, resize or delete it; drag the chart or its corner with the mouse; Enter edits it |58-| Cmd- or Ctrl-click a URL | Open it: cells holding a URL, and `=HYPERLINK(url, [label])`, are terminal hyperlinks |59-| Data > Macros | Record what you do as a macro (absolute or relative references), stop and save it with a name and shortcut, run or manage saved macros; see [macros.md](macros.md) |60-| Ctrl+Alt+Shift+0 ... 9 | Run the macro with that shortcut; Esc stops a macro while it runs (the indicator says CMD) |110+| Shift+F10, right-click | The cell, column or row menu |111+| F1, Ctrl+/ | Keyboard shortcuts |112+113+## Macros114+115+| Key | Action |116+|---|---|117+| Ctrl+Alt+Shift+0 to 9 | Run the macro with that shortcut ([macros.md](macros.md#running)) |118+| Esc | Stop a macro while it runs (the mode indicator says CMD) |61119 62120 ## Vim keys63121 64122 File > Settings > Vim keys (also `:settings.vim` or the palette) turns65123 on a vim keymap in the spirit of sc-im and VisiData. It is off by66-default and belongs to you, not the sheet: it isn't saved in `.012`67-files, and File > New and Open keep it. The mode indicator says68-**NORMAL** where it would say READY, **VISUAL** while selecting with69-`v` or `V`, and **COMMAND** on the `:` line; typing into a cell is70-ENTER or EDIT, as always, and Enter, Tab or Esc there return to NORMAL.71-Keys vim doesn't take keep their Sheets meaning (arrows, Del, Ctrl+S,72-Ctrl+C/V, Alt+letter menus, F-keys); Ctrl+D, Ctrl+U and Ctrl+R are73-vim's, so fill down and fill right are in the Edit menu. F1 lists the74-vim keys first, and the menus and palette show them where Sheets' keys75-are taken.124+default and belongs to you, not the sheet: it's kept in the config file125+as `keymap = vim` ([config.md](config.md#keymap)), never in `.012` files.126+The mode indicator says **NORMAL** where it would say READY, **VISUAL**127+while selecting with `v` or `V`, and **COMMAND** on the `:` line; typing128+into a cell is ENTER or EDIT, as always, and Enter, Tab or Esc there129+return to NORMAL. Keys vim doesn't take keep their Sheets meaning130+(arrows, Del, Ctrl+S, Ctrl+C and Ctrl+V, Alt+letter menus, F-keys);131+Ctrl+D, Ctrl+U and Ctrl+R are vim's, so fill down and fill right are in132+the Edit menu. F1 lists the vim keys first, and the menus and palette133+show them where Sheets' keys are taken.134+135+![Vim keys: counts, dd and p, / search, VISUAL rows and the : line](media/vim.gif)76136 77137 | Keys (NORMAL) | Action |78138 |---|---|
@@ -87,7 +147,7 @@ are taken.
87147 | `=` | Start a formula |88148 | `o` `O` | Insert a row below or above and start typing in it |89149 | `x` | Clear the cell (with a count, that many to the right) |90-| `dd` `yy` | Cut or copy the row (with a count, that many rows); `p` pastes them back |150+| `dd` `yy` | Cut or copy the row (with a count, that many rows) |91151 | `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 |92152 | `u`, Ctrl+R | Undo, redo |93153 | `v` `V` | Select cells or whole rows (VISUAL); motions stretch the selection |
@@ -96,10 +156,14 @@ are taken.
96156 | `:` | The command line |97157 | Esc | Cancel a count or sequence, leave VISUAL, deselect |98158 99-In VISUAL mode `d` or `x` deletes the selection (whole rows with `V`,100-the cells' contents with `v`) and keeps a copy to paste, `y` copies it,101-`p` pastes over it, `o` goes to the other corner, and `v`, `V` or Esc go102-back to NORMAL.159+| Keys (VISUAL) | Action |160+|---|---|161+| `d` `x` | Delete the selection (whole rows with `V`, the cells' contents with `v`), keeping a copy to paste |162+| `y` | Copy the selection |163+| `p` | Paste over the selection |164+| `o` | Go to the other corner |165+| `:` | The command line, acting on the selection |166+| `v` `V`, Esc | Back to NORMAL |103167 104168 The `:` line takes:105169
@@ -107,11 +171,11 @@ The `:` line takes:
107171 |---|---|108172 | `:B12`, `:Sheet2!A1`, `:C3:D9`, `:Sales` | Go to a cell, range or named range |109173 | `:40` | Go to row 40 |110-| `:w`, `:w name`, `:w out.csv` | Save, Save as, or Download as another format, just as the File menu does |174+| `:w`, `:w name`, `:w out.csv` | Save, Save as, or Download as another format, as the File menu does |111175 | `:q`, `:q!` | Quit (asking about unsaved changes), quit discarding them |112176 | `:wq`, `:x` | Save and quit; `:x` saves only if something changed |113177 | `:e name`, `:e!` | Open a sheet or import a file; refused with unsaved changes unless `:e!` |114-| `:edit.fill_down`, `:fill down` | Any command, by its ID or title |178+| `:edit.fill_down`, `:fill down` | Any command, by its id or title |115179 116180 Completions for commands appear under the line as you type, drawn from117181 the same registry as the menus and palette: Tab puts the highlighted
@@ -121,33 +185,37 @@ line isn't a command itself (`:fill d` Enter fills down).
121185 122186 ## Mouse123187 124-Click a cell to select it, drag to select a range, Shift+click to extend,125-and click a column or row header (or the corner) to select whole columns,126-rows or everything. Double-click edits. Drag a column header's right edge127-to resize it; double-click the edge to fit the contents. Dragging past the128-edge of the grid scrolls. Right-click opens the cell, column or row menu.129-A click on a checkbox's box checks or unchecks it, and a click on a130-dropdown's ▾ opens its list.131-The mouse pointer changes shape over cells, resize handles and the formula132-bar in terminals that support it (OSC 22).133-134-The sheet tabs at the left of the status line take the mouse too: click a135-tab to show its sheet, double-click to rename it, right-click for its menu136-(rename, duplicate, delete, hide, move left or right), drag it onto another tab to137-move it there, and click `+` to add a sheet. When the tabs don't all fit,138-`‹` and `›` step through them. While typing a formula, clicking a tab points139-into that sheet, as in Sheets.140-141-In Ghostty and xterm, Shift+click normally starts the terminal's own text142-selection; 012 asks the terminal to pass it through (XTSHIFTESCAPE) and143-restores that on exit.188+| Gesture | Action |189+|---|---|190+| Click, drag, Shift+click | Select a cell, a range, or extend the selection; dragging past the edge scrolls |191+| Click a column or row header, or the corner | Select whole columns, rows, or everything |192+| Double-click | Edit the cell |193+| Drag the fill handle (▟ at the selection's corner, shown on hover) | Continue a series (1, 2, 3; Jan, Feb; Mon, Tue; dates; Item 1, Item 2) or copy |194+| Drag a column header's right edge; double-click it | Resize the column; fit it to its contents |195+| Right-click | The cell, column or row menu |196+| Click `▾` in a header, or in a cell | Open the column's filter, or the cell's dropdown |197+| Click a checkbox | Check or uncheck it |198+| Click a chart; drag it or its corner | Select it; move or resize it (then arrows move it, Shift+arrows resize, Enter edits, Del deletes) |199+| Cmd- or Ctrl-click a link | Open a URL in a cell or a `=HYPERLINK(url, [label])` |200+| Hover a cell with a note | Show the note beside it |201+202+The sheet tabs at the left of the status line take the mouse too: click203+a tab to show its sheet, double-click to rename it, right-click for its204+menu (rename, duplicate, delete, hide, move left or right), drag it onto205+another tab to move it there, and click `+` to add a sheet. When the206+tabs don't all fit, `‹` and `›` step through them. While typing a207+formula, clicking a cell or a tab points at it, as in Sheets.208+209+The pointer changes shape over cells, resize handles and the formula bar210+in terminals that support it (OSC 22). In Ghostty and xterm, Shift+click211+normally starts the terminal's own text selection; 012 asks the terminal212+to pass it through (XTSHIFTESCAPE) and restores that on exit.144213 145214 ## The screen146215 147-The top three lines are the **menu bar** with the mode indicator on the148-right (and `REC` beside it while a macro is recorded), the **formula bar** (name box, then the cell's contents or the entry149-being typed) and the **context line**, which holds prompts, key hints,150-formula errors and explanations. The bottom line is the **status line**:151-the sheet tabs (as tmux lists its windows, the sheet shown highlighted),152-the file name, whether it's modified, and Sum, Avg and Count for a153-selection.216+| Line | Shows |217+|---|---|218+| Menu bar (top) | The menus, and the mode indicator on the right (`REC` beside it while a macro records) |219+| Formula bar | The name box, then the cell's contents or the entry being typed |220+| Context line | Prompts, key hints, formula errors and explanations, and bars such as find and the chart editor |221+| Status line (bottom) | The sheet tabs (the one shown highlighted), the file name, whether it's modified, and Sum, Avg and Count for a selection |
M docs/macros.md +2 -0
M docs/media/charts.gif +0 -0

Binary file.

A docs/media/decimal-on.png +0 -0

Binary file.

M docs/media/find-replace.gif +0 -0

Binary file.

M docs/media/first-steps.gif +0 -0

Binary file.

M docs/media/freeze-sort-filter.gif +0 -0

Binary file.

A docs/media/import-picker.png +0 -0

Binary file.

M docs/media/jev.gif +0 -0

Binary file.

A docs/media/macros.gif +0 -0

Binary file.

M docs/media/menus-palette.gif +0 -0

Binary file.

A docs/media/pivot.gif +0 -0

Binary file.

A docs/media/rules.gif +0 -0

Binary file.

A docs/media/selection-stats.png +0 -0

Binary file.

A docs/media/theme-classic.png +0 -0

Binary file.

A docs/media/vim.gif +0 -0

Binary file.

M docs/testing.md +41 -20
@@ -2,7 +2,7 @@
22 33 ```sh44 make check # all of the below that must pass before a push: gofmt, vet, lint, test, oracle, e2e5-make lint # go vet, staticcheck, cognitive complexity at most 25, Go files at most 500 lines, doclint (docs describe the code, not its history)5+make lint # go vet, staticcheck, cognitive complexity at most 25, Go files at most 500 lines, doclint and doccheck (see Docs below)66 make test # engine, file formats and UI unit tests77 make fuzz # fuzz the formula parser and the CSV, .wk1 and XLSX readers88 make oracle # compare formulas and number formats with excelize
@@ -28,21 +28,18 @@ Ghostty would send. Tests assert on what a user would see: screen text,
2828 cursor position, window title, hyperlinks, which screen is active after2929 quitting. A fake TypeSafe server answers JEV questions, and the harness3030 clears `TYPESAFE_API_KEY` so tests never reach the real service. Every31-session gets its own config directory (`XDG_CONFIG_HOME`, with an32-optional config file), and the binary is built with `-tags fakekeyring`,33-whose credential store is a file there, so tests never read your config34-or touch your keychain. Unit tests do the same with `ui.Settings` and35-`keyring.Memory`.31+session points `XDG_CONFIG_HOME` into its working directory (with an32+optional config file), so its config, theme files and the machine id33+that macros' trust uses are the test's own: sessions that share a34+directory are one computer. The binary is built with `-tags35+fakekeyring`, whose credential store is a file there, so tests never36+read your config or touch your keychain. Unit tests do the same with37+`ui.Settings` and `keyring.Memory`.3638 3739 `e2e/ssh_test.go` runs `012 serve` and reaches it with the system's3840 `ssh` client inside the same libghostty terminal, so the server path3941 is tested as a user sees it (skipped when `ssh` isn't installed).4042 41-Each session points `XDG_CONFIG_HOME` into its working directory, so its42-config file, theme files and the machine id that macros' trust uses are43-the test's own: sessions that share a directory are one computer, and44-tests never touch the real config, keychain or machine id.45-4643 `make e2e` builds libghostty-vt from source with Zig into `.deps/` on first4744 use. It is its own Go module so cgo never reaches the main binary.4845
@@ -67,8 +64,8 @@ reason.
6764 ## The XLSX differential test6865 6966 `TestXLSXDifferential` (`internal/fileio/xlsx_diff_test.go`) imports a70-corpus of workbooks with 012's own XLSX reader and with the excelize71-importer it replaced (kept in `xlsx_excelize_test.go`), and compares67+corpus of workbooks with 012's own XLSX reader and with an importer68+built on excelize (`xlsx_excelize_test.go`), and compares7269 every sheet as saved, the widths, names, sheet shown, rows and notes.7370 The corpus is workbooks written by the test (hand-written SpreadsheetML,7471 excelize and 012's exporter), the stress datasets' XLSX files when
@@ -86,13 +83,37 @@ service one question of each kind, using `TYPESAFE_API_KEY`.
8683 ## Demo recordings8784 8885 `demos/` holds [VHS](https://github.com/charmbracelet/vhs) tapes for the89-recordings in the README. `make demos` renders all of them (or90-`make demos DEMOS=jev` for one) into `demos/out/`: full GIFs, PNG stills of91-key moments in `demos/out/stills/`, and smaller GIFs in `demos/out/media/`.92-It needs vhs 0.12+, ttyd and ffmpeg. The JEV tape talks to `demos/fakejev`, a93-local stand-in the target starts on 127.0.0.1, never the real service. On94-macOS, `demos/lib/ttyd` wraps ttyd so Alt+letter reaches the app. VHS has no95-mouse commands, so the tapes use the keyboard.86+recordings and stills the README and the docs show. `make demos` renders87+all of them (or `make demos DEMOS=jev` for one) into `demos/out/`: full88+GIFs, PNG stills of key moments in `demos/out/stills/`, and smaller GIFs89+in `demos/out/media/`. It needs vhs 0.12+, ttyd and ffmpeg. The JEV tape90+talks to `demos/fakejev`, a local stand-in the target starts on91+127.0.0.1, never the real service. On macOS, `demos/lib/ttyd` wraps ttyd92+so Alt+letter reaches the app. VHS has no mouse commands, so the tapes93+use the keyboard, and they reach commands through the palette (Ctrl+K)94+rather than counting menu items, so a new menu item doesn't break them.95+Tapes that change settings set them for the run (`O12_KEYMAP=vim`)96+rather than writing the config file the other tapes share.97+98+A change to what a tape shows means rendering it again, reading its99+stills, and copying what the docs show into `docs/media/`: GIFs from100+`demos/out/media/`, stills from `demos/out/stills/`. Only files a doc101+shows go there, and GIFs stay under 1 MB.102+103+## Docs104+105+`make lint` runs `scripts/doclint`, which flags wording that narrates106+history (see [CLAUDE.md](../CLAUDE.md)), and `scripts/doccheck`: relative107+links and anchors resolve, [docs/README.md](README.md) links every doc,108+every file in `docs/media` is shown by a doc and made by a tape, and109+every tape records something a doc shows. Unit tests check what the docs110+say about the code: menu paths lead to menu items, command ids and keys111+exist and every bound key is in [keys.md](keys.md)112+(`internal/ui/docs_test.go`), settings and variables are options113+(`internal/config/docs_test.go`), and [files.md](files.md) names every114+field of the format (`internal/sheet/file_doc_test.go`).115+[functions.md](functions.md) and [config.md](config.md)'s reference are116+generated, and their tests fail when they're stale.96117 97118 ## No broken windows98119
M docs/themes.md +2 -0
M internal/config/docs.go +26 -0
A internal/config/docs_test.go +92 -0
@@ -0,0 +1,92 @@
1+package config2+3+import (4+ "os"5+ "path/filepath"6+ "regexp"7+ "slices"8+ "strings"9+ "testing"10+)11+12+var (13+ // inlineSetting is a setting quoted in prose: `theme = Dracula`.14+ inlineSetting = regexp.MustCompile("`([a-z][a-z0-9-]*) = [^`]*`")15+ // fileSetting is a line of a config example.16+ fileSetting = regexp.MustCompile(`^([a-z][a-z0-9-]*)\s*=`)17+ // envVar is one of 012's own variables, or the JEV service's.18+ envVar = regexp.MustCompile(`\b(?:O12|TYPESAFE)_[A-Z_]+\b`)19+)20+21+// notOptions are variables the docs name that aren't options: the JEV key22+// never goes through the config (see docs/jev.md).23+var notOptions = []string{"TYPESAFE_API_KEY"}24+25+// TestDocsNameOptions checks the settings and variables the docs name are26+// options. A config example is a fenced block whose first line is a27+// comment naming the file, `# ~/.config/012/config`; other blocks (theme28+// files, shell) aren't read.29+func TestDocsNameOptions(t *testing.T) {30+ paths, err := filepath.Glob("../../docs/*.md")31+ if err != nil {32+ t.Fatal(err)33+ }34+ var env []string35+ for _, o := range Options {36+ env = append(env, o.Env...)37+ }38+ for _, p := range append(paths, "../../README.md") {39+ data, err := os.ReadFile(p)40+ if err != nil {41+ t.Fatal(err)42+ }43+ checkDocOptions(t, strings.TrimPrefix(p, "../../"), string(data), env)44+ }45+}46+47+// checkDocOptions reports settings and variables doc names that aren't48+// options.49+func checkDocOptions(t *testing.T, name, doc string, env []string) {50+ t.Helper()51+ check := func(key string) {52+ if _, ok := Lookup(key); !ok && key != "key" {53+ t.Errorf("%s names setting %q, which isn't an option", name, key)54+ }55+ }56+ for _, m := range inlineSetting.FindAllStringSubmatch(doc, -1) {57+ check(m[1])58+ }59+ for _, block := range configBlocks(doc) {60+ for _, l := range block {61+ if m := fileSetting.FindStringSubmatch(l); m != nil {62+ check(m[1])63+ }64+ }65+ }66+ for _, v := range envVar.FindAllString(doc, -1) {67+ if !slices.Contains(env, v) && !slices.Contains(notOptions, v) {68+ t.Errorf("%s names %s, which no option reads", name, v)69+ }70+ }71+}72+73+// configBlocks returns the lines of the fenced blocks that are config74+// examples.75+func configBlocks(doc string) [][]string {76+ var out [][]string77+ var cur []string78+ in := false79+ for l := range strings.SplitSeq(doc, "\n") {80+ if strings.HasPrefix(strings.TrimSpace(l), "```") {81+ if in && len(cur) > 0 && strings.HasPrefix(cur[0], "#") && strings.Contains(cur[0], "012/config") {82+ out = append(out, cur)83+ }84+ in, cur = !in, nil85+ continue86+ }87+ if in {88+ cur = append(cur, strings.TrimSpace(l))89+ }90+ }91+ return out92+}
A internal/sheet/file_doc_test.go +56 -0
@@ -0,0 +1,56 @@
1+package sheet2+3+import (4+ "os"5+ "reflect"6+ "strings"7+ "testing"8+)9+10+// TestFileDocNamesFields checks docs/files.md names every field the .01211+// format has, and docs/macros.md a macro's, so a new field comes with its12+// documentation.13+func TestFileDocNamesFields(t *testing.T) {14+ files := readDoc(t, "../../docs/files.md")15+ macros := readDoc(t, "../../docs/macros.md")16+ seen := map[reflect.Type]bool{reflect.TypeFor[fileMacro](): true}17+ text := files18+ var walk func(reflect.Type, string)19+ walk = func(ty reflect.Type, path string) {20+ for ty.Kind() == reflect.Pointer || ty.Kind() == reflect.Slice || ty.Kind() == reflect.Map {21+ ty = ty.Elem()22+ }23+ if ty.Kind() != reflect.Struct || seen[ty] {24+ return25+ }26+ seen[ty] = true27+ for f := range ty.Fields() {28+ if f.Anonymous {29+ walk(f.Type, path)30+ continue31+ }32+ name, _, _ := strings.Cut(f.Tag.Get("json"), ",")33+ if name == "" || name == "-" {34+ continue35+ }36+ if !strings.Contains(text, "`"+name+"`") && !strings.Contains(text, `"`+name+`"`) {37+ t.Errorf("the docs don't name the field %s.%s", path, name)38+ }39+ walk(f.Type, path+"."+name)40+ }41+ }42+ for _, v := range []any{fileFormat{}, fileCell{}, fileChart{}} {43+ walk(reflect.TypeOf(v), reflect.TypeOf(v).Name())44+ }45+ text, seen = macros, map[reflect.Type]bool{}46+ walk(reflect.TypeFor[fileMacro](), "fileMacro")47+}48+49+func readDoc(t *testing.T, path string) string {50+ t.Helper()51+ data, err := os.ReadFile(path)52+ if err != nil {53+ t.Fatal(err)54+ }55+ return string(data)56+}
A internal/ui/docs_test.go +210 -0
@@ -0,0 +1,210 @@
1+package ui2+3+import (4+ "os"5+ "path/filepath"6+ "regexp"7+ "strings"8+ "testing"9+ "unicode"10+11+ "github.com/FelineStateMachine/012/internal/ui/theme"12+)13+14+// The docs name menus, commands and keys; these tests keep what they name15+// true of the registry. scripts/doccheck checks links, the index and media.16+17+// userDocs reads README.md and docs/*.md with fenced code blocks and18+// inline code removed and each paragraph on one line, so a menu path19+// wrapped across lines reads whole. The generated function reference is20+// left out: it names no menus.21+func userDocs(t *testing.T) map[string]string {22+ t.Helper()23+ paths, err := filepath.Glob("../../docs/*.md")24+ if err != nil {25+ t.Fatal(err)26+ }27+ paths = append(paths, "../../README.md")28+ out := map[string]string{}29+ for _, p := range paths {30+ if filepath.Base(p) == "functions.md" {31+ continue32+ }33+ data, err := os.ReadFile(p)34+ if err != nil {35+ t.Fatal(err)36+ }37+ var b strings.Builder38+ fenced := false39+ for l := range strings.SplitSeq(string(data), "\n") {40+ if strings.HasPrefix(strings.TrimSpace(l), "```") {41+ fenced = !fenced42+ continue43+ }44+ if !fenced {45+ b.WriteString(l)46+ b.WriteByte(' ')47+ }48+ }49+ out[strings.TrimPrefix(p, "../../")] = strings.Join(strings.Fields(b.String()), " ")50+ }51+ return out52+}53+54+var menuPath = regexp.MustCompile(`\b(File|Edit|View|Insert|Format|Data|Help) > `)55+56+// TestDocsMenuPaths checks every menu path the docs name ("Data > Macros >57+// Record macro") leads to an item of 012's menu bar. A path after a58+// possessive ("Sheets' Insert > Pivot table") is another program's.59+func TestDocsMenuPaths(t *testing.T) {60+ for name, text := range userDocs(t) {61+ for _, loc := range menuPath.FindAllStringSubmatchIndex(text, -1) {62+ before := strings.TrimRight(text[:loc[0]], " *")63+ if strings.HasSuffix(before, "'") || strings.HasSuffix(before, "'s") || strings.HasSuffix(before, "’") {64+ continue65+ }66+ title := text[loc[2]:loc[3]]67+ if err := resolveMenuPath(title, text[loc[1]:]); err != "" {68+ end := min(len(text), loc[1]+40)69+ t.Errorf("%s: %q: %s", name, text[loc[0]:end], err)70+ }71+ }72+ }73+}74+75+func TestResolveMenuPath(t *testing.T) {76+ for _, c := range []struct{ title, rest, err string }{77+ {"Data", "Macros > Record macro, then", ""},78+ {"File", "Settings > Vim keys turns", ""},79+ {"Data", "Frequency table (column stats), or", ""},80+ {"Data", "Frequency table, or", ""},81+ {"Data", "Pivots > Edit", "no such item in the Data menu"},82+ {"Data", "Macros > Play macro", "no such item in the Macros menu"},83+ {"Insert", "Chart > Pie", "Chart has no submenu"},84+ } {85+ if got := resolveMenuPath(c.title, c.rest); got != c.err {86+ t.Errorf("%s > %s: %q, want %q", c.title, c.rest, got, c.err)87+ }88+ }89+}90+91+// resolveMenuPath follows rest, the text after "Title > ", through the92+// menu's items, and says what it couldn't find.93+func resolveMenuPath(title, rest string) string {94+ var items []menuItem95+ for _, d := range menuBar {96+ if d.title == title {97+ items = visibleItems(d.items)98+ }99+ }100+ for {101+ found := menuItemAt(items, rest)102+ if found == nil {103+ return "no such item in the " + title + " menu"104+ }105+ label := found.label()106+ if !labelPrefix(rest, label) {107+ label, _, _ = strings.Cut(label, " (")108+ }109+ rest = rest[len(label):]110+ if !strings.HasPrefix(rest, " > ") {111+ return ""112+ }113+ if found.items == nil {114+ return label + " has no submenu"115+ }116+ title, items, rest = label, found.items, rest[len(" > "):]117+ }118+}119+120+// menuItemAt is the item of items whose label, or its part before a121+// parenthesis, text starts with; the longest when several do.122+func menuItemAt(items []menuItem, text string) *menuItem {123+ var found *menuItem124+ for i, it := range items {125+ if it.sep {126+ continue127+ }128+ label := it.label()129+ short, _, _ := strings.Cut(label, " (")130+ for _, l := range []string{label, short} {131+ if labelPrefix(text, l) && (found == nil || len(l) > len(found.label())) {132+ found = &items[i]133+ }134+ }135+ }136+ return found137+}138+139+// labelPrefix reports whether text starts with label as whole words.140+func labelPrefix(text, label string) bool {141+ if !strings.HasPrefix(strings.ToLower(text), strings.ToLower(label)) {142+ return false143+ }144+ rest := text[len(label):]145+ return rest == "" || !unicode.IsLetter(rune(rest[0])) && !unicode.IsDigit(rune(rest[0]))146+}147+148+var (149+ colonCommand = regexp.MustCompile("`:([a-z_]+\\.[a-z_.0-9]+)`")150+ runCommandID = regexp.MustCompile(`run\("([a-z_.0-9]+)"`)151+)152+153+// TestDocsCommandIDs checks the command ids the docs name, on the vim154+// command line (`:settings.vim`) or in scripts (run("format.bold")),155+// are registered.156+func TestDocsCommandIDs(t *testing.T) {157+ for _, p := range []string{"../../docs/keys.md", "../../docs/macros.md"} {158+ data, err := os.ReadFile(p)159+ if err != nil {160+ t.Fatal(err)161+ }162+ for _, re := range []*regexp.Regexp{colonCommand, runCommandID} {163+ for _, m := range re.FindAllStringSubmatch(string(data), -1) {164+ if commands[m[1]] == nil {165+ t.Errorf("%s names command %q, which isn't registered", p, m[1])166+ }167+ }168+ }169+ }170+}171+172+// TestKeysDocCoversKeymap checks docs/keys.md lists every key bound to a173+// command, written as the menus and help show it.174+func TestKeysDocCoversKeymap(t *testing.T) {175+ data, err := os.ReadFile("../../docs/keys.md")176+ if err != nil {177+ t.Fatal(err)178+ }179+ doc := string(data)180+ for k, id := range keymap {181+ if commands[id] == nil {182+ continue183+ }184+ label := keyLabel(k)185+ re := regexp.MustCompile(`(^|[\s,|(/])` + regexp.QuoteMeta(label) + `($|[\s,|)/.;:])`)186+ if !re.MatchString(doc) {187+ t.Errorf("docs/keys.md doesn't list %s (%s, %s)", label, id, commands[id].title)188+ }189+ }190+ var vimKeys []string191+ for _, table := range []map[string]vimBinding{vimNormal, vimVisual} {192+ for k := range table {193+ vimKeys = append(vimKeys, k)194+ }195+ }196+ for k := range vimMotions {197+ if !isMoveKey(k) { // Sheets' own keys, listed above198+ vimKeys = append(vimKeys, k)199+ }200+ }201+ for _, k := range vimKeys {202+ if strings.Contains(k, "+") || len(k) > 2 {203+ if !strings.Contains(doc, keyLabel(k)) && !strings.Contains(doc, theme.KeyLabel(k)) {204+ t.Errorf("docs/keys.md doesn't list the vim key %s", k)205+ }206+ } else if !strings.Contains(doc, "`"+k+"`") {207+ t.Errorf("docs/keys.md doesn't list the vim key `%s`", k)208+ }209+ }210+}
A scripts/doccheck/main.go +271 -0
@@ -0,0 +1,271 @@
1+// Command doccheck keeps the documentation's structure sound, where a2+// machine can tell:3+//4+// - every relative link and image in Markdown points at a file that5+// exists, and every #anchor at a heading of that file;6+// - docs/README.md, the index, links every doc in docs/;7+// - every file in docs/media is shown by some doc, comes from a VHS tape8+// in demos/ (its Output or a Screenshot), and no GIF is over maxGIF;9+// - every tape in demos/ records something a doc shows.10+//11+// What the docs say about the program (keys, menus, commands, options) is12+// checked by tests next to the code they name: internal/ui's docs_test.go13+// and internal/config's.14+//15+// Usage: go run ./scripts/doccheck (from the repository root)16+package main17+18+import (19+ "bufio"20+ "fmt"21+ "io/fs"22+ "os"23+ "path"24+ "path/filepath"25+ "regexp"26+ "slices"27+ "strings"28+)29+30+// maxGIF keeps the README's recordings quick to load.31+const maxGIF = 1 << 2032+33+func main() {34+ docs, err := readDocs(".")35+ if err != nil {36+ fmt.Println("doccheck:", err)37+ os.Exit(1)38+ }39+ var problems []string40+ problems = append(problems, checkLinks(docs)...)41+ problems = append(problems, checkIndex(docs)...)42+ problems = append(problems, checkMedia(docs)...)43+ for _, p := range problems {44+ fmt.Println(p)45+ }46+ if len(problems) > 0 {47+ fmt.Printf("doccheck: %d problems\n", len(problems))48+ os.Exit(1)49+ }50+}51+52+// doc is a Markdown file: its links and the anchors of its headings.53+type doc struct {54+ path string // slash-separated, relative to the root55+ links []link56+ anchors map[string]bool57+}58+59+type link struct {60+ line int61+ target string62+}63+64+// readDocs reads every Markdown file under root, skipping hidden65+// directories, test data and generated output.66+func readDocs(root string) (map[string]*doc, error) {67+ docs := map[string]*doc{}68+ err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {69+ if err != nil {70+ return err71+ }72+ name := d.Name()73+ if d.IsDir() {74+ if p != root && (strings.HasPrefix(name, ".") || name == "testdata" || name == "node_modules" || name == "out") {75+ return filepath.SkipDir76+ }77+ return nil78+ }79+ if filepath.Ext(name) != ".md" {80+ return nil81+ }82+ dc, err := parseDoc(p)83+ if err != nil {84+ return err85+ }86+ docs[dc.path] = dc87+ return nil88+ })89+ return docs, err90+}91+92+var (93+ linkRE = regexp.MustCompile(`!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)`)94+ inlineCode = regexp.MustCompile("`[^`]*`")95+ headingRE = regexp.MustCompile(`^#{1,6}\s+(.*?)\s*#*\s*$`)96+ anchorTag = regexp.MustCompile(`<a\s+(?:name|id)="([^"]+)"`)97+)98+99+func parseDoc(p string) (*doc, error) {100+ f, err := os.Open(p)101+ if err != nil {102+ return nil, err103+ }104+ defer f.Close()105+ dc := &doc{path: filepath.ToSlash(filepath.Clean(p)), anchors: map[string]bool{}}106+ seen := map[string]int{}107+ fenced := false108+ sc := bufio.NewScanner(f)109+ sc.Buffer(make([]byte, 1<<20), 1<<20)110+ for n := 1; sc.Scan(); n++ {111+ l := sc.Text()112+ if strings.HasPrefix(strings.TrimSpace(l), "```") {113+ fenced = !fenced114+ continue115+ }116+ if fenced {117+ continue118+ }119+ if m := headingRE.FindStringSubmatch(l); m != nil {120+ s := slug(m[1])121+ if k := seen[s]; k > 0 {122+ dc.anchors[fmt.Sprintf("%s-%d", s, k)] = true123+ } else {124+ dc.anchors[s] = true125+ }126+ seen[s]++127+ }128+ for _, m := range anchorTag.FindAllStringSubmatch(l, -1) {129+ dc.anchors[m[1]] = true130+ }131+ for _, m := range linkRE.FindAllStringSubmatch(inlineCode.ReplaceAllString(l, "``"), -1) {132+ dc.links = append(dc.links, link{n, m[1]})133+ }134+ }135+ return dc, sc.Err()136+}137+138+// slug is the anchor GitHub gives a heading: lower case, spaces as139+// hyphens, punctuation other than hyphens and underscores dropped.140+func slug(heading string) string {141+ heading = strings.ToLower(inlineCode.ReplaceAllStringFunc(heading, func(s string) string { return strings.Trim(s, "`") }))142+ var b strings.Builder143+ for _, r := range heading {144+ switch {145+ case r == ' ':146+ b.WriteByte('-')147+ case r == '-' || r == '_' || r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r > 127 && isLetter(r):148+ b.WriteRune(r)149+ }150+ }151+ return b.String()152+}153+154+func isLetter(r rune) bool { return strings.ToLower(string(r)) != strings.ToUpper(string(r)) }155+156+// checkLinks reports relative links to missing files or anchors.157+func checkLinks(docs map[string]*doc) []string {158+ var out []string159+ for _, p := range sortedKeys(docs) {160+ dc := docs[p]161+ for _, l := range dc.links {162+ t := l.target163+ if strings.Contains(t, "://") || strings.HasPrefix(t, "mailto:") {164+ continue165+ }166+ file, anchor, _ := strings.Cut(t, "#")167+ target := dc.path168+ if file != "" {169+ target = path.Clean(path.Join(path.Dir(dc.path), file))170+ }171+ where := fmt.Sprintf("%s:%d: %s", dc.path, l.line, t)172+ if _, err := os.Stat(filepath.FromSlash(target)); err != nil {173+ out = append(out, where+": no such file")174+ continue175+ }176+ if anchor == "" {177+ continue178+ }179+ td, ok := docs[target]180+ if !ok {181+ out = append(out, where+": an anchor into a file that isn't Markdown")182+ } else if !td.anchors[anchor] {183+ out = append(out, where+": no heading with that anchor in "+target)184+ }185+ }186+ }187+ return out188+}189+190+// checkIndex reports docs the index doesn't link.191+func checkIndex(docs map[string]*doc) []string {192+ index, ok := docs["docs/README.md"]193+ if !ok {194+ return []string{"docs/README.md: missing"}195+ }196+ linked := map[string]bool{}197+ for _, l := range index.links {198+ file, _, _ := strings.Cut(l.target, "#")199+ linked[path.Clean(path.Join("docs", file))] = true200+ }201+ var out []string202+ for _, p := range sortedKeys(docs) {203+ if path.Dir(p) == "docs" && p != "docs/README.md" && !linked[p] {204+ out = append(out, "docs/README.md: doesn't link "+p)205+ }206+ }207+ return out208+}209+210+var (211+ tapeOutput = regexp.MustCompile(`(?m)^Output\s+"?out/([^"\s]+)"?`)212+ tapeScreenshot = regexp.MustCompile(`(?m)^Screenshot\s+"?out/stills/([^"\s]+)"?`)213+)214+215+// checkMedia reports media no doc shows, media no tape makes, GIFs over216+// maxGIF and tapes whose recordings no doc shows.217+func checkMedia(docs map[string]*doc) []string {218+ shown := map[string]bool{}219+ for _, dc := range docs {220+ for _, l := range dc.links {221+ file, _, _ := strings.Cut(l.target, "#")222+ if file != "" && !strings.Contains(file, "://") {223+ shown[path.Clean(path.Join(path.Dir(dc.path), file))] = true224+ }225+ }226+ }227+ tapes, _ := filepath.Glob("demos/*.tape")228+ madeBy := map[string]string{} // media file name -> tape229+ var out []string230+ for _, t := range tapes {231+ src, err := os.ReadFile(t)232+ if err != nil {233+ out = append(out, t+": "+err.Error())234+ continue235+ }236+ var made []string237+ for _, re := range []*regexp.Regexp{tapeOutput, tapeScreenshot} {238+ for _, m := range re.FindAllStringSubmatch(string(src), -1) {239+ made = append(made, m[1])240+ madeBy[m[1]] = t241+ }242+ }243+ if !slices.ContainsFunc(made, func(name string) bool { return shown["docs/media/"+name] }) {244+ out = append(out, t+": no doc shows its recording or stills (docs/media/"+strings.Join(made, ", docs/media/")+")")245+ }246+ }247+ media, _ := filepath.Glob("docs/media/*")248+ for _, m := range media {249+ m = filepath.ToSlash(m)250+ name := path.Base(m)251+ if !shown[m] {252+ out = append(out, m+": no doc shows it")253+ }254+ if madeBy[name] == "" {255+ out = append(out, m+": no tape in demos/ makes it (Output or Screenshot)")256+ }257+ if fi, err := os.Stat(m); err == nil && path.Ext(m) == ".gif" && fi.Size() > maxGIF {258+ out = append(out, fmt.Sprintf("%s: %d KB, over %d KB", m, fi.Size()>>10, maxGIF>>10))259+ }260+ }261+ return out262+}263+264+func sortedKeys[V any](m map[string]V) []string {265+ keys := make([]string, 0, len(m))266+ for k := range m {267+ keys = append(keys, k)268+ }269+ slices.Sort(keys)270+ return keys271+}
M scripts/lint.sh +24 -0