cat / 012
clone

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

Merge branch 'main' into the locale branch

Dami committed 27bca8f
# Conflicts:
#	docs/reference/config.md
#	internal/config/registry.go
#	internal/ui/rowtext/rowtext.go
commit
27bca8f2d8d879433fa3f84d24911264cb7f36a3
parent
411dbe1b18da1f5caa253bdc4a0ddc73ab4c2f97
parent
63b05efb0c62cd1dab9126c0835457804415a19e
author
Dami <dami.etoile@gmail.com>
94 files changed, +2966 -510
M.gitignore1+
MMakefile16+-
MROADMAP.md26+-
Mcmd/012/configcmd.go2+
Mcmd/012/main.go6+-
Mcmd/012/main_test.go12+
Acmd/012/version.go43++++
Mdeploy/observability/docker-compose.yml2+-
Mdeploy/observability/grafana/dashboards/runtime.json122++++++++++-
Mdocs/contributing/README.md1+
Mdocs/contributing/architecture.md27++-
Mdocs/contributing/extending.md22+-
Mdocs/contributing/limits.md212+++++++++--------
Mdocs/contributing/observability.md56++++-
Adocs/contributing/releasing.md62+++++
Mdocs/contributing/ux.md32++-
Mdocs/files/README.md4+-
Mdocs/reference/config.md8+-
Mdocs/sheets/editing.md5+-
Mdocs/sheets/rules.md19+-
Mdocs/terminal/README.md2-
Mdocs/terminal/ssh.md10+-
Mdocs/terminal/themes.md27++-
Ae2e/screenthemes_test.go25++
Me2e/testdata/screens/rules-panel.html2+-
Me2e/testdata/screens/spill-light.html6+-
Me2e/testdata/screens/spill.html6+-
Ae2e/testdata/screens/theme-high-contrast-light.html30++
Ae2e/testdata/screens/theme-high-contrast-menu.html30++
Ae2e/testdata/screens/theme-high-contrast.html30++
Me2e/testdata/screens/theme-picker.html2+-
Minternal/config/registry.go10+-
Minternal/sheet/cellformat.go14+-
Minternal/sheet/chart.go6+-
Minternal/sheet/clipfmt.go4+-
Minternal/sheet/evaluate.go13+-
Minternal/sheet/file.go4+-
Minternal/sheet/filter.go5+-
Minternal/sheet/history.go6+-
Minternal/sheet/historysize.go12+-
Minternal/sheet/historysize_test.go2+-
Minternal/sheet/insertbook.go2+-
Minternal/sheet/lines.go23+-
Minternal/sheet/looks.go8+-
Minternal/sheet/move.go3+-
Minternal/sheet/notes.go6+-
Minternal/sheet/occupancy.go31++-
Minternal/sheet/ops.go20+-
Minternal/sheet/pivotlayout.go2+-
Minternal/sheet/rangememo.go4+-
Minternal/sheet/recalc.go9+-
Minternal/sheet/remote.go2+-
Ainternal/sheet/rulemove.go117++++++++++
Ainternal/sheet/rulemove_test.go75++++++
Minternal/sheet/series.go2+-
Minternal/sheet/sheet.go46+--
Ainternal/sheet/slot.go284++++++++++++++++++++++++
Ainternal/sheet/slot_test.go144++++++++++++
Minternal/sheet/stats.go20+-
Minternal/sheet/stats_test.go8+-
Minternal/sheet/store.go362+++++++++++++++++++++++++-----
Minternal/sheet/store_test.go101++++++--
Minternal/sheet/stress_test.go68+++++-
Minternal/sheet/validation.go2+-
Ainternal/sheet/validcheck.go53++++
Minternal/sheet/workbook.go4+-
Minternal/stress/gen.go8+
Minternal/ui/clipboard.go8+
Minternal/ui/columns.go4+-
Minternal/ui/edit.go8+-
Minternal/ui/fill.go3+
Minternal/ui/links.go2+-
Ainternal/ui/monochrome_test.go302+++++++++++++++++++++++++
Minternal/ui/rowtext/rowtext.go40++-
Minternal/ui/rules.go50++++-
Minternal/ui/theme/contrast.go1+
Ainternal/ui/theme/highcontrast.go92++++++++
Ainternal/ui/theme/monochrome.go32+++
Minternal/ui/theme/palette.go15+-
Minternal/ui/theme/scheme.go57+++-
Minternal/ui/theme/scheme_test.go99+++++---
Ainternal/ui/theme/standout_test.go39+++
Minternal/ui/theme/theme.go40+++-
Minternal/ui/themepicker/themepicker.go2+
Ainternal/ui/validpaste_test.go88+++++++
Minternal/ui/view.go2+-
Minternal/ui/vim.go2+-
Ascripts/dist.sh63+++++
Mscripts/doccheck/tree.go45++-
Mscripts/stress/load.sql12+-
Ascripts/stress/release.sql71++++++
Mscripts/stress/report.sh59++++-
Mscripts/stress/report.sql8+-
Mscripts/stress/run.sh4+-
M .gitignore +1 -0
M Makefile +12 -4
@@ -7,7 +7,7 @@ GHOSTTY_SRC := $(DEPS)/ghostty-src
77 GHOSTTY_OUT := $(DEPS)/ghostty88 GHOSTTY_STAMP := $(GHOSTTY_OUT)/.built-$(GHOSTTY_COMMIT)99 10-.PHONY: check lint build run test fuzz e2e screens oracle demos libghostty clean stress stress-data stress-report obs-up obs-down obs-status stress-load stress-e2e10+.PHONY: check lint build run test fuzz e2e screens oracle demos libghostty clean stress stress-data stress-report obs-up obs-down obs-status stress-load stress-e2e dist1111 1212 build:1313 CGO_ENABLED=0 go build -o bin/012 ./cmd/012
@@ -15,6 +15,12 @@ build:
1515 run: build1616 ./bin/0121717 18+# Release archives: 012 cross-compiled (pure Go, -trimpath, stamped with19+# VERSION) for macOS, Linux and Windows on amd64 and arm64 into dist/,20+# with SHA256SUMS. Nothing is uploaded; see docs/contributing/releasing.md.21+dist:22+ VERSION=$(VERSION) scripts/dist.sh23+1824 test:1925 go test ./...2026
@@ -52,19 +58,21 @@ screens: $(GHOSTTY_STAMP)
5258 # Stress: fetch real datasets into .deps/stress, run the benchmarks built5359 # with -tags stress, print a summary and record the run in5460 # .deps/stress/results/runs.jsonl. BENCH, BENCHTIME and PKGS narrow it;55-# see docs/limits.md. Plain `go test ./...` never runs these.61+# see docs/contributing/limits.md. Plain `go test ./...` never runs these.5662 stress:5763 scripts/stress/run.sh5864 5965 stress-data:6066 scripts/stress-data.sh6167 62-# Compare the latest run with the previous one and a baseline (DuckDB).68+# Compare the latest run with the previous one, a baseline and the last69+# tagged release's run (DuckDB); exits 1 on a regression against the70+# release past THRESHOLD (0.10) plus the benchmark's noise.6371 stress-report:6472 scripts/stress/report.sh6573 6674 # The observability stack (deploy/observability): an OpenTelemetry67-# Collector, ClickHouse and Grafana in Docker. See docs/observability.md.75+# Collector, ClickHouse and Grafana in Docker. See docs/contributing/observability.md.6876 obs-up:6977 scripts/obs.sh up7078
M ROADMAP.md +11 -15
@@ -19,7 +19,7 @@ Sizes: S (a day or two), M (about a week), L (weeks).
1919 | Item | Why | Size |2020 |---|---|---|2121 | 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 |22+| Conditional formatting and validation, what's left: data bars and icon sets, "top 10", "duplicate values", date periods (this week, last month), rules moving with cells cut to another sheet, a dropdown's chip look, and custom checkbox values | Excel's other rule types come in as notes | M |2323 | Remaining overlays get narrow hosts: filter picker, sort and choice bars, chart editor, shortcuts, named ranges, cell entry and prompts | Components testable without the model ([Architecture](docs/contributing/architecture.md#the-ui)) | M |2424 2525 ### 2. Spreadsheet features Sheets users reach for
@@ -32,14 +32,14 @@ Sizes: S (a day or two), M (about a week), L (weeks).
3232 ### 3. Scale3333 3434 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 [Bounds of support](docs/contributing/limits.md#what-would-raise-the-bounds).35+`max-cells` budget (ten million cells) rather than the grid. The budget36+rises in measured steps; see [Bounds of support](docs/contributing/limits.md#what-would-raise-the-bounds).3737 3838 | Item | Result | Size |3939 |---|---|---|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 |4140 | A streaming or binary `.012` format next to the readable JSON one | Open and save scale with the data | M |42-| Smaller undo steps: formatting changes as diffs | More history in the same memory | S |41+| Smaller undo steps: plain cells' before-images as slots, formatting changes as diffs | More history in the same memory; clearing a full sheet costs what the sheet does | S to M |42+| Compact spilled and pivot cells: derived cells in the compact slot form instead of whole `Cell`s | Large spills and pivots cost what plain cells cost ([Bounds of support](docs/contributing/limits.md#what-would-raise-the-bounds)) | M |4343 | Linked, paged read-only ranges over Parquet and SQLite that feed pivots and formulas by streaming | Sources too big for any grid | L |4444 4545 ### 4. Macros, keys and the terminal
@@ -51,15 +51,6 @@ steps; see [Bounds of support](docs/contributing/limits.md#what-would-raise-the-
5151 | Hold-to-preview and Shift+Enter on terminals with the kitty keyboard protocol (`View.KeyboardEnhancements`) | S |5252 | Sixel chart images, redrawn on resize, for terminals without kitty graphics | M |5353 54-### 5. Distribution and upkeep55-56-| Item | Size |57-|---|---|58-| Prebuilt binaries attached to version tags, built locally (the GitHub repo is hosting only); tags carry release notes in their message | S |59-| `make stress-report` thresholds that flag regressions over a set percentage against the last release | S |60-| Grafana: a trace panel for the nested spans | S |61-| Accessibility: a high-contrast theme, and an audit that every state reads without color (a [UX rule](docs/contributing/ux.md#visual-rules)) | S |62-6354 ## Later: sharing a live sheet (shelved)6455 6556 Explored, not scheduled. `012 serve` gives each SSH session its own
@@ -88,6 +79,7 @@ by a gateway dialing the iroh ticket.
8879 - Opt-in decimal arithmetic for money: [Decimal arithmetic](docs/formulas/decimal.md)8980 - A locale per file, as Sheets' File > Settings > Locale: decimal commas, date order, currency and `;` in formulas, typed and shown while files store en-US's form: [Locale](docs/sheets/locale.md)9081 - An Excel-sized grid with a `max-cells` budget; operations cost the data, not the grid: [Bounds of support](docs/contributing/limits.md#sheet-size)82+- Compact column storage and a ten-million-cell `max-cells` budget: [Bounds of support](docs/contributing/limits.md#sheet-size)9183 9284 **Finding and using features**9385
@@ -100,7 +92,7 @@ by a gateway dialing the iroh ticket.
10092 10193 - Freeze, multi-column sort, filters with value pickers and conditions: [Freeze, sort and filter](docs/sheets/sort-filter.md#freeze)10294 - Find and replace, with regular expressions, across sheets: [Find and replace](docs/sheets/find-replace.md)103-- Conditional formatting (single-color rules, color scales) and data validation (dropdowns, checkboxes, bounds): [Conditional formatting and data validation](docs/sheets/rules.md#conditional-formatting)95+- Conditional formatting (single-color rules, color scales) and data validation (dropdowns, checkboxes, bounds), checked on pastes and fills and moving with cut and paste: [Conditional formatting and data validation](docs/sheets/rules.md#conditional-formatting)10496 - Pivot tables and frequency tables, live, with subtotals and renamed values: [Pivot tables](docs/sheets/pivots.md)10597 - Notes on cells: [Notes and protection](docs/sheets/notes-protection.md#notes)10698 - Protected sheets and ranges that warn on edit: [Notes and protection](docs/sheets/notes-protection.md#protected-sheets-and-ranges)
@@ -112,6 +104,7 @@ by a gateway dialing the iroh ticket.
112104 113105 - System clipboard, hyperlinks, curly error underlines, cursor shapes, progress and notifications, light and dark: [The terminal](docs/terminal/README.md#terminal-features-012-uses)114106 - Color schemes and a config file: [Themes](docs/terminal/themes.md), [Configuration](docs/reference/config.md)107+- A high-contrast theme at WCAG AAA, and every state readable without color: [Themes](docs/terminal/themes.md#high-contrast), [UX](docs/contributing/ux.md#reading-without-color)115108 - `012 serve` over SSH, with files on the ssh command line and recovery of unsaved work: [Serving over SSH](docs/terminal/ssh.md)116109 117110 **Files**
@@ -121,3 +114,6 @@ by a gateway dialing the iroh ticket.
121114 **Upkeep**122115 123116 - `make check` before every push; demo tapes (`make demos`); annotated version tags with release notes (v0.2.0 onward; v0.1.0 remains on the Go module proxy): [Testing](docs/contributing/testing.md)117+- A release checklist and `make dist`, release archives cross-compiled locally with SHA256SUMS: [Releasing](docs/contributing/releasing.md)118+- `make stress-report` flags regressions against the last release's run, allowing for noise: [Observability](docs/contributing/observability.md#regressions-against-the-last-release)119+- Grafana: recent traces and a trace view of the nested spans: [Observability](docs/contributing/observability.md#the-stack)
M cmd/012/configcmd.go +2 -0
M cmd/012/main.go +5 -1
M cmd/012/main_test.go +12 -0
A cmd/012/version.go +43 -0
M deploy/observability/docker-compose.yml +1 -1
M deploy/observability/grafana/dashboards/runtime.json +118 -4
@@ -409,7 +409,7 @@
409409 "id": 9,410410 "type": "table",411411 "title": "Slowest spans (OTLP traces)",412- "description": "The slowest spans 012 sent over OTLP; each is the root of its own trace, and the log record of the same event carries its TraceId",412+ "description": "The slowest spans 012 sent over OTLP, roots and nested ones; a trace id opens that trace in the trace view below",413413 "datasource": {414414 "type": "grafana-clickhouse-datasource",415415 "uid": "clickhouse"
@@ -435,17 +435,131 @@
435435 ],436436 "fieldConfig": {437437 "defaults": {},438- "overrides": []438+ "overrides": [439+ {440+ "matcher": {441+ "id": "byName",442+ "options": "trace"443+ },444+ "properties": [445+ {446+ "id": "links",447+ "value": [448+ {449+ "title": "Show this trace",450+ "url": "/d/o12-runtime/012-runtime?var-trace=${__value.raw}&${__url_time_range}"451+ }452+ ]453+ }454+ ]455+ }456+ ]457+ },458+ "options": {459+ "showHeader": true460+ }461+ },462+ {463+ "id": 10,464+ "type": "table",465+ "title": "Recent traces with nested spans",466+ "description": "Traces of more than one span, newest first: the root span, its command id, how many spans it holds, which spans nest in it and how long the root took. A trace id opens it in the trace view below",467+ "datasource": {468+ "type": "grafana-clickhouse-datasource",469+ "uid": "clickhouse"470+ },471+ "gridPos": {472+ "x": 0,473+ "y": 39,474+ "w": 24,475+ "h": 8476+ },477+ "targets": [478+ {479+ "datasource": {480+ "type": "grafana-clickhouse-datasource",481+ "uid": "clickhouse"482+ },483+ "editorType": "sql",484+ "format": 1,485+ "queryType": "table",486+ "refId": "A",487+ "rawSql": "SELECT min(Timestamp) AS time, anyIf(SpanName, ParentSpanId = '') AS root, anyIf(SpanAttributes['id'], ParentSpanId = '') AS command, count() AS spans, arrayStringConcat(arraySort(groupUniqArrayIf(SpanName, ParentSpanId != '')), ', ') AS nested, round(maxIf(Duration, ParentSpanId = '') / 1e6, 2) AS ms, countIf(StatusCode = 'Error') AS errors, TraceId AS trace FROM otel.otel_traces WHERE $__timeFilter(Timestamp) AND ServiceName = '012' GROUP BY TraceId HAVING spans > 1 ORDER BY time DESC LIMIT 100"488+ }489+ ],490+ "fieldConfig": {491+ "defaults": {},492+ "overrides": [493+ {494+ "matcher": {495+ "id": "byName",496+ "options": "trace"497+ },498+ "properties": [499+ {500+ "id": "links",501+ "value": [502+ {503+ "title": "Show this trace",504+ "url": "/d/o12-runtime/012-runtime?var-trace=${__value.raw}&${__url_time_range}"505+ }506+ ]507+ }508+ ]509+ }510+ ]439511 },440512 "options": {441513 "showHeader": true442514 }515+ },516+ {517+ "id": 11,518+ "type": "traces",519+ "title": "Trace ${trace}",520+ "description": "The spans of one trace as a tree with their timing: the trace id in the Trace id box at the top, or the latest trace with nested spans when it is empty",521+ "datasource": {522+ "type": "grafana-clickhouse-datasource",523+ "uid": "clickhouse"524+ },525+ "gridPos": {526+ "x": 0,527+ "y": 47,528+ "w": 24,529+ "h": 14530+ },531+ "targets": [532+ {533+ "datasource": {534+ "type": "grafana-clickhouse-datasource",535+ "uid": "clickhouse"536+ },537+ "editorType": "sql",538+ "format": 3,539+ "queryType": "traces",540+ "refId": "A",541+ "rawSql": "SELECT TraceId AS traceID, SpanId AS spanID, ParentSpanId AS parentSpanID, ServiceName AS serviceName, SpanName AS operationName, multiply(toUnixTimestamp64Nano(Timestamp), 0.000001) AS startTime, multiply(Duration, 0.000001) AS duration, arrayMap(key -> map('key', key, 'value', SpanAttributes[key]), mapKeys(SpanAttributes)) AS tags, arrayMap(key -> map('key', key, 'value', ResourceAttributes[key]), mapKeys(ResourceAttributes)) AS serviceTags, if(StatusCode IN ('Error', 'STATUS_CODE_ERROR'), 2, 0) AS statusCode FROM otel.otel_traces WHERE ServiceName = '012' AND TraceId = if('${trace}' != '', '${trace}', (SELECT TraceId FROM otel.otel_traces WHERE $__timeFilter(Timestamp) AND ServiceName = '012' AND ParentSpanId != '' ORDER BY Timestamp DESC LIMIT 1)) ORDER BY Timestamp LIMIT 1000"542+ }543+ ],544+ "options": {}443545 }444546 ],445547 "templating": {446- "list": []548+ "list": [549+ {550+ "name": "trace",551+ "label": "Trace id",552+ "type": "textbox",553+ "query": "",554+ "current": {555+ "text": "",556+ "value": ""557+ },558+ "description": "The trace the trace view shows; empty shows the latest trace with nested spans"559+ }560+ ]447561 },448562 "annotations": {449563 "list": []450564 }451-}\ No newline at end of file565+}
M docs/contributing/README.md +1 -0
M docs/contributing/architecture.md +19 -8
@@ -107,13 +107,24 @@ was typed, its parsed expression, its computed value, and its format and
107107 style.108108 109109 - **Cells.** Each sheet keeps its cells in a `cellStore` (`store.go`):110- get, set, delete, count, iterate all or a range. Nothing else touches111- the map beneath, so the representation can change without the rest of112- the engine noticing. Beside it, occupancy indexes (`occupancy.go`,110+ get, set, delete, count, iterate all or a range, and narrower reads111+ (a value, presence, formatting, the formula cells). Nothing else112+ touches the representation beneath. Occupancy indexes (`occupancy.go`,113113 bitmaps of 1024 rows per column) record which cells are stored and114114 which have contents, so a range of the million-row grid is read at the115- cost of what it holds. Formats of whole columns and rows, and of the116- whole sheet, live on the lines (`lines.go`); a cell falls back on them.115+ cost of what it holds; the stored index's blocks hold the cells116+ themselves, a 16-byte slot each in row order (`slot.go`). A plain cell117+ (a number, boolean or text as typed, with a format and style) lives in118+ its slot, with its text in a table of strings kept once each, its119+ formatting in a table of looks, and its input only when that isn't120+ the value's own text ("1.50" is kept as 1.5 printed with two121+ decimals). Formulas, notes, and what pivots and spills write are whole122+ `Cell`s in a side table, which recalculation updates in place. `get`123+ hands out a plain cell as a `Cell` made for the caller, a copy whose124+ changes reach nothing; `set` is the one way to change a cell. What125+ each costs is in [Bounds of support](limits.md#sheet-size). Formats of126+ whole columns and rows, and of the whole sheet, live on the lines127+ (`lines.go`); a cell falls back on them.117128 Copy, paste and move carry the formatting cells show (`clipfmt.go`):118129 whole lines as line formats, blocks as the cells' own. A line's format119130 changing recalculates the formulas reading any cell of it, found through
@@ -288,9 +299,9 @@ setting (`SetDecimal`, which marks formulas with `functions.Decimalize`).
288299 The package boundaries leave the bounds in [Bounds of support](limits.md) room to289300 move without touching callers:290301 291-- **Storage.** Compact cell storage (column blocks of values, formulas and292- formats in side tables) replaces `cellStore`'s map; its methods are the293- whole contract.302+- **Storage.** `cellStore`'s methods are the whole contract, so what is303+ kept whole or in a slot can change (spilled and pivot cells in slots,304+ plain before-images in the history) without touching callers.294305 - **Range reads.** Shared range results, prefix sums for running totals295306 and column-block scans go behind the engine's `Scan` and `RangeAgg`,296307 the places functions read ranges.
M docs/contributing/extending.md +15 -7
@@ -118,13 +118,21 @@ modified.
118118 119119 Code reads and writes cells through a narrow API (`cellStore` in120120 `internal/sheet/store.go`: get, set, delete, count, iterate everything or121-a range, a column being a range), not the map underneath. That lets the122-store change shape (compact column blocks, side tables for formulas and123-formats; see [Bounds of support](limits.md)) without touching the rest of the124-engine. Beside the map, occupancy indexes (`occupancy.go`) say which rows125-of each column hold a cell, and which hold contents, so a range yields its126-cells in row order at the cost of what it holds, and the used range, data127-edges and the next filled cell are found without scanning.121+a range, a column being a range), never the representation underneath:122+column blocks of 16-byte slots holding plain cells' values, with text,123+formats and whole formula cells in side tables (see124+[Bounds of support](limits.md#sheet-size)). `get` returns a `*Cell`:125+a formula's own, which recalculation updates, or for a plain cell one126+made for the caller, which it may keep but whose changes reach nothing;127+every change goes through `set`. Making that `Cell` costs an allocation128+or three, so code that visits many cells asks for only what it needs:129+`value` and `peek` for values, `has` and `filledAt` for presence,130+`look` for formatting, `richAt` and `richCells` for formulas and notes,131+and the key iterators (`keys`, `keysIn`, `anyKeysIn`) for addresses. The132+stored occupancy index (`occupancy.go`) holds the blocks; a second says133+which rows hold contents, so a range yields its cells in row order at134+the cost of what it holds, and the used range, data edges and the next135+filled cell are found without scanning.128136 129137 ### 7. Read ranges as ranges130138
M docs/contributing/limits.md +111 -101
@@ -25,51 +25,60 @@ it lags, and past a second it stalls.
2525 2626 | Dimension | Comfortable | Degraded | Unsupported | Dominant cost |2727 |---|---|---|---|---|28-| Sheet size | A grid of 1,048,576 x 16,384 (A..XFD), with up to `max-cells` cells (two million by default): navigation, drawing and every command cost what the cells cost, not the grid | Loading and saving two million cells: 1 to 2 s; 600 MB of heap | More than `max-cells` cells: imports keep whole rows up to it and say what they dropped; larger pastes and fills are refused | Heap per cell (about 300 B), JSON file format |29-| Incremental recalc | A change that makes formulas read under about 500,000 cells in total (fan-out, chains and volatiles of 8192 cells: 1 to 2 ms); 1000 SUMs over a full column: 0.75 ms; 8192 running totals: 2.8 ms | 60 criteria functions (SUMIF, COUNTIFS, AVERAGEIF) over whole columns of 8192 rows: 37 ms an edit | | About 20 ns per cell read: two map lookups |30-| Full recalc | Any sheet: under 40 ms for 213 k numbers; 1000 full-column SUMs 1 ms; 8192 running totals 2.5 ms | 60 whole-column criteria functions over 8192 rows: 59 ms | | Same as above |28+| Sheet size | A grid of 1,048,576 x 16,384 (A..XFD), with up to `max-cells` cells (ten million by default): navigation, drawing and every command cost what the cells cost, not the grid; ten million numbers take 205 MB and build in 2.2 s | Opening a `.012` file of ten million cells: 8 s and 2.7 GB at its peak; saving it: 3.7 s | More than `max-cells` cells: imports keep whole rows up to it and say what they dropped; larger pastes and fills are refused | JSON file format, decoded whole; heap per cell (about 20 B for numbers, 20 to 60 B for real data, 750 B for formulas) |29+| Incremental recalc | A change that makes formulas read under about 500,000 cells in total (fan-out, chains and volatiles of 8192 cells: 1.3 to 1.9 ms); 1000 SUMs over a full column: 0.53 ms; 8192 running totals: 2.7 ms | 60 criteria functions (SUMIF, COUNTIFS, AVERAGEIF) over whole columns of 8192 rows: 29 ms an edit | | About 3 ns per cell read: an index into the column's block |30+| Full recalc | Any sheet: numbers and text hold their values and cost nothing; 1000 full-column SUMs 0.5 ms; 8192 running totals 1.8 ms | 60 whole-column criteria functions over 8192 rows: 44 ms | | Same as above |3131 | Rendering | Any sheet at up to 200 x 60: 1 ms a frame; 400 x 120: 4.5 ms; a color scale on every cell shown adds 0.4 ms at 200 x 60 | 20 charts at 400 x 120: 7 ms | | View building styled strings, then Bubble Tea parsing and diffing them |32-| Selection statistics | Any selection: extending one over all 2 M cells costs 0.4 ms a key | | | Per column with data, 1024 or 64 rows at a time from an index on the blocks of filled cells, plus the rows at the selection's ends |33-| Imports | CSV, SQLite, Parquet: 1 to 3 M cells/s (two million cells in about 1 s); XLSX numbers or text: 0.7 to 1.1 M cells/s | XLSX with formulas: 0.4 to 0.6 M cells/s | Data past `max-cells` or the grid (dropped, with a note); XLSX files past the reader's limits (refused) | Building cells one at a time; XML decoding; XLSX formula translation |34-| Undo | One step of any size: undo costs what the edit cost | History capped at 100 steps and 256 MB of before-images: 100 whole-column steps hold 250 MB | | Whole-cell before-images, about 300 B per cell per step |32+| Selection statistics | Any selection: extending one over all 2.1 M cells of 8192 x 256 costs 0.3 ms a key | | | Per column with data, 1024 or 64 rows at a time from an index on the blocks of filled cells, plus the rows at the selection's ends |33+| Imports | CSV, SQLite, Parquet: 2 to 4 M cells/s (a million cells in 0.25 to 0.4 s); XLSX numbers or text: 0.8 to 1.4 M cells/s | XLSX with formulas: 0.4 to 0.6 M cells/s | Data past `max-cells` or the grid (dropped, with a note); XLSX files past the reader's limits (refused) | Building cells one at a time; XML decoding; XLSX formula translation |34+| Undo | One step of any size: undo costs what the edit cost | History capped at 100 steps and 256 MB of before-images: 100 whole-column steps hold 280 MB | | Whole-cell before-images, about 300 B per cell per step |3535 | JEV | 4000 JEV cells: 4 us of CPU per answer, 15 ms to answer them all | | | An answer recalculates the cells that asked it; answers within a frame recalculate together |3636 | Formula depth | 1000 nested parentheses or IFs: under 0.5 ms; chains of formulas through every cell of a sheet | | More than 1024 levels of nesting in one formula: a parse error | Recursive parser; evaluation puts off cells past 65,536 levels |3737 | Macros | Replaying 1000 recorded actions: 2.2 ms, one undo step; a script's call to the sheet: about 1.4 us | | Scripts past 10 M Starlark steps: stopped, with the line | One message per call to the sheet, served in batches on the UI goroutine |3838 | Find, filter, sort, fill | Filter or sort 8191 rows: 1 to 32 ms; find over 213 k cells: 38 ms; fill 8192 rows: 4 ms | Replace all over 213 k cells: 280 ms | | Per-cell string conversion and regexp |3939 | SSH sessions (`012 serve`) | 50 sessions typing at once: frames within one frame interval (p95 8.7 ms), 1.3 MiB per session plus its sheets (upper bound) | | More than `--max-sessions` (8 by default): turned away | Bubble Tea's 120 fps pacing; per session, the terminal's cell buffers |40-| Pivot tables | A pivot over 8191 rows recomputes after an edit to its source in 1 to 4 ms | | Results past the grid (the pivot shows #REF!) | Reading each source cell of its fields: a map lookup each |41-| Arrays and spills | FILTER, SORT or UNIQUE over 8192 rows: about 0.1 ms each per edit; 1000 of them spilling 516 k cells, an edit recomputing 500: 55 ms | Full recalculation of those 1000: 204 ms | An array past `maxArray` values (2,097,152) stored: `#VALUE!`; a spill past the sheet's edge or `max-cells` cells: `#REF!` | Computing each array's values; writing only the spilled cells that changed |40+| Pivot tables | A pivot over 8191 rows recomputes after an edit to its source in 0.8 to 2.5 ms | | Results past the grid (the pivot shows #REF!) | Grouping the source rows |41+| Arrays and spills | FILTER, SORT or UNIQUE over 8192 rows: about 0.1 ms each per edit; 1000 of them spilling 516 k cells, an edit recomputing 500: 52 ms | Full recalculation of those 1000: 217 ms | An array past `maxArray` values (2,097,152) stored: `#VALUE!`; a spill past the sheet's edge or `max-cells` cells: `#REF!` | Computing each array's values; writing only the spilled cells that changed |4242 4343 ## Sheet size4444 4545 Hard limits: `sheet.MaxRows = 1,048,576`, `sheet.MaxCols = 16,384`4646 (A..XFD), as in Excel. Memory is bounded by `max-cells` (the config file,47-two million cells by default): imports keep whole rows up to it, pastes47+ten million cells by default): imports keep whole rows up to it, pastes4848 and fills write at most that many cells at once. WK1 files keep their own49-8192 x 256. Past about two million cells the costs below grow with the50-cells; the grid itself costs nothing (see [The grid](#the-grid)).49+8192 x 256. The costs below grow with the cells; the grid itself costs50+nothing (see [The grid](#the-grid)). At ten million cells the file51+format is what degrades: a `.012` file is JSON decoded whole.5152 52-| Measure | 8192 x 26 (213 k cells) | 8192 x 256 (2.1 M cells) |53-|---|---|---|54-| Build through `Load`, then full recalc | 90 ms | 1.4 s |55-| Live heap | 64 MB | 602 MB (about 300 B per number cell) |56-| Save `.012` (JSON, one line per cell) | 74 ms, 4.6 MB | 1.04 s, 47 MB |57-| Open `.012` | 120 to 130 ms (116 MB allocated) | 1.9 to 2 s (1.3 GB allocated) |58-| Export CSV / TSV / XLSX / SQLite | 41 / 55 / 62 / 40 ms | 0.71 / 0.66 / 0.81 / 0.72 s |59-| Frame at 200 x 60, whole | 1.06 ms | 1.05 ms |53+| Measure | 8192 x 26 (213 k cells) | 8192 x 256 (2.1 M cells) | 1,000,000 x 10 (10 M cells) |54+|---|---|---|---|55+| Build through `Load`, then full recalc | 48 ms | 0.47 s | 2.2 s (396 MB peak resident) |56+| Live heap | 4.4 MB | 43 MB (20.5 B per number cell) | 205 MB |57+| Save `.012` (JSON, one line per cell) | 77 ms, 4.6 MB | 0.83 s, 47 MB | 3.7 s, 236 MB |58+| Open `.012` | 108 ms (118 MB allocated) | 1.5 s (1.3 GB allocated) | 8.1 to 8.3 s (5.7 GB allocated, 2.7 GB peak resident) |59+| Export CSV / TSV / XLSX / SQLite | 33 / 31 / 45 / 38 ms | 0.51 / 0.52 / 0.65 / 0.68 s | |60+| Edit a number, undo and redo it | 0.7 us, 1.2 us | | 0.7 us, 1.2 us |61+| Frame at 200 x 60, whole | 0.88 ms | 0.87 ms | |6062 6163 XLSX exports are written by 012 with archive/zip: 1.9 MB allocated for6264 8192 x 26 and 17 MB for 8192 x 256, whose time is mostly compression.6365 6466 Heap per non-blank cell after loading (`BenchmarkMemory`,65-`BenchmarkImport`): numbers 301 B, formulas 707 B (the parsed tree and66-reference lists), 200-character text 790 B, imported CSV 275 to 300 B.67-A `Cell` struct is 216 B; the rest is the map entry, the input string68-and a boxed number for the parsed literal.67+`BenchmarkImport`): numbers 20.5 B, opened from a file too; formulas 75068+B (the parsed tree and reference lists); imported CSV, SQLite and69+Parquet 21 to 63 B; text about 60 B plus its length, once per distinct string70+(a column of 90 texts repeated takes 27 B a cell). A plain cell (a71+number, boolean or text as typed, with a format and style) is a 16-byte72+slot in its column's block of 1024 rows; formulas, notes, and the73+results pivots and spills write are whole `Cell`s, about 300 B each with74+their entry and input (the `Cell` itself is 216 B). How the store lays75+them out is in [Architecture](architecture.md#the-engine). Reading a76+cell's value is an index into its column's block: 2.8 ns77+(`BenchmarkRead`), 8.6 ns a cell read by a SUM in a full recalculation.6978 7079 ## The grid7180 72-Nothing costs what the grid costs. Beside the cell map, occupancy81+Nothing costs what the grid costs. The cells' occupancy7382 indexes (bitmaps of 1024 rows per column) say which cells are stored and7483 which have contents; ranges are read through them, so a whole column of7584 ten cells is ten visits. Formats of whole columns, rows and the sheet
@@ -101,7 +110,7 @@ What each part of 012 visits when given a range or selection:
101110 | XLSX column widths and styles | the data's width (at least 256), a style on A and XFD as the sheet's |102111 103112 A sheet of 10,000 numbers spread down a million rows under 3000104-whole-column formulas (sparse-1M) edits in 2.5 ms, draws a 200 x 60113+whole-column formulas (sparse-1M) edits in 1.6 ms, draws a 200 x 60105114 frame in 0.68 ms, and answers arrow keys at XFD1048576 in 0.65 ms.106115 Typing an entry under 1000 SUMs over a column, through to its frame at107116 200 x 60, takes 0.91 ms.
@@ -119,19 +128,22 @@ read once cost nothing extra.
119128 120129 | Topology | Edit | Full recalc | Undo + redo |121130 |---|---|---|---|122-| Independent cell in 8192 x 26 numbers | 0.66 us | 32 to 38 ms | 0.8 us |123-| Chain: A(n) = A(n-1)+1 down 8192 rows | 1.85 ms | 1.1 to 1.4 ms | 3.4 ms |124-| Fan-out: one cell read by 8192 formulas | 1.4 ms | 0.9 ms | 2.7 ms |125-| Volatile: 8192 TODAY() and RAND() | 1.3 ms | 0.8 ms | 2.5 ms |126-| 1000 named ranges, one formula each | 48 us | 2.8 ms | 99 us |127-| Fan-in: 1000 x SUM(A1:A8192), or SUM(A:A) | 0.75 ms | 1 ms | 1.4 ms |128-| Running totals: 8192 x SUM($A$1:An) | 2.85 ms | 2.5 ms | 5.4 ms |129-| Sparse: 10,000 numbers spread down a million rows, 1000 each of SUM(A:A), running totals and VLOOKUP over A:B | 2.45 ms | 3.6 ms | 5.1 ms |130-| Criteria: 60 SUMIF, COUNTIFS and AVERAGEIF over whole columns of 8192 rows | 37 ms | 59 ms | |131-| Lookup: 300 VLOOKUP, MATCH and XLOOKUP into 8192 keys | 25 ms | 26.5 ms | |132-133-Every volatile formula is recomputed on every change, as in Sheets, so134-8192 volatile cells add about 1.2 ms to every edit anywhere. Criteria131+| Independent cell in 8192 x 26 numbers | 0.7 us | nothing to compute | 1.2 us |132+| Chain: A(n) = A(n-1)+1 down 8192 rows | 1.9 ms | 1 ms | 3.8 ms |133+| Fan-out: one cell read by 8192 formulas | 1.3 ms | 0.8 ms | 2.6 ms |134+| Volatile: 8192 TODAY() and RAND() | 1.3 ms | 0.8 ms | 2.6 ms |135+| 1000 named ranges, one formula each | 55 us | 1.2 ms | 110 us |136+| Fan-in: 1000 x SUM(A1:A8192), or SUM(A:A) | 0.53 ms | 0.48 ms | 1.1 to 1.2 ms |137+| Running totals: 8192 x SUM($A$1:An) | 2.7 ms | 1.8 ms | 5.8 ms |138+| Sparse: 10,000 numbers spread down a million rows, 1000 each of SUM(A:A), running totals and VLOOKUP over A:B | 1.6 ms | 1.5 ms | 3.2 ms |139+| Criteria: 60 SUMIF, COUNTIFS and AVERAGEIF over whole columns of 8192 rows | 29 ms | 44 ms | 59 ms |140+| Lookup: 300 VLOOKUP, MATCH and XLOOKUP into 8192 keys | 12 ms | 12 ms | 27 ms |141+| 10 M numbers (1,000,000 x 10) | 0.7 us | nothing to compute | 1.2 us |142+143+A full recalculation computes only formulas and what pivots and spills144+write: numbers and text hold their values from the moment they are145+stored. Every volatile formula is recomputed on every change, as in146+Sheets, so 8192 volatile cells add about 1.2 ms to every edit anywhere. Criteria135147 and lookups cost what their ranges hold, not what the grid holds: the136148 criteria collect positions from the occupancy index, and lookups walk137149 the stored entries with a cursor.
@@ -169,7 +181,7 @@ terminal (`BenchmarkFrame`).
169181 | Sheet | 80 x 24 | 200 x 60 | 400 x 120 |170182 |---|---|---|---|171183 | Empty | 0.19 ms | 0.89 ms | 3.2 ms |172-| 8192 x 256 numbers | 0.20 ms | 1.05 ms | 4.1 ms |184+| 8192 x 256 numbers | 0.18 ms | 0.87 ms | 3.4 ms |173185 | 8192 rows of 500-character unicode text | 0.34 ms | 1.37 ms | 4.4 ms |174186 | 1000 named ranges | 0.30 ms | 1.06 ms | 3.5 ms |175187 | 20 charts (text) | 0.82 ms | 2.9 ms | 7.3 ms |
@@ -189,7 +201,7 @@ every cell on screen a shade), at 200 x 60 (`BenchmarkFrame`,
189201 | | Without rules | With the scale |190202 |---|---|---|191203 | A frame, or an arrow key through to it | 0.89 ms | 1.28 ms |192-| Typing a number and Enter, through to the frame | 0.91 ms | 2.9 ms (the scale's percentile over 213 k numbers) |204+| Typing a number and Enter, through to the frame | 0.89 ms | 2.3 ms (the scale's percentile over 213 k numbers) |193205 194206 Shades and rule colors keep their escape codes, so a plain cell on one195207 costs a string concatenation, not a style render. A custom formula is
@@ -202,8 +214,8 @@ slices, 0.44 ms, is the worst). An image for kitty graphics is redrawn
202214 when its data, size or theme changes: 25 to 260 us at 24 x 10 cells and203215 1 to 1.5 ms at 120 x 40, a pie 8.6 ms (`internal/chart`,204216 `BenchmarkDraw`, `BenchmarkImage`). Key presses add little: an arrow key205-through to its frame is 0.21 ms at 80 x 24 and 1.06 ms at 200 x 60;206-Page Down 1.26 ms.217+through to its frame is 0.19 ms at 80 x 24 and 0.9 ms at 200 x 60;218+Page Down 1.1 ms.207219 208220 ## Selection statistics209221
@@ -223,9 +235,9 @@ a selection over the whole sheet, through to its frame
223235 224236 | Key, 8192 x 256 numbers | 80 x 24 | 200 x 60 |225237 |---|---|---|226-| Shift+Up and Shift+Down with the data selected (`extend-data`) | 0.41 to 0.53 ms | 2.1 ms |227-| Ctrl+Shift+Down and Ctrl+Shift+Up over the first row (`extend-edge`) | 0.43 ms | 1.7 ms |228-| Ctrl+A, the selection standing (`select-data`) | 0.28 ms | 1.8 ms |238+| Shift+Up and Shift+Down with the data selected (`extend-data`) | 0.33 ms | 1.85 ms |239+| Ctrl+Shift+Down and Ctrl+Shift+Up over the first row (`extend-edge`) | 0.29 ms | 1.56 ms |240+| Ctrl+A, the selection standing (`select-data`) | 0.29 ms | 1.78 ms |229241 230242 The index costs memory only on sheets that had a large selection, in231243 proportion to their data, and a nil check per stored or recalculated
@@ -238,13 +250,13 @@ Real, openly licensed datasets fetched by `scripts/stress-data.sh`
238250 239251 | File | Shape | Import | Throughput | Heap per cell |240252 |---|---|---|---|---|241-| OWID energy (CSV) | 23,377 x 130, 1.04 M cells | 0.84 to 0.95 s | 1.1 to 1.2 M cells/s | 277 B |242-| Airport codes (CSV) | 86,134 x 13, 855 k cells | 0.78 to 0.80 s | 1.1 M cells/s | 274 B |243-| NOAA daily CO2 (CSV) | 18,304 x 2 | 6 ms | 2.7 M cells/s | 294 B |244-| Country codes (CSV, Arabic, CJK) | 249 x 56 | 5.6 ms | 2.2 M cells/s | 272 B |245-| Apache POI formula tests (XLSX) | 788 rows, 1189 formulas, 3828 cells | 9.5 to 9.7 ms | 395 k cells/s | 425 B |246-| Chinook PlaylistTrack (SQLite) | 8715 x 2 | 5.5 ms | 3.0 M cells/s | 293 B |247-| Parquet alltypes_tiny_pages | 7300 x 13 | 40 ms | 2.4 M cells/s | 276 B |253+| OWID energy (CSV) | 23,377 x 130, 1.04 M cells | 0.25 s | 4.2 M cells/s | 23 B |254+| Airport codes (CSV) | 86,134 x 13, 855 k cells | 0.39 s | 2.2 M cells/s | 55 B |255+| NOAA daily CO2 (CSV) | 18,304 x 2 | 12 ms | 3.1 M cells/s | 63 B |256+| Country codes (CSV, Arabic, CJK) | 249 x 56 | 5.4 ms | 2.3 M cells/s | 59 B |257+| Apache POI formula tests (XLSX) | 788 rows, 1189 formulas, 3828 cells | 8.5 to 10 ms | 380 to 450 k cells/s | 239 B |258+| Chinook PlaylistTrack (SQLite) | 8715 x 2 | 6.1 ms | 2.9 M cells/s | 21 B |259+| Parquet alltypes_tiny_pages | 7300 x 13 | 27 ms | 3.5 M cells/s | 44 B |248260 249261 Past `max-cells` (or the grid), imports keep whole rows up to it and250262 say how many rows they left out. Parquet files and SQLite tables stop
@@ -263,10 +275,10 @@ importing, per cell kept, and includes garbage not yet collected:
263275 264276 | Workbook | Time | Allocated | Allocations | Heap per cell after | Peak heap per cell |265277 |---|---|---|---|---|---|266-| 8192 x 26 numbers (213 k cells) | 189 to 199 ms | 170 MB | 3.3 M | 290 B | 589 B |267-| 8192 x 26 table, text and numbers | 296 to 312 ms | 234 MB | 7.4 M | 297 B | 722 B |268-| 8192 formulas (`=A1+1` down a column) | 13.8 ms | 17 MB | 321 k | 746 B | 2188 B |269-| Apache POI formula tests | 9.5 to 9.7 ms | 9.7 MB | 179 k | 425 B | 1288 B |278+| 8192 x 26 numbers (213 k cells) | 147 to 154 ms | 171 MB | 3.3 M | 20.5 B | 114 B |279+| 8192 x 26 table, text and numbers | 262 to 275 ms | 246 MB | 7.7 M | 21 B | 119 B |280+| 8192 formulas (`=A1+1` down a column) | 14.5 ms | 18.6 MB | 321 k | 755 B | 2061 B |281+| Apache POI formula tests | 8.5 to 10 ms | 9 MB | 162 k | 239 B | 965 B |270282 271283 The reader refuses files past `xlsxLimits` (in `internal/fileio/xlsxpkg.go`),272284 with a message naming the limit: 10,000 files in the zip; 1 GB
@@ -282,9 +294,12 @@ entities, so entity bombs fail as unknown entities.
282294 283295 ## Undo284296 285-Each step keeps a copy of every cell it changed. Undoing costs about297+Each step keeps a copy of every cell it changed, as a whole `Cell`298+(about 300 B, where the sheet keeps a number in 20). Undoing costs about286299 what the change cost, since it recalculates the same cells. Clearing287-213 k cells and undoing it takes 158 to 164 ms.300+213 k cells and undoing it takes 144 ms; clearing all ten million cells301+of a full `max-cells` sheet would keep about 3 GB of before-images, the302+one step the history always keeps however large.288303 289304 The history is bounded by steps (`MaxUndo = 100`) and by an estimate of290305 the memory its before-images hold (`MaxUndoBytes = 256 MB`); past either,
@@ -298,9 +313,9 @@ measured heap within about 10% (`BenchmarkHistoryFull`,
298313 299314 | Benchmark | Result |300315 |---|---|301-| 100 edits rewriting a whole column (819 k before-images) | 250 MB held (239 MB estimated), all 100 kept |302-| 12 edits rewriting all of 8192 x 26 (2.6 M before-images) | 230 MB held (248 MB estimated), the last 4 kept |303-| Undo + redo of one cell (dense, names) | 1.0 us, 106 us |316+| 100 edits rewriting a whole column (819 k before-images) | 281 MB held (252 MB estimated), all 100 kept |317+| 12 edits rewriting all of 8192 x 26 (2.6 M before-images) | 199 MB held (196 MB estimated), the last 3 kept |318+| Undo + redo of one cell (dense, names) | 1.2 us, 110 us |304319 305320 ## JEV306321
@@ -372,8 +387,8 @@ read-heavy recalculation.
372387 373388 | Benchmark (`internal/sheet`, `go test -bench SheetEdit`) | Edit |374389 |---|---|375-| 5000 formulas on one sheet reading `A(n)` and `SUM(A1:A10)` | 2.7 ms |376-| The same 5000 formulas reading `Data!A(n)` and `SUM(Data!A1:A10)` | 3.5 ms |390+| 5000 formulas on one sheet reading `A(n)` and `SUM(A1:A10)` | 1.65 ms |391+| The same 5000 formulas reading `Data!A(n)` and `SUM(Data!A1:A10)` | 2 ms |377392 378393 ## Serving over SSH379394
@@ -388,10 +403,10 @@ for 120 frames a second, as the local app does (`-benchtime 200x`):
388403 389404 | Sessions, sheet | Heap per session | Key to frame p50 / p95 / max | CPU per frame |390405 |---|---|---|---|391-| 10, new sheet | 1.30 MiB | 5.1 / 8.3 / 9.6 ms | 0.67 ms |392-| 10, 1000 x 26 numbers | 8.5 MiB | 4.3 / 8.3 / 8.5 ms | 0.71 ms |406+| 10, new sheet | 1.35 MiB | 5.1 / 8.3 / 9.8 ms | 0.83 ms |407+| 10, 1000 x 26 numbers | 1.9 MiB | 3.9 / 8.3 / 10.8 ms | 0.79 ms |393408 | 50, new sheet | 1.30 MiB | 5.0 / 8.7 / 17.8 ms | 0.53 ms |394-| 50, 1000 x 26 numbers | 8.5 MiB | 5.1 / 8.9 / 12.8 ms | 0.66 ms |409+| 50, 1000 x 26 numbers | 1.9 MiB | 5.1 / 8.9 / 12.8 ms | 0.66 ms |395410 396411 Latency doesn't move from 10 to 50 sessions: it's Bubble Tea's frame397412 pacing (at most one frame every 8.3 ms, so a key waits half a frame on
@@ -400,7 +415,7 @@ average), not load. A heap profile with 50 sessions open
400415 Bubble Tea's render buffers (about 1 MiB at 120 x 40, growing with the401416 window), 130 KiB in its input key table and under 50 KiB in both ends402417 of the SSH connection, so the figure is close to the server's own. On403-top come about 300 B per cell of the sheets a session has open;418+top come 20 to 60 B per cell of data of the sheets a session has open;404419 sessions opening the same file each hold a copy. CPU per frame covers405420 both ends of the connection (the encryption twice, the client reading406421 the frame); at 0.7 ms, 50 sessions typing continuously at 120 frames a
@@ -420,10 +435,10 @@ times the edit, the recalculation and the pivot together:
420435 421436 | Pivot | Result rows | Edit | Allocations |422437 |---|---|---|---|423-| Rows: 8 categories; SUM and COUNTA | 10 | 0.77 to 0.95 ms | 196 |424-| Rows: 8 x 8 categories with subtotals; columns: 8; SUM and AVERAGE | 75 | 2.7 to 2.9 ms | 2.5 k |425-| Rows: 1000 items sorted by their sum; SUM and COUNTUNIQUE | 1001 | 3.5 to 4.2 ms | 14 k |426-| Frequency table of the 1000 items | 1001 | 2.5 to 2.8 ms | 24 k |438+| Rows: 8 categories; SUM and COUNTA | 10 | 0.79 ms | 196 |439+| Rows: 8 x 8 categories with subtotals; columns: 8; SUM and AVERAGE | 75 | 2.2 ms | 2.5 k |440+| Rows: 1000 items sorted by their sum; SUM and COUNTUNIQUE | 1001 | 2.5 ms | 14 k |441+| Frequency table of the 1000 items | 1001 | 1.8 ms | 24 k |427442 428443 ## Arrays and spills429444
@@ -448,10 +463,10 @@ FILTERs, 63 of whose arrays change:
448463 449464 | Measure | Time | Allocated |450465 |---|---|---|451-| Edit a number | 55 ms | 161 MB, 8.3 k allocations |452-| Undo and redo it | 110 ms | |453-| Full recalculation | 204 ms | |454-| Build (load and recalculate) | 310 ms | |466+| Edit a number | 52 ms | 161 MB, 7.9 k allocations |467+| Undo and redo it | 103 ms | |468+| Full recalculation | 217 ms | |469+| Build (load and recalculate) | 298 ms | |455470 456471 Most of an edit is the FILTERs' own work: comparing 8192 categories each457472 (`B1:B8192="alpha"`, about 20 ns a value) and picking the rows that
@@ -481,39 +496,33 @@ computation.
481496 482497 ## What would raise the bounds483498 484-In order of value for effort. The first two are what raising485-`max-cells` toward Google Sheets' 10 M cells would take; without them a486-10 M-cell sheet would need about 3 GB of heap and take 10 s to open.487-488-1. **Compact cell storage** (L, 2 to 3 weeks). Cells live in a489- `map[Addr]*Cell` behind `cellStore` (`internal/sheet/store.go`), 300 B490- each: a 216 B struct carrying formula-only491- fields (expression, reference lists, inferred format) on every cell,492- plus the map entry, the input string and a boxed literal. Storing493- columns in row blocks (say 1024 rows) of compact values, with494- formulas, formats and styles in side tables keyed by address, and495- the input string kept only when it differs from the canonical text496- of the value, would bring numbers to about 20 to 40 B per cell and497- turn every cell read in recalc from two hash lookups (20 ns) into an498- index (2 to 3 ns). Cells are already per sheet in a workbook, so the499- storage can change sheet by sheet, and nothing outside `cellStore`500- touches the map, so the change stays inside it.501-2. **Streaming, compact file format** (M, 1 week). The `.012` file is502- JSON decoded whole (1.3 GB allocated to open 47 MB). A streaming499+In order of value for effort; sizes and scheduling are in the500+[roadmap](../../ROADMAP.md#3-scale). `max-cells` stands at ten million,501+as Google Sheets' limit does; the sheet itself would hold several times502+that in a few GB, and these are what stand in the way.503+504+1. **Streaming, compact file format.** The `.012` file is JSON decoded505+ whole: ten million cells take 8 s and 2.7 GB at the peak to open,506+ 5.7 GB allocated, for a sheet that then holds 205 MB. A streaming503507 decoder over the same format would roughly halve open time and cut504508 allocation tenfold; a columnar or gzip-compressed variant would cut505- the size about fourfold. The format is `internal/sheet/file.go`, apart506- from the cell store.507-3. **Smaller undo steps** (S, 1 day). The history is capped by the508- memory its before-images hold; storing formatting-only changes as509- diffs rather than whole cells would let it keep more of them.509+ the size about fourfold. The format is `internal/sheet/file.go`,510+ apart from the cell store.511+2. **Smaller undo steps.** A step keeps a whole `Cell` for each cell it512+ changed, formatting-only changes included (see [Undo](#undo)).513+ Keeping plain cells' before-images as slots, and formatting changes514+ as diffs, would let a step over a full sheet cost what the sheet515+ does.516+3. **Compact spilled and pivot cells.** What arrays and pivots write is517+ kept as whole `Cell`s (516 k of them in the arrays stress shape);518+ their values and inferred formats would fit in slots.510519 511520 ## Measuring512521 513522 ```sh514523 make stress # fetch datasets, run everything, record the run (5 minutes)515524 BENCH='Edit|Frame' make stress # a subset; BENCHTIME=2s for steadier numbers516-make stress-report # latest against previous and baseline, with trends525+make stress-report # latest against previous, baseline and last release, with trends517526 make stress-e2e # key press to screen through libghostty518527 ```519528
@@ -531,5 +540,6 @@ overhead), `internal/fileio/stress_test.go` (imports and exports),
531540 `internal/chart/stress_test.go` (charts as text and images),532541 `internal/serve/stress_test.go` (concurrent SSH sessions) and533542 `e2e/stress_test.go`; the synthetic sheets are built by534-`internal/stress`. Results accumulate in `.deps/stress/results` and, with543+`internal/stress`, up to `dense-1Mx10`, ten million numbers, the544+default `max-cells`. Results accumulate in `.deps/stress/results` and, with535545 the observability stack up, in ClickHouse.
M docs/contributing/observability.md +46 -10
@@ -278,18 +278,49 @@ FROM read_json_auto('events.jsonl') WHERE msg = 'jev';
278278 `git_sha`, `git_dirty`, `go_version`, `goos`, `goarch`, `cpu`, `host`,279279 `pkg`, `name`, `procs`, `n`, `ns_op`, `b_op`, `allocs_op`, `mb_s`, and280280 `metrics`, a map of custom units such as `B/cell`, `cells/s`,281-`MB-file` or `us/answer`. `make stress-report` loads them with282-`scripts/stress/load.sql` and runs `scripts/stress/report.sql`: the283-latest run against the previous one on the same CPU and against a284-baseline (`BASELINE=run_id`, default the oldest run that covered most285-of the same benchmarks), flagging changes past `THRESHOLD` (0.10) and281+`MB-file` or `us/answer`. `COUNT=n` runs each benchmark n times, one282+record per sample. `make stress-report` loads them with283+`scripts/stress/load.sql` (a benchmark's time in a run is the median of284+its samples) and runs `scripts/stress/report.sql`: the latest run285+against the previous one on the same CPU and against a baseline286+(`BASELINE=run_id`, default the oldest run that covered most of the287+same benchmarks), flagging changes past `THRESHOLD` (0.10) and286288 `MIN_DELTA_NS` (2000), and the headline benchmarks over the last eight287289 runs. With the default 500 ms per benchmark, runs on a busy machine (say288290 with the Docker stack ingesting) differ by 10 to 30% on benchmarks of a289291 few tens of milliseconds; use `BENCHTIME=2s` on a quiet machine before290-trusting a single regression. `FAIL_ON_REGRESSION=1` makes it291-exit 1 on a regression, for CI. The same records load into ClickHouse292-(`stress.results`) with the same columns.292+trusting a single regression. `FAIL_ON_REGRESSION=1` also makes it exit293+1 on a regression against the previous run. The same records load into294+ClickHouse (`stress.results`) with the same columns; there, samples of295+one benchmark in one run collapse into one row.296+297+### Regressions against the last release298+299+`make stress-report` then compares the latest run with the last300+release's (`scripts/stress/release.sql`) and exits 1 when anything301+regressed, so a release checklist ([Releasing](releasing.md)) can stop302+on it:303+304+- **The release** is the newest `v*` tag reachable from HEAD whose305+ commit has a clean recorded run on the latest run's CPU and OS (other306+ than the latest run, and not on the latest run's commit). All such307+ runs of that commit are pooled. `RELEASE=v1.2.3` picks the tag,308+ `RELEASE_RUN=run_id` one run instead. With none recorded, the report309+ says so and exits 0: record one with `make stress` on the tagged310+ commit.311+- **Noise** is each benchmark's spread, `(max - min) / median` of its312+ samples, the larger of the release's and the latest run's. It is 0313+ for a benchmark with one sample on each side, so `COUNT=3` or more on314+ both runs gives the threshold room where a benchmark is noisy.315+- **A regression** is a median slower than the release's by more than316+ `THRESHOLD` plus the noise (10% plus, by default) and by more than317+ `MIN_DELTA_NS`. Faster past the same bar is listed as `faster`.318+319+```sh320+COUNT=3 BENCHTIME=2s make stress # on the tagged commit, then on the change321+make stress-report # exit 1 on a regression against the tag322+THRESHOLD=0.2 RELEASE=v0.2.0 make stress-report323+```293324 294325 ## The stack295326
@@ -376,8 +407,13 @@ The `012 runtime` dashboard shows recalculation time (p50, p95, max),
376407 frame and key-to-frame p95 per second, heap and cells, JEV answer time377408 and queue, imports and exports with throughput, the slowest operations,378409 and events per minute (all from the logs, so either way in works), and379-from OTLP the key-to-frame latency summary and the slowest spans with380-their trace ids. `012 stress runs` shows edit latency per410+from OTLP the key-to-frame latency summary, the slowest spans, the411+recent traces with nested spans (root span, command id, span count and412+which spans nest in it), and a trace view: the spans of one trace as a413+tree on a timeline, in Grafana's trace panel (the ClickHouse plugin's414+trace format). A trace id in either table links to it, through the415+dashboard's Trace id box; with the box empty the view shows the latest416+trace with nested spans. `012 stress runs` shows edit latency per381417 topology, frame and keystroke times, full recalc and file operations,382418 memory per cell (log scales where the spread is wide), the latest run383419 against the previous one, and the runs.
A docs/contributing/releasing.md +62 -0
@@ -0,0 +1,62 @@
1+---2+title: "Releasing"3+sidebar_position: 84+---5+6+# Releasing7+8+A release is an annotated version tag whose message is the release9+notes, and archives of the binaries built from it on a maintainer's10+machine. The GitHub repository only hosts the code: nothing builds or11+publishes there, and `make dist` uploads nothing.12+13+## Checklist14+15+1. **`make check`** passes on the commit to be tagged (see16+ [Testing](testing.md)), with the golden screens reviewed in the17+ gallery.18+2. **Stress** (optional for small releases): `make stress` on a quiet19+ machine, then `make stress-report`, which compares the run with the20+ last release's and exits non-zero on a regression past the threshold21+ (see [Observability](observability.md#regressions-against-the-last-release)).22+ Record the run on the release commit so the next release compares23+ against it.24+3. **XLSX in spreadsheet apps.** Export a workbook that uses what the25+ release touches (formats, notes, frozen panes, filters, validation,26+ conditional formats, charts' data) with File > Download, and open it in27+ Excel, LibreOffice Calc and Google Sheets: it opens without a repair28+ prompt, and values, formulas and formats read as they did in 012. The29+ automated checks read XLSX with excelize and 012's own reader only.30+4. **Tag** with the release notes as the message, what changed since the31+ previous tag for a user, with the install line last:32+33+ ```sh34+ git tag -a v1.2.3 # the editor opens for the notes35+ git push origin v1.2.336+ ```37+38+ The Go module proxy picks the tag up, so `go install39+ github.com/FelineStateMachine/012/cmd/012@v1.2.3` works from then on.40+5. **Binaries**: `make dist VERSION=v1.2.3` on the tagged commit.41+42+## make dist43+44+`make dist VERSION=v1.2.3` (`scripts/dist.sh`) cross-compiles 012 with45+`CGO_ENABLED=0` and `-trimpath` for macOS, Linux and Windows on amd6446+and arm64, stamping the version with `-ldflags -X main.version=...`, so47+`012 version` prints it. Each platform gets an archive in `dist/`48+(gitignored) holding the binary, `LICENSE`, `NOTICE` and `README.md`:49+50+```51+dist/012_1.2.3_darwin_arm64.tar.gz52+dist/012_1.2.3_linux_amd64.tar.gz53+dist/012_1.2.3_windows_amd64.zip54+...55+dist/SHA256SUMS56+```57+58+`PLATFORMS="darwin/arm64 linux/amd64"` narrows the list. The script59+refuses a version that isn't `vMAJOR.MINOR.PATCH` (with an optional60+`-suffix`) or that is tagged at another commit than the one checked61+out, and warns when the tree has uncommitted changes. The archives and62+`SHA256SUMS` are what a release attaches, wherever it is published.
M docs/contributing/ux.md +30 -2
@@ -58,8 +58,8 @@ follow-up task.
5858 at odd widths and under overlays; draw them with `theme.Fill`.5959 - Text on a colored background needs about 4.5:1 contrast against both6060 reference palettes (`e2e/palette_test.go`). Check it in the gallery.61-- Never rely on color alone: state also shows as text (mode indicator,62- `modified`, `#DIV/0!`, `Circular reference`).61+- Never rely on color alone: every state also shows as text, a glyph or62+ an attribute, listed in [Reading without color](#reading-without-color).6363 - Numbers right-aligned, text left, booleans and errors centered, one6464 column of padding, text overflowing into empty neighbors: as Sheets.6565 Headers centered. Menu bar titles separated by two spaces. Keys shown
@@ -77,6 +77,34 @@ follow-up task.
7777 - Render only what's visible. A keystroke on a 10,000-cell sheet must feel7878 instant.7979 80+## Reading without color81+82+Each state has a cue that survives a terminal without color, or a reader83+who can't tell the colors apart. `internal/ui/monochrome_test.go` draws84+each with `theme.Monochrome` (every role in one gray) and checks the cue85+is in the cell's text or attributes; a new state gets a cue and a case86+there.87+88+| State | Cue without color |89+|---|---|90+| The pointer (active cell), the selection, their row and column headers | Reverse video; the active cell's headers are also bold, and the name box names the cell or range |91+| Errors | The error's text (`#DIV/0!`) with a curly underline, explained on the context line |92+| Warnings | Text on the context line or status line (`Invalid: ...`, `Circular reference`) |93+| Entries failing validation | A dotted underline, and `Invalid:` with the rule on the context line |94+| Values an array spilled | Italic, and `Spilled from B2` on the context line |95+| Pivot table results | Their headings (`SUM of Units`, `Grand Total`), and a note in words when an edit is refused |96+| Protected ranges | A question in words before an edit (`A1:B2 is protected.`) |97+| Notes | A `▝` in the cell's top-right corner |98+| Macro recording | `REC` beside the mode indicator |99+| Mode | The mode indicator's word (`READY`, `ENTER`, `POINT`, `MENU`) |100+| Search matches, traced cells | Reverse video, with a count or the list on the context line |101+| The copied range | A dashed underline |102+| Dropdowns, checkboxes, active filters | `▾`; `[ ]` and `[✓]`; `▼` instead of `▾` |103+104+The reverse-video roles (`Theme.standouts`) keep their look in color:105+their colors are stored swapped, so SGR 7 swaps them back. Conditional106+formats and color scales are colors the user chose, and stay colors.107+80108 ## Required with every user-facing change81109 82110 - [ ] Registered command(s) with title and description; key binding in
M docs/files/README.md +2 -2
M docs/reference/config.md +4 -4
@@ -73,7 +73,7 @@ start.
7373 | [`notifications`](#notifications) | `true` | `O12_NOTIFICATIONS` |7474 | [`keymap`](#keymap) | `default` | `O12_KEYMAP` |7575 | [`locale`](#locale) | `en-US` | `O12_LOCALE` |76-| [`max-cells`](#max-cells) | `2000000` | `O12_MAX_CELLS` |76+| [`max-cells`](#max-cells) | `10000000` | `O12_MAX_CELLS` |7777 | [`jev-api-key-command`](#jev-api-key-command) | | |7878 | [`jev-credential-store`](#jev-credential-store) | `true` | `O12_JEV_CREDENTIAL_STORE` |7979 | [`jev-base-url`](#jev-base-url) | | `TYPESAFE_BASE_URL` |
@@ -92,7 +92,7 @@ start.
9292 9393 #### `theme`9494 95-Colors. `terminal` uses the terminal's own 16-color palette. Any other name is a terminal color scheme, built in (`012 config themes` lists them) or a file in the themes directory, drawn in its own colors with solid menu and status bars. `light:NAME,dark:NAME` picks one by the terminal's background and follows it when it changes.95+Colors. `terminal` uses the terminal's own 16-color palette. `high-contrast` draws white on black or black on white by the terminal's background, with WCAG AAA contrast. Any other name is a terminal color scheme, built in (`012 config themes` lists them) or a file in the themes directory, drawn in its own colors with solid menu and status bars. `light:NAME,dark:NAME` picks one by the terminal's background and follows it when it changes.9696 9797 | | |9898 |---|---|
@@ -151,12 +151,12 @@ How new sheets and files without a locale of their own are typed and shown: deci
151151 152152 #### `max-cells`153153 154-The most cells an import keeps, and a paste or fill writes at once. A sheet takes about 300 bytes a cell, so the default of two million is about 600 MB. Imports keep whole rows up to the budget and say how many they left out; larger pastes and fills are refused. The grid itself is 1,048,576 rows by 16,384 columns (A to XFD) whatever this is.154+The most cells an import keeps, and a paste or fill writes at once. Numbers and text take 20 to 60 bytes a cell and formulas about 750, so the default of ten million cells of data is 200 to 600 MB. Imports keep whole rows up to the budget and say how many they left out; larger pastes and fills are refused. The grid itself is 1,048,576 rows by 16,384 columns (A to XFD) whatever this is.155155 156156 | | |157157 |---|---|158158 | Type | number |159-| Default | `2000000` |159+| Default | `10000000` |160160 | Environment | `O12_MAX_CELLS` |161161 | Applies | File > Settings > Reload config |162162
M docs/sheets/editing.md +3 -2
M docs/sheets/rules.md +18 -1
@@ -53,6 +53,16 @@ results. They follow inserted and deleted rows and columns, and a rule
5353 whose cells are all deleted goes. Format > Clear conditional formats5454 takes the selection out of every rule.5555 56+Rules, conditional formats and validation alike, move with cut and57+paste on a sheet, as in Sheets: the cut cells take their rules to where58+they land, and the cells they land on lose theirs. A rule whose cells59+partly move keeps both parts; with a custom formula it becomes two60+rules, each with the formula written for its own first cell, so the61+cells that stayed read what they read and the moved ones read the cells62+beside their new places. Formulas in rules follow cells that63+move. Cut on one sheet and pasted on another, the cells leave their64+rules behind and meet the rules where they land.65+5666 ## Data validation5767 5868 Data > Data validation opens the sheet's validation rules in the same
@@ -87,7 +97,14 @@ are always valid.
8797 8898 Each cell has one rule: a rule added over cells that had another takes8999 them from it. Validation checks what's typed, picked from a dropdown and90-entered by macros; pastes, fills and imports aren't refused, and what100+entered by macros. Pastes (Ctrl+V, paste values, text pasted from101+another program) and fills (Ctrl+D, Ctrl+R, Ctrl+Enter, the fill102+handle) are checked after they're written, as in Sheets: when a cell's103+rule rejects what landed there, the whole paste or fill is undone and104+ERROR mode says which cell and why ("Paste undone: Invalid entry in105+B2: ..."; Ctrl+Enter keeps the entry open instead); when only rules that106+warn fail, it stays, the cells are marked, and the context line says how107+many ("2 cells invalid, first B2: ..."). Imports aren't checked, and what91108 they leave invalid is marked. Validation can't go on a pivot table's92109 results, which take no entries. Data > Remove data validation takes the93110 selection out of every rule.
M docs/terminal/README.md +0 -2
M docs/terminal/ssh.md +5 -5
M docs/terminal/themes.md +23 -4
@@ -25,6 +25,9 @@ theme = light:Catppuccin Latte,dark:Catppuccin Mocha # follow the terminal
2525 background, and draws the menu bar, the column headers and the status2626 line as solid bands, in the manner of 1-2-3 and classic terminal2727 programs. The terminal's own palette is never changed.28+- **`high-contrast`** is white on black on a dark terminal and black on29+ white on a light one, switching when the terminal does, with every30+ role at WCAG AAA contrast (see [High contrast](#high-contrast)).2831 - **`light:A,dark:B`** picks A on a light terminal and B on a dark one,2932 and switches when the terminal does (for example with the system's3033 appearance, in terminals that report it).
@@ -49,6 +52,21 @@ takes, plus **1-2-3 Classic**: CGA colors on a blue screen. Names match
4952 without regard to case, and then without regard to spaces, hyphens and5053 underscores, so `tokyo-night` finds `TokyoNight`.5154 55+## High contrast56+57+`theme = high-contrast` draws with two built-in schemes, **High Contrast58+Dark** and **High Contrast Light**, picked by the terminal's background;59+name one of them (`theme = high-contrast-light`) to keep it. They hold60+every role to WCAG AAA rather than AA: 7:1 for all text, hints and61+muted text included, and 4.5:1 for lines, borders, chart axes and62+unavailable menu items. The pointer is black on cyan, the selection63+white on blue, errors a light red on black (a dark red on white).64+65+States never rest on color alone in any theme: the pointer and the66+selection are in reverse video, errors have a curly underline, invalid67+entries a dotted one, spilled values are italic, and so on68+([Reading without color](../contributing/ux.md#reading-without-color)).69+5270 ## Your own schemes5371 5472 Put a file in the `themes` directory next to the config file
@@ -91,13 +109,14 @@ well:
91109 text color; the column header row is filled with the header color;92110 - a background role too close to the screen's background is moved apart;93111 - text below its contrast minimum against its background (WCAG 4.5:1 for94- text, 3:1 for hints, lines and chart axes, 2:1 for unavailable items)95- is moved toward the scheme's text color, or toward black or white, just96- far enough to reach it.112+ text, 3:1 for hints, lines and chart axes, 2:1 for unavailable items;113+ the high-contrast schemes' AAA minimums above) is moved toward the114+ scheme's text color, or toward black or white, just far enough to115+ reach it.97116 98117 A test runs every built-in scheme through this and checks those minimums99118 for cell text, text on the bars, the selection, the headers and every100-other role (`TestEveryThemeReadable`). Charts, as text and as kitty119+other role (`TestEveryThemeReadable`, and `TestHighContrast` for AAA). Charts, as text and as kitty101120 images, use the scheme's colors too.102121 103122 Conditional formats ([Conditional formatting and data validation](../sheets/rules.md#conditional-formatting)) name
A e2e/screenthemes_test.go +25 -0
M e2e/testdata/screens/rules-panel.html +1 -1
@@ -5,7 +5,7 @@
55 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Task</span><span style="background:var(--vt-palette-6)"> </span> <span style="font-weight:bold">Owner</span> <span style="font-weight:bold">Hours</span> <span style="font-weight:bold">Done</span> <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Conditional</span> <span style="font-weight:bold">format</span> <span style="font-weight:bold">rules</span> <span style="color:var(--vt-palette-8)">───────────────────┐</span>66 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-2);text-decoration:line-through">Write</span><span style="text-decoration:line-through"> </span><span style="color:var(--vt-palette-2);text-decoration:line-through">spec</span> Ann <span style="color:var(--vt-palette-8)">▾</span><span style="background:#f0c674"> </span><span style="color:#1d1f21;background:#f0c674">12</span><span style="background:#f0c674"> </span> [✓] <span style="color:var(--vt-palette-8)">│</span> + Add rule <span style="color:var(--vt-palette-8)">│</span>77 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Build UI Bo <span style="color:var(--vt-palette-8)">▾</span><span style="background:#cc6666"> </span><span style="color:#000000;background:#cc6666">40</span><span style="background:#cc6666"> </span> [ ] <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-2);text-decoration:line-through">123</span> A2:A6 Custom formula is =$D2 <span style="color:var(--vt-palette-8)">│</span>8-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-2);text-decoration:line-through">Tests</span> Cy <span style="color:var(--vt-palette-8)">▾</span><span style="background:#de966d"> </span><span style="color:#1d1f21;background:#de966d">26</span><span style="background:#de966d"> </span> [✓] <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="background:#b5bd68"> </span><span style="background:#dcc370"> </span><span style="background:#e4a56f"> </span><span style="background:#cc6666"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:#000000;background:var(--vt-palette-6)">C2:C6</span><span style="background:var(--vt-palette-6)"> </span><span style="color:#000000;background:var(--vt-palette-6)">Color</span><span style="background:var(--vt-palette-6)"> </span><span style="color:#000000;background:var(--vt-palette-6)">scale</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-2);text-decoration:line-through">Tests</span> Cy <span style="color:var(--vt-palette-8)">▾</span><span style="background:#de966d"> </span><span style="color:#1d1f21;background:#de966d">26</span><span style="background:#de966d"> </span> [✓] <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="background:#b5bd68"> </span><span style="background:#dcc370"> </span><span style="background:#e4a56f"> </span><span style="background:#cc6666"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">C2:C6</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Color</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">scale</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>99 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Docs <span style="text-decoration:underline dotted var(--vt-palette-3)">Dee</span> <span style="color:var(--vt-palette-8)">▾</span><span style="background:#c9c06c"> </span><span style="color:#1d1f21;background:#c9c06c">6</span><span style="background:#c9c06c"> </span> [ ] <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-5);font-weight:bold">123</span><span style="background:var(--vt-palette-5)"> </span> C7 Greater than 80 <span style="color:var(--vt-palette-8)">│</span>1010 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Release Ann <span style="color:var(--vt-palette-8)">▾</span><span style="background:#b5bd68"> </span><span style="color:#1d1f21;background:#b5bd68">3</span><span style="background:#b5bd68"> </span> [ ] <span style="color:var(--vt-palette-8)">└────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">3</span> <span style="color:var(--vt-palette-8)">rules</span> <span style="color:var(--vt-palette-8)">─┘</span>1111 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span> Total <span style="background:var(--vt-palette-5)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-5);font-weight:bold">87</span><span style="background:var(--vt-palette-5)"> </span>
M e2e/testdata/screens/spill-light.html +3 -3
@@ -3,9 +3,9 @@
33 <span style="color:var(--vt-palette-0)">Spilled</span> <span style="color:var(--vt-palette-0)">from</span> <span style="font-weight:bold">D2</span><span style="color:var(--vt-palette-0)">;</span> <span style="color:var(--vt-palette-0)">edit</span> <span style="color:var(--vt-palette-0)">the</span> <span style="color:var(--vt-palette-0)">formula</span> <span style="color:var(--vt-palette-0)">there</span>44 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">E</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>55 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span> Household budget 20266-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span> Rent <span style="color:var(--vt-palette-4)">1450</span>7-<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">3</span><span style="background:var(--vt-palette-6)"> </span> Rent 1450 <span style="color:var(--vt-palette-4)">Groceries</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">612.4</span><span style="background:var(--vt-palette-6)"> </span>8-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> Groceries 612.4 <span style="color:var(--vt-palette-4)">Transit</span> <span style="color:var(--vt-palette-4)">96</span>6+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span> Rent <span style="color:var(--vt-palette-4);font-style:italic">1450</span>7+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">3</span><span style="background:var(--vt-palette-6)"> </span> Rent 1450 <span style="color:var(--vt-palette-4);font-style:italic">Groceries</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">612.4</span><span style="background:var(--vt-palette-6)"> </span>8+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> Groceries 612.4 <span style="color:var(--vt-palette-4);font-style:italic">Transit</span> <span style="color:var(--vt-palette-4);font-style:italic">96</span>99 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span> Transit 961010 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span>1111 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span> Total 2158.4
M e2e/testdata/screens/spill.html +3 -3
@@ -3,9 +3,9 @@
33 <span style="color:var(--vt-palette-8)">Spilled</span> <span style="color:var(--vt-palette-8)">from</span> <span style="font-weight:bold">D2</span><span style="color:var(--vt-palette-8)">;</span> <span style="color:var(--vt-palette-8)">edit</span> <span style="color:var(--vt-palette-8)">the</span> <span style="color:var(--vt-palette-8)">formula</span> <span style="color:var(--vt-palette-8)">there</span>44 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">E</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>55 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> Household budget 20266-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> Rent <span style="color:var(--vt-palette-6)">1450</span>7-<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">3</span><span style="background:var(--vt-palette-6)"> </span> Rent 1450 <span style="color:var(--vt-palette-6)">Groceries</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">612.4</span><span style="background:var(--vt-palette-6)"> </span>8-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Groceries 612.4 <span style="color:var(--vt-palette-6)">Transit</span> <span style="color:var(--vt-palette-6)">96</span>6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> Rent <span style="color:var(--vt-palette-6);font-style:italic">1450</span>7+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">3</span><span style="background:var(--vt-palette-6)"> </span> Rent 1450 <span style="color:var(--vt-palette-6);font-style:italic">Groceries</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">612.4</span><span style="background:var(--vt-palette-6)"> </span>8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Groceries 612.4 <span style="color:var(--vt-palette-6);font-style:italic">Transit</span> <span style="color:var(--vt-palette-6);font-style:italic">96</span>99 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Transit 961010 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span>1111 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span> Total 2158.4
A e2e/testdata/screens/theme-high-contrast-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0;text-decoration:underline">F</span><span style="color:#000000;background:#e0e0e0">ile</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0;text-decoration:underline">E</span><span style="color:#000000;background:#e0e0e0">dit</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0;text-decoration:underline">V</span><span style="color:#000000;background:#e0e0e0">iew</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0;text-decoration:underline">I</span><span style="color:#000000;background:#e0e0e0">nsert</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">F</span><span style="color:#000000;background:#e0e0e0;text-decoration:underline">o</span><span style="color:#000000;background:#e0e0e0">rmat</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0;text-decoration:underline">D</span><span style="color:#000000;background:#e0e0e0">ata</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0;text-decoration:underline">H</span><span style="color:#000000;background:#e0e0e0">elp</span><span style="background:#e0e0e0"> </span><span style="background:#a0e8ff"> </span><span style="color:#000000;background:#a0e8ff;font-weight:bold">READY</span><span style="background:#a0e8ff"> </span>2+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">B3:B5</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">1450</span><span style="background:#ffffff"> </span>3+<span style="background:#ffffff"> </span>4+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">A</span><span style="background:#d0d0d0"> </span><span style="background:#a0e8ff"> </span><span style="color:#000000;background:#a0e8ff;font-weight:bold">B</span><span style="background:#a0e8ff"> </span><span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">C</span><span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">D</span><span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">E</span><span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">F</span><span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">G</span><span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">H</span><span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">I</span><span style="background:#d0d0d0"> </span>5+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">1</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">Household</span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">budget</span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">2026</span><span style="background:#ffffff"> </span>6+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">2</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>7+<span style="background:#a0e8ff"> </span><span style="color:#000000;background:#a0e8ff;font-weight:bold">3</span><span style="background:#a0e8ff"> </span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">Rent</span><span style="background:#ffffff"> </span><span style="background:#a0e8ff"> </span><span style="color:#000000;background:#a0e8ff">1450</span><span style="background:#a0e8ff"> </span><span style="background:#ffffff"> </span>8+<span style="background:#0033cc"> </span><span style="color:#ffffff;background:#0033cc">4</span><span style="background:#0033cc"> </span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">Groceries</span><span style="background:#0033cc"> </span><span style="color:#ffffff;background:#0033cc">612.4</span><span style="background:#0033cc"> </span><span style="background:#ffffff"> </span>9+<span style="background:#0033cc"> </span><span style="color:#ffffff;background:#0033cc">5</span><span style="background:#0033cc"> </span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">Transit</span><span style="background:#ffffff"> </span><span style="background:#0033cc"> </span><span style="color:#ffffff;background:#0033cc">96</span><span style="background:#0033cc"> </span><span style="background:#ffffff"> </span>10+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">6</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">Savings</span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">r</span><span style="background:#ffffff"> </span><span style="color:#a00000;background:#ffffff;text-decoration:underline wavy #a00000">#DIV/0!</span><span style="background:#ffffff"> </span>11+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">7</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">Total</span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">2158.4</span><span style="background:#ffffff"> </span>12+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">8</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>13+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">9</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>14+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">10</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>15+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">11</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>16+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">12</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>17+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">13</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>18+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">14</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>19+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">15</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>20+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">16</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>21+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">17</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>22+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">18</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>23+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">19</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>24+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">20</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>25+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">21</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>26+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">22</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>27+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">23</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>28+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">24</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>29+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">25</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>30+<span style="background:#a0e8ff"> </span><span style="color:#000000;background:#a0e8ff;font-weight:bold">Sheet1</span><span style="background:#a0e8ff"> </span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">+</span><span style="background:#e0e0e0"> </span><span style="color:#6a6a6a;background:#e0e0e0">│</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">untitled</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">modified</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0;font-weight:bold">B3:B5</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">Sum</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">2158.4</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">Avg</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">719.47</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">Count</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">3</span></pre>\ No newline at end of file
A e2e/testdata/screens/theme-high-contrast-menu.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;text-decoration:underline">F</span><span style="color:#000000;background:#40e0ff">ile</span><span style="background:#40e0ff"> </span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">E</span><span style="color:#ffffff;background:#1f1f1f">dit</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">V</span><span style="color:#ffffff;background:#1f1f1f">iew</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">I</span><span style="color:#ffffff;background:#1f1f1f">nsert</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">F</span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">o</span><span style="color:#ffffff;background:#1f1f1f">rmat</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">D</span><span style="color:#ffffff;background:#1f1f1f">ata</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">H</span><span style="color:#ffffff;background:#1f1f1f">elp</span><span style="background:#1f1f1f"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">MENU</span><span style="background:#40e0ff"> </span>2+<span style="color:#757575;background:#000000">┌─────────────────────┐</span><span style="background:#000000"> </span>3+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">New</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span>4+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Open</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+O</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#40e0ff"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">C</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">D</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">E</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">G</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">H</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">I</span><span style="background:#3a3a3a"> </span>5+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Import</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">2026</span><span style="background:#000000"> </span>6+<span style="color:#757575;background:#000000">├─────────────────────┤</span><span style="background:#000000"> </span>7+<span style="color:#757575;background:#000000">│</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">Save</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">Ctrl+S</span><span style="background:#40e0ff"> </span><span style="color:#757575;background:#000000">│</span><span style="color:#ffffff;background:#000000">50</span><span style="background:#000000"> </span>8+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Save</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">as</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="color:#ffffff;background:#000000">.4</span><span style="background:#000000"> </span>9+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Download</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">›</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="color:#ffffff;background:#000000">96</span><span style="background:#000000"> </span>10+<span style="color:#757575;background:#000000">├─────────────────────┤</span><span style="color:#ff8080;background:#000000;text-decoration:underline wavy #ff8080">!</span><span style="background:#000000"> </span>11+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Settings</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">›</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="color:#000000;background:#40e0ff">.4</span><span style="background:#40e0ff"> </span><span style="background:#000000"> </span>12+<span style="color:#757575;background:#000000">├─────────────────────┤</span><span style="background:#000000"> </span>13+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Quit</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+Q</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span>14+<span style="color:#757575;background:#000000">└─────────────────────┘</span><span style="background:#000000"> </span>15+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">11</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>16+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">12</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>17+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">13</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>18+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">14</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>19+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">15</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>20+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">16</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>21+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">17</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>22+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">18</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>23+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">19</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>24+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">20</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>25+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">21</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>26+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">22</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>27+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">23</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>28+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">24</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>29+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">25</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>30+<span style="color:#ffffff;background:#1f1f1f">Save</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">the</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">sheet</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Arrows</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">move</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Enter</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">choose</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Esc</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">close</span></pre>\ No newline at end of file
A e2e/testdata/screens/theme-high-contrast.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">F</span><span style="color:#ffffff;background:#1f1f1f">ile</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">E</span><span style="color:#ffffff;background:#1f1f1f">dit</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">V</span><span style="color:#ffffff;background:#1f1f1f">iew</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">I</span><span style="color:#ffffff;background:#1f1f1f">nsert</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">F</span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">o</span><span style="color:#ffffff;background:#1f1f1f">rmat</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">D</span><span style="color:#ffffff;background:#1f1f1f">ata</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">H</span><span style="color:#ffffff;background:#1f1f1f">elp</span><span style="background:#1f1f1f"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">READY</span><span style="background:#40e0ff"> </span>2+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">B3:B5</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">1450</span><span style="background:#000000"> </span>3+<span style="background:#000000"> </span>4+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">A</span><span style="background:#3a3a3a"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">B</span><span style="background:#40e0ff"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">C</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">D</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">E</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">G</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">H</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">I</span><span style="background:#3a3a3a"> </span>5+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">1</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Household</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">budget</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">2026</span><span style="background:#000000"> </span>6+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">2</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>7+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">3</span><span style="background:#40e0ff"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Rent</span><span style="background:#000000"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">1450</span><span style="background:#40e0ff"> </span><span style="background:#000000"> </span>8+<span style="background:#0033cc"> </span><span style="color:#ffffff;background:#0033cc">4</span><span style="background:#0033cc"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Groceries</span><span style="background:#0033cc"> </span><span style="color:#ffffff;background:#0033cc">612.4</span><span style="background:#0033cc"> </span><span style="background:#000000"> </span>9+<span style="background:#0033cc"> </span><span style="color:#ffffff;background:#0033cc">5</span><span style="background:#0033cc"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Transit</span><span style="background:#000000"> </span><span style="background:#0033cc"> </span><span style="color:#ffffff;background:#0033cc">96</span><span style="background:#0033cc"> </span><span style="background:#000000"> </span>10+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">6</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Savings</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">r</span><span style="background:#000000"> </span><span style="color:#ff8080;background:#000000;text-decoration:underline wavy #ff8080">#DIV/0!</span><span style="background:#000000"> </span>11+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">7</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Total</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">2158.4</span><span style="background:#000000"> </span>12+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">8</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>13+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">9</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>14+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">10</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>15+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">11</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>16+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">12</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>17+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">13</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>18+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">14</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>19+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">15</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>20+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">16</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>21+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">17</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>22+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">18</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>23+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">19</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>24+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">20</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>25+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">21</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>26+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">22</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>27+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">23</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>28+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">24</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>29+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">25</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>30+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">Sheet1</span><span style="background:#40e0ff"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">+</span><span style="background:#1f1f1f"> </span><span style="color:#757575;background:#1f1f1f">│</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">untitled</span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">modified</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;font-weight:bold">B3:B5</span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">Sum</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">2158.4</span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">Avg</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">719.47</span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">Count</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">3</span></pre>\ No newline at end of file
M e2e/testdata/screens/theme-picker.html +1 -1
@@ -5,7 +5,7 @@
55 <span style="background:#928374"> </span><span style="color:#211e19;background:#928374">1</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Household</span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">b</span><span style="color:#928374;background:#282828">│</span><span style="background:#689d6a"> </span><span style="color:#282828;background:#689d6a;font-weight:bold;text-decoration:underline">Gruvbox</span><span style="color:#282828;background:#689d6a">Dark</span><span style="background:#689d6a"> </span><span style="color:#282828;background:#689d6a">dark</span><span style="background:#689d6a"> </span><span style="color:#928374;background:#282828">│</span><span style="background:#282828"> </span>66 <span style="background:#928374"> </span><span style="color:#211e19;background:#928374">2</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">│</span><span style="background:#282828"> </span><span style="color:#d79921;background:#282828;font-weight:bold">Gruvbox</span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Light</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">light</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">│</span><span style="background:#282828"> </span>77 <span style="background:#928374"> </span><span style="color:#211e19;background:#928374">3</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Rent</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">│</span><span style="background:#282828"> </span><span style="color:#d79921;background:#282828;font-weight:bold">Gruvbox</span><span style="color:#ebdbb2;background:#282828">DarkHard</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">dark</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">│</span><span style="background:#282828"> </span>8-<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">4</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Groceries</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">└───────────────────────────────────────────────────</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">3</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">of</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">350</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">─┘</span><span style="background:#282828"> </span>8+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">4</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Groceries</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">└───────────────────────────────────────────────────</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">3</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">of</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">353</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">─┘</span><span style="background:#282828"> </span>99 <span style="background:#928374"> </span><span style="color:#211e19;background:#928374">5</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Transit</span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">96</span><span style="background:#282828"> </span>1010 <span style="background:#928374"> </span><span style="color:#211e19;background:#928374">6</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Savings</span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">r</span><span style="background:#282828"> </span><span style="color:#d9705a;background:#282828;text-decoration:underline wavy #cc241d">#DIV/0!</span><span style="background:#282828"> </span>1111 <span style="background:#689d6a"> </span><span style="color:#282828;background:#689d6a;font-weight:bold">7</span><span style="background:#689d6a"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Total</span><span style="background:#282828"> </span><span style="background:#689d6a"> </span><span style="color:#282828;background:#689d6a">2158.4</span><span style="background:#689d6a"> </span><span style="background:#282828"> </span>
M internal/config/registry.go +6 -4
@@ -86,7 +86,8 @@ var Groups = []string{GroupAppearance, GroupData, GroupJEV, GroupTelemetry, Grou
8686 // (go test ./internal/config -update-docs) and Settings follow.8787 var Options = []Option{8888 {Name: "theme", Group: GroupAppearance, Default: "terminal", Env: []string{"O12_THEME"}, Flag: "--theme", Live: true,89- Desc: "Colors. `terminal` uses the terminal's own 16-color palette. Any other name is a " +89+ Desc: "Colors. `terminal` uses the terminal's own 16-color palette. `high-contrast` draws white on " +90+ "black or black on white by the terminal's background, with WCAG AAA contrast. Any other name is a " +9091 "terminal color scheme, built in (`012 config themes` lists them) or a file in the themes " +9192 "directory, drawn in its own colors with solid menu and status bars. " +9293 "`light:NAME,dark:NAME` picks one by the terminal's background and follows it when it changes.",
@@ -109,9 +110,10 @@ var Options = []Option{
109110 "which sets a file's own. Files store the same thing in every locale. " +110111 "When unset, the POSIX locale (`LC_ALL`, `LC_NUMERIC`, then `LANG`, e.g. `de_DE.UTF-8`) picks it if it's one of these."},111112 112- {Name: "max-cells", Kind: Int, Group: GroupData, Default: "2000000", Env: []string{"O12_MAX_CELLS"}, Live: true,113- Desc: "The most cells an import keeps, and a paste or fill writes at once. A sheet takes about " +114- "300 bytes a cell, so the default of two million is about 600 MB. Imports keep whole rows " +113+ {Name: "max-cells", Kind: Int, Group: GroupData, Default: "10000000", Env: []string{"O12_MAX_CELLS"}, Live: true,114+ Desc: "The most cells an import keeps, and a paste or fill writes at once. Numbers and text " +115+ "take 20 to 60 bytes a cell and formulas about 750, so the default of ten million cells of " +116+ "data is 200 to 600 MB. Imports keep whole rows " +115117 "up to the budget and say how many they left out; larger pastes and fills are refused. " +116118 "The grid itself is 1,048,576 rows by 16,384 columns (A to XFD) whatever this is."},117119
M internal/sheet/cellformat.go +7 -7
M internal/sheet/chart.go +3 -3
M internal/sheet/clipfmt.go +2 -2
M internal/sheet/evaluate.go +8 -5
M internal/sheet/file.go +2 -2
M internal/sheet/filter.go +2 -3
M internal/sheet/history.go +3 -3
M internal/sheet/historysize.go +6 -6
@@ -12,15 +12,15 @@ const MaxUndoBytes = 256 << 20
1212 // undoBudget is MaxUndoBytes; tests lower it.1313 var undoBudget int64 = MaxUndoBytes1414 15-// Estimated heap held by the parts of a step. A before-image is a shallow16-// copy of a Cell (216 B) plus its map entry and boxed literal; a formula17-// also keeps its parsed tree and reference lists alive, which grow with18-// its text. The constants match the heap measured by the stress15+// Estimated heap held by the parts of a step. A before-image is a Cell16+// (216 B) plus its map entry, its input and boxed literal (a plain cell's17+// are made for it from the store, see store.go); a formula also keeps its18+// parsed tree and reference lists alive, which grow with its text. The constants match the heap measured by the stress1919 // benchmarks (BenchmarkHistoryFull) within about 10%.2020 const (2121 stepBytes = 512 // the step and its empty maps2222 entryBytes = 64 // a map entry: the key and a pointer23- cellBytes = 240 // a copied Cell and its boxed literal23+ cellBytes = 256 // a Cell, its input's header and its boxed literal2424 formulaBytes = 256 // a parsed formula's tree and references, plus perFormulaByte per byte of text2525 perFormulaByte = 162626 chartBytes = 128
@@ -73,7 +73,7 @@ func (w *Workbook) deletedSize(st *step) int64 {
7373 var n int647474 for _, s := range st.sheets.order {7575 if !slices.Contains(w.sheets, s) {76- n += int64(s.Len()) * (entryBytes + cellBytes)76+ n += s.cells.size()7777 }7878 }7979 return n
M internal/sheet/historysize_test.go +1 -1
M internal/sheet/insertbook.go +1 -1
M internal/sheet/lines.go +10 -13
M internal/sheet/looks.go +4 -4
M internal/sheet/move.go +2 -1
M internal/sheet/notes.go +3 -3
M internal/sheet/occupancy.go +28 -3
@@ -15,6 +15,7 @@ import (
1515 type occupancy struct {1616 cols []*colIndex // by column; nil for none1717 colIDs []int // the columns holding cells, ascending18+ n int // the cells marked1819 }1920 2021 // Rows of a column are indexed in blocks of blockRows, one bit each.
@@ -25,9 +26,28 @@ const (
2526 )2627 2728 type rowBlock struct {28- bits [blockWords]uint6429+ bits [blockWords]uint6430+ // rank counts the rows set in the words before each, so a row's31+ // position among the block's is a popcount away (index).32+ rank [blockWords]uint162933 n int3034 stats *blockStats // the filled index's statistics, made on first use; see stats.go35+ vals []slot // the stored index's cells, one per set row in order; see slot.go36+}37+38+// index is the position of row bit of the block among its set rows, and39+// whether it is set.40+func (b *rowBlock) index(bit int) (int, bool) {41+ w, m := bit>>6, uint64(1)<<(bit&63)42+ x := b.bits[w]43+ return int(b.rank[w]) + bits.OnesCount64(x&(m-1)), x&m != 044+}45+46+// count adds d to the rank of the words after word w.47+func (b *rowBlock) count(w, d int) {48+ for j := w + 1; j < blockWords; j++ {49+ b.rank[j] = uint16(int(b.rank[j]) + d)50+ }3151 }3252 3353 // colIndex is the occupancy of one column: its blocks by number (nil
@@ -61,8 +81,8 @@ func (o *occupancy) has(a Addr) bool {
6181 return b != nil && b.bits[bit>>6]&(1<<(bit&63)) != 06282 }6383 64-// mark records a cell at a, which had none.65-func (o *occupancy) mark(a Addr) {84+// mark records a cell at a, which had none, and returns its block.85+func (o *occupancy) mark(a Addr) *rowBlock {6686 ci := o.col(a.Col)6787 if ci == nil {6888 ci = &colIndex{}
@@ -84,8 +104,11 @@ func (o *occupancy) mark(a Addr) {
84104 }85105 bit := a.Row & (blockRows - 1)86106 b.bits[bit>>6] |= 1 << (bit & 63)107+ b.count(bit>>6, 1)87108 b.n++88109 ci.n++110+ o.n++111+ return b89112 }90113 91114 // unmark records that a no longer holds a cell.
@@ -95,8 +118,10 @@ func (o *occupancy) unmark(a Addr) {
95118 b := ci.blocks[id]96119 bit := a.Row & (blockRows - 1)97120 b.bits[bit>>6] &^= 1 << (bit & 63)121+ b.count(bit>>6, -1)98122 b.n--99123 ci.n--124+ o.n--100125 if b.n == 0 {101126 ci.blocks[id] = nil102127 ci.ids = removeSorted(ci.ids, id)
M internal/sheet/ops.go +10 -10
@@ -17,9 +17,9 @@ var (
1717 ErrFillTooBig = errors.New("That would write more cells than max-cells allows (see File > Settings)")1818 )1919 20-// DefaultMaxCells is the max-cells setting's default: about 600 MB of21-// cells at 300 bytes each.22-const DefaultMaxCells = 2_000_00020+// DefaultMaxCells is the max-cells setting's default: about 200 MB of21+// numbers at 20 bytes each (store.go).22+const DefaultMaxCells = 10_000_0002323 2424 // maxCells caps how many cells a paste or fill writes at once, and how2525 // many an import keeps, so an accidental whole-sheet selection or a huge
@@ -66,13 +66,13 @@ func (s *Sheet) remap(label string, focus Rect, cell func(Addr) (Addr, bool), rn
6666 for l, c := range others {6767 l.s.place(l.a, c)6868 }69- for a, c := range s.cells.all() {70- if next[a] != c {71- s.place(a, next[a])69+ for a := range s.cells.keys() {70+ if c := next[a]; !s.cells.holds(a, c) {71+ s.place(a, c)7272 }7373 }7474 for a, c := range next {75- if s.cells.get(a) != c {75+ if !s.cells.holds(a, c) {7676 s.place(a, c)7777 }7878 }
@@ -112,7 +112,7 @@ func (s *Sheet) insert(rows bool, at, n int) error {
112112 if rows {113113 edge = rowRect(first, size-1)114114 }115- for range s.cells.inRange(edge) {115+ for range s.cells.keysIn(edge) {116116 return ErrPushedOff117117 }118118 s.restructure(rows, formula.Span{At: at, N: n, Size: size})
@@ -291,7 +291,7 @@ func (s *Sheet) Paste(c *Clip, dst Rect, values bool) (Rect, error) {
291291 func (s *Sheet) pasteCell(a Addr, c *Cell, dc, dr int, values bool) {292292 switch {293293 case c == nil:294- if s.cells.get(a) != nil {294+ if s.cells.has(a) {295295 s.place(a, nil)296296 }297297 case values:
@@ -412,7 +412,7 @@ func (s *Sheet) seriesStart(r Rect, down bool) (Rect, bool) {
412412 }413413 used := map[int]bool{}414414 for _, a := range s.cellsIn(r) {415- if !s.cells.get(a).Blank() {415+ if s.cells.filledAt(a) {416416 used[line(a)] = true417417 }418418 }
M internal/sheet/pivotlayout.go +1 -1
M internal/sheet/rangememo.go +2 -2
M internal/sheet/recalc.go +5 -4
M internal/sheet/remote.go +1 -1
A internal/sheet/rulemove.go +117 -0
@@ -0,0 +1,117 @@
1+package sheet2+3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/012/internal/formula"7+)8+9+// Conditional formats and validation move with cut and paste on a sheet,10+// as in Sheets: the cut cells take their rules to where they land, the11+// cells they land on lose theirs, and formulas in the rules follow the12+// cells they read.13+14+// moveRules moves the sheet's rules with the cells in src, landing on15+// dst; cell and rng are the move's, for the rules' formulas.16+func (s *Sheet) moveRules(src, dst Rect, cell func(Addr) (Addr, bool), rng func(Rect) (Rect, bool)) {17+ if s.rules.empty() {18+ return19+ }20+ next := s.rules.moved(src, dst, formula.Relocate(s.onThis(s), cell, rng))21+ if !next.equal(s.rules) {22+ s.recordRules()23+ s.rules = next24+ }25+}26+27+// moved is the rules after the cells in src move to dst, with their28+// formulas rewritten by rw. A rule whose cells are partly moved keeps one29+// range list, or, when its formula is relative to its first cell, splits30+// into a rule for the cells that stayed and one for those that moved,31+// each with the formula written for its own first cell.32+func (r rulesState) moved(src, dst Rect, rw formula.Rewriter) rulesState {33+ d := Addr{Col: dst.From.Col - src.From.Col, Row: dst.From.Row - src.From.Row}34+ var out rulesState35+ for _, f := range r.formats {36+ for _, p := range movedParts(f.Ranges, src, dst, d, f.Op == RuleFormula) {37+ g := f38+ g.Ranges = p.ranges39+ if f.Op == RuleFormula {40+ g.Args[0] = reanchor(f.Args[0], p.shift, rw)41+ }42+ out.formats = append(out.formats, g)43+ }44+ }45+ for _, v := range r.validations {46+ for _, p := range movedParts(v.Ranges, src, dst, d, v.Kind == ValidFormula) {47+ w := v.clone()48+ w.Ranges = p.ranges49+ switch v.Kind {50+ case ValidFormula:51+ w.Args[0] = reanchor(v.Args[0], p.shift, rw)52+ case ValidRange:53+ w.Source = strings.TrimPrefix(shiftFormula("="+v.Source, rw), "=")54+ }55+ out.validations = append(out.validations, w)56+ }57+ }58+ return out59+}60+61+// rulePart is the ranges of a rule after a move, and how far its62+// formula's relative references shift: from the rule's first cell to63+// where the part's first cell was before the move.64+type rulePart struct {65+ ranges []Rect66+ shift Addr67+}68+69+// movedParts splits a rule's ranges rs by the move of src to dst (d70+// away): the cells that stay, outside both, and those that move. split71+// keeps the two apart, for a rule with a relative formula.72+func movedParts(rs []Rect, src, dst Rect, d Addr, split bool) []rulePart {73+ var kept, moved []Rect74+ for _, r := range rs {75+ for _, k := range subtract(r, src) {76+ kept = append(kept, subtract(k, dst)...)77+ }78+ if in, ok := overlap(r, src); ok {79+ moved = append(moved, Rect{From: plus(in.From, d), To: plus(in.To, d)})80+ }81+ }82+ from := anchor(rs)83+ keptPart := func() rulePart { return rulePart{kept, minus(anchor(kept), from)} }84+ movedPart := func() rulePart { return rulePart{moved, minus(minus(anchor(moved), d), from)} }85+ switch {86+ case len(moved) == 0 && len(kept) == 0:87+ return nil88+ case len(moved) == 0:89+ return []rulePart{keptPart()}90+ case len(kept) == 0:91+ return []rulePart{movedPart()}92+ case split:93+ return []rulePart{keptPart(), movedPart()}94+ }95+ return []rulePart{{ranges: append(kept, moved...)}}96+}97+98+// reanchor rewrites a rule's formula (with its "=") for a first cell99+// shift away, then for the move (rw).100+func reanchor(src string, shift Addr, rw formula.Rewriter) string {101+ if shift != (Addr{}) {102+ src = shiftFormula(src, formula.Shift(shift.Col, shift.Row))103+ }104+ return shiftFormula(src, rw)105+}106+107+func plus(a, d Addr) Addr { return Addr{Col: a.Col + d.Col, Row: a.Row + d.Row} }108+func minus(a, d Addr) Addr { return Addr{Col: a.Col - d.Col, Row: a.Row - d.Row} }109+110+// overlap is the cells a and b share, and whether they share any.111+func overlap(a, b Rect) (Rect, bool) {112+ r := Rect{113+ From: Addr{Col: max(a.From.Col, b.From.Col), Row: max(a.From.Row, b.From.Row)},114+ To: Addr{Col: min(a.To.Col, b.To.Col), Row: min(a.To.Row, b.To.Row)},115+ }116+ return r, r.From.Col <= r.To.Col && r.From.Row <= r.To.Row117+}
A internal/sheet/rulemove_test.go +75 -0
@@ -0,0 +1,75 @@
1+package sheet2+3+import "testing"4+5+// Rules move with cut and paste: the cut cells take them along, the6+// cells they land on lose theirs, and relative formulas stay right for7+// the cells that stayed and those that moved.8+func TestRulesMoveWithCells(t *testing.T) {9+ s := New()10+ for r := range 10 {11+ s.Set(Addr{Row: r}, "5")12+ }13+ mustAdd(t, s, CondFormat{Ranges: ranges("A1:A10"), Op: RuleFormula, Args: [2]string{"=A1>4"}, Style: green})14+ mustAdd(t, s, CondFormat{Ranges: ranges("A1:A10"), Op: RuleGreater, Args: [2]string{"3"}, Style: green})15+ if err := s.AddValidation(Validation{Ranges: ranges("A1:A10"), Kind: ValidNumber, Op: RuleBetween, Args: [2]string{"1", "9"}, Reject: true}); err != nil {16+ t.Fatal(err)17+ }18+ if err := s.AddValidation(Validation{Ranges: ranges("C1:C3"), Kind: ValidList, Items: []string{"x"}}); err != nil {19+ t.Fatal(err)20+ }21+ if _, err := s.Move(rect("A1:A3"), at("C2")); err != nil {22+ t.Fatal(err)23+ }24+ fs, vs := s.CondFormats(), s.Validations()25+ if len(fs) != 3 {26+ t.Fatalf("formats %+v", fs)27+ }28+ // The formula rule splits: A4:A10 reads its own cells, and the moved29+ // cells read theirs, at their new places.30+ if RangesText(fs[0].Ranges) != "A4:A10" || fs[0].Args[0] != "=A4>4" {31+ t.Errorf("kept part: %s %s", RangesText(fs[0].Ranges), fs[0].Args[0])32+ }33+ if RangesText(fs[1].Ranges) != "C2:C4" || fs[1].Args[0] != "=C2>4" {34+ t.Errorf("moved part: %s %s", RangesText(fs[1].Ranges), fs[1].Args[0])35+ }36+ if RangesText(fs[2].Ranges) != "A4:A10,C2:C4" {37+ t.Errorf("a plain rule keeps one list: %s", RangesText(fs[2].Ranges))38+ }39+ // The list rule loses the cells landed on; the number rule reaches them.40+ if len(vs) != 2 || RangesText(vs[0].Ranges) != "A4:A10,C2:C4" || RangesText(vs[1].Ranges) != "C1" {41+ t.Errorf("validations %s, %s", RangesText(vs[0].Ranges), RangesText(vs[len(vs)-1].Ranges))42+ }43+ if !s.Look(at("C3")).Styled || s.Look(at("A2")).Styled {44+ t.Error("looks don't follow the move")45+ }46+ s.Undo()47+ if fs := s.CondFormats(); len(fs) != 2 || RangesText(fs[0].Ranges) != "A1:A10" || fs[0].Args[0] != "=A1>4" {48+ t.Errorf("undo: %+v", fs)49+ }50+}51+52+// A paste or fill is checked after the fact: InvalidIn lists what fails,53+// and Discard takes the change back without leaving it to redo.54+func TestInvalidInAndDiscard(t *testing.T) {55+ s := New()56+ if err := s.AddValidation(Validation{Ranges: ranges("B1:B9"), Kind: ValidNumber, Op: RuleBetween, Args: [2]string{"1", "10"}, Reject: true}); err != nil {57+ t.Fatal(err)58+ }59+ s.Set(at("A1"), "5")60+ s.Set(at("A2"), "50")61+ s.Set(at("A3"), "x")62+ if _, err := s.Paste(s.Copy(rect("A1:A3")), rect("B1"), false); err != nil {63+ t.Fatal(err)64+ }65+ bad := s.InvalidIn(rect("B1:B3"))66+ if len(bad) != 2 || bad[0].Addr != at("B2") || bad[1].Addr != at("B3") || !bad[0].Reject {67+ t.Fatalf("invalid %+v", bad)68+ }69+ if !s.Discard() || s.Value(at("B2")).Kind != Empty || s.CanRedo() {70+ t.Errorf("discard: B2 %v, redo %v", s.Value(at("B2")), s.CanRedo())71+ }72+ if len(s.InvalidIn(rect("B1:B3"))) != 0 {73+ t.Error("still invalid after discarding")74+ }75+}
M internal/sheet/series.go +1 -1
M internal/sheet/sheet.go +17 -29
@@ -118,30 +118,21 @@ func New() *Sheet {
118118 // formatting. Use Blank to test for contents.119119 func (s *Sheet) Cell(a Addr) *Cell { return s.cells.get(a) }120120 121+// Filled reports whether the cell at a has contents: !Cell(a).Blank(),122+// without making the cell.123+func (s *Sheet) Filled(a Addr) bool { return s.cells.filledAt(a) }124+121125 // Value returns the computed value at a.122-func (s *Sheet) Value(a Addr) Value {123- if c := s.cells.get(a); c != nil {124- return c.Value125- }126- return Value{}127-}126+func (s *Sheet) Value(a Addr) Value { return s.cells.value(a) }128127 129128 // Len returns the number of non-blank cells.130-func (s *Sheet) Len() int {131- n := 0132- for _, c := range s.cells.all() {133- if !c.Blank() {134- n++135- }136- }137- return n138-}129+func (s *Sheet) Len() int { return s.cells.filledLen() }139130 140131 // Addrs returns every non-blank cell in row-major order.141132 func (s *Sheet) Addrs() []Addr {142- out := make([]Addr, 0, s.cells.len())143- for a, c := range s.cells.all() {144- if !c.Blank() {133+ out := make([]Addr, 0, s.cells.filledLen())134+ for a := range s.cells.keys() {135+ if s.cells.filledAt(a) {145136 out = append(out, a)146137 }147138 }
@@ -245,12 +236,8 @@ func (s *Sheet) Set(a Addr, input string) error {
245236 // put stores an entry without recalculating, keeping the cell's246237 // formatting.247238 func (s *Sheet) put(a Addr, input string) error {248- var f Format249- var st Style250- var note string251- if old := s.cells.get(a); old != nil {252- f, st, note = old.Format, old.Style, old.Note253- }239+ f, st, _ := s.cells.look(a)240+ note := s.Note(a)254241 if f.IsZero() && !st.own && s.inherited(a).Format.Kind == FmtText {255242 f = Format{Kind: FmtText} // typed into a plain text column: text256243 }
@@ -388,8 +375,9 @@ func (s *Sheet) place(a Addr, c *Cell) {
388375 func (s *Sheet) EraseRange(r Rect) {389376 s.change("clear "+r.String(), r, func() {390377 for _, a := range s.cellsIn(r) {391- if c := s.cells.get(a); !c.Blank() {392- s.place(a, c.leftover())378+ if s.cells.filledAt(a) {379+ f, st, _ := s.cells.look(a)380+ s.place(a, formattingOnly(f, st).withNote(s.Note(a)))393381 }394382 }395383 })
@@ -398,7 +386,7 @@ func (s *Sheet) EraseRange(r Rect) {
398386 // cellsIn returns the cells in r that have contents or formatting.399387 func (s *Sheet) cellsIn(r Rect) []Addr {400388 var out []Addr401- for a := range s.cells.anyInRange(r) {389+ for a := range s.cells.anyKeysIn(r) {402390 out = append(out, a)403391 }404392 return out
@@ -414,9 +402,9 @@ func (s *Sheet) unlink(a Addr) {
414402 // unindex removes the dependency edges of the cell at a, reporting415403 // whether there is one.416404 func (s *Sheet) unindex(a Addr) bool {417- old := s.cells.get(a)405+ old := s.cells.richAt(a) // plain cells have no edges418406 if old == nil {419- return false407+ return s.cells.has(a)420408 }421409 for _, r := range old.refs {422410 delete(s.dependents[r], a)
A internal/sheet/slot.go +284 -0
@@ -0,0 +1,284 @@
1+package sheet2+3+import (4+ "math"5+ "strconv"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/formula"9+)10+11+// A slot is how cellStore keeps one cell: 16 bytes in its column's12+// block. Most cells are plain, an entry typed or imported that is a13+// number, a boolean or text, with at most a format and a style; their14+// slot holds the value itself, the text in the store's string table, the15+// format and style in its table of looks, and the input only when it16+// isn't the value's own text. Everything else (formulas, notes, what17+// pivots and spills write, text typed with a leading ') is rich: the18+// slot points at a whole Cell in the store's side table.19+type slot struct {20+ num float64 // slotNum's value; slotBool's, 1 or 021+ // ref is slotText's text in strs; slotNum's or slotBool's input in22+ // strs when it isn't the value's text (0 when it is); slotRich's cell23+ // in rich.24+ ref uint3225+ look uint16 // the format and style in looks; 0 for neither26+ kind uint827+ // dec is how a slotNum without ref prints its input: the value with28+ // dec-1 decimals, or its shortest text when 0.29+ dec uint830+}31+32+// Kinds of slot.33+const (34+ slotBlank uint8 = iota // formatting only35+ slotNum36+ slotBool37+ slotText38+ slotRich39+)40+41+// maxDec bounds the decimals a number's input may have to be kept as a42+// count rather than as text.43+const maxDec = 4044+45+// look is a cell's format and style, kept once per store however many46+// cells share it.47+type look struct {48+ f Format49+ st Style50+}51+52+// strTable holds the text of plain cells, each distinct string once,53+// counted by the cells using it; entry 0 is unused.54+type strTable struct {55+ strs []string56+ refs []uint3257+ index map[string]uint3258+ free []uint3259+}60+61+// add counts one more use of s and returns its entry.62+func (t *strTable) add(s string) uint32 {63+ if i, ok := t.index[s]; ok {64+ t.refs[i]++65+ return i66+ }67+ if t.index == nil {68+ t.index = map[string]uint32{}69+ t.strs, t.refs = []string{""}, []uint32{0}70+ }71+ var i uint3272+ if n := len(t.free); n > 0 {73+ i, t.free = t.free[n-1], t.free[:n-1]74+ t.strs[i], t.refs[i] = s, 175+ } else {76+ i = uint32(len(t.strs))77+ t.strs, t.refs = append(t.strs, s), append(t.refs, 1)78+ }79+ t.index[s] = i80+ return i81+}82+83+// release counts one fewer use of entry i, if it is one.84+func (t *strTable) release(i uint32) {85+ if i == 0 {86+ return87+ }88+ if t.refs[i]--; t.refs[i] == 0 {89+ delete(t.index, t.strs[i])90+ t.strs[i] = ""91+ t.free = append(t.free, i)92+ }93+}94+95+// lookID is the entry of l in the table of looks, added if new, and96+// false when the table is full.97+func (st *cellStore) lookID(f Format, sty Style) (uint16, bool) {98+ l := look{f, sty}99+ if l == (look{}) {100+ return 0, true101+ }102+ if i, ok := st.lookIdx[l]; ok {103+ return i, true104+ }105+ if len(st.looks) > math.MaxUint16 {106+ return 0, false107+ }108+ if st.lookIdx == nil {109+ st.lookIdx = map[look]uint16{}110+ st.looks = []look{{}}111+ }112+ i := uint16(len(st.looks))113+ st.looks = append(st.looks, l)114+ st.lookIdx[l] = i115+ return i, true116+}117+118+// lookOf is sl's format and style.119+func (st *cellStore) lookOf(sl slot) look {120+ if sl.look == 0 {121+ return look{}122+ }123+ return st.looks[sl.look]124+}125+126+// plainSlot is c as a plain slot, or false if c is rich. It adds the127+// strings it uses to the table.128+func (st *cellStore) plainSlot(c *Cell) (slot, bool) {129+ if c.Note != "" || c.derived || c.spilled || !c.auto.IsZero() || strings.HasPrefix(c.Input, "=") {130+ return slot{}, false131+ }132+ lk, ok := st.lookID(c.Format, c.Style)133+ if !ok {134+ return slot{}, false135+ }136+ sl := slot{look: lk}137+ switch e := c.expr.(type) {138+ case nil:139+ switch {140+ case c.Input == "":141+ sl.kind = slotBlank142+ case c.Format.Kind != FmtText && strings.HasPrefix(c.Input, "'"):143+ return slot{}, false // its value isn't its input144+ default:145+ sl.kind, sl.ref = slotText, st.strs.add(c.Input)146+ }147+ case formula.Num:148+ if math.IsNaN(e.V) || math.IsInf(e.V, 0) {149+ return slot{}, false // its value is #NUM!150+ }151+ sl.kind, sl.num = slotNum, e.V152+ if d, ok := numDec(e.V, c.Input); ok {153+ sl.dec = d154+ } else {155+ sl.ref = st.strs.add(c.Input)156+ }157+ case formula.Bool:158+ sl.kind = slotBool159+ if e.V {160+ sl.num = 1161+ }162+ if c.Input != boolText(e.V) {163+ sl.ref = st.strs.add(c.Input)164+ }165+ default:166+ return slot{}, false167+ }168+ return sl, true169+}170+171+// numDec is how input prints v, as slot.dec, or false if it isn't v172+// printed plainly.173+func numDec(v float64, input string) (uint8, bool) {174+ var buf [64]byte175+ if string(strconv.AppendFloat(buf[:0], v, 'f', -1, 64)) == input {176+ return 0, true177+ }178+ i := strings.IndexByte(input, '.')179+ if i < 0 || len(input)-i-1 >= maxDec {180+ return 0, false181+ }182+ d := len(input) - i - 1183+ if string(strconv.AppendFloat(buf[:0], v, 'f', d, 64)) == input {184+ return uint8(d + 1), true185+ }186+ return 0, false187+}188+189+func boolText(b bool) string {190+ if b {191+ return "TRUE"192+ }193+ return "FALSE"194+}195+196+// input is the entry of a plain slot.197+func (st *cellStore) input(sl slot) string {198+ switch {199+ case sl.kind == slotBlank:200+ return ""201+ case sl.ref != 0:202+ return st.strs.strs[sl.ref]203+ case sl.kind == slotBool:204+ return boolText(sl.num != 0)205+ }206+ return strconv.FormatFloat(sl.num, 'f', int(sl.dec)-1, 64)207+}208+209+// slotValue is the value of a plain slot.210+func (st *cellStore) slotValue(sl slot) Value {211+ switch sl.kind {212+ case slotNum:213+ return Value{Kind: Number, Num: sl.num}214+ case slotBool:215+ return Value{Kind: Bool, Num: sl.num}216+ case slotText:217+ return Value{Kind: Text, Str: st.strs.strs[sl.ref]}218+ }219+ return Value{}220+}221+222+// view is a plain slot as a Cell of its own: changing it changes nothing223+// stored.224+func (st *cellStore) view(sl slot) *Cell {225+ lk := st.lookOf(sl)226+ c := &Cell{Input: st.input(sl), Value: st.slotValue(sl), Format: lk.f, Style: lk.st}227+ switch sl.kind {228+ case slotNum:229+ c.expr = formula.Num{V: sl.num}230+ case slotBool:231+ c.expr = formula.Bool{V: sl.num != 0}232+ }233+ return c234+}235+236+// releaseSlot counts one fewer use of what sl refers to.237+func (st *cellStore) releaseSlot(sl slot) {238+ switch sl.kind {239+ case slotRich:240+ st.rich[sl.ref] = richCell{}241+ st.richFree = append(st.richFree, sl.ref)242+ case slotNum, slotBool, slotText:243+ st.strs.release(sl.ref)244+ }245+}246+247+// Estimated heap of the store's parts, for the undo history's budget248+// (historysize.go): a slot with its share of the blocks and indexes, as249+// BenchmarkMemory measures it, and a string's entry in the table.250+const (251+ slotBytes = 20252+ strBytes = 64253+)254+255+// size estimates the heap the cells hold: a slot each, and the rich256+// cells and strings on top.257+func (st *cellStore) size() int64 {258+ n := int64(st.len()) * slotBytes259+ for _, rc := range st.rich {260+ n += cellSize(rc.c)261+ }262+ for _, s := range st.strs.strs {263+ n += strBytes + int64(len(s))264+ }265+ return n266+}267+268+// richCell is a rich cell and where it is.269+type richCell struct {270+ c *Cell271+ a Addr272+}273+274+// addRich stores c, at a, in the side table and returns its entry.275+func (st *cellStore) addRich(a Addr, c *Cell) uint32 {276+ if n := len(st.richFree); n > 0 {277+ i := st.richFree[n-1]278+ st.richFree = st.richFree[:n-1]279+ st.rich[i] = richCell{c, a}280+ return i281+ }282+ st.rich = append(st.rich, richCell{c, a})283+ return uint32(len(st.rich) - 1)284+}
A internal/sheet/slot_test.go +144 -0
@@ -0,0 +1,144 @@
1+package sheet2+3+import (4+ "reflect"5+ "strings"6+ "testing"7+8+ "github.com/FelineStateMachine/012/internal/formula"9+)10+11+// literalValue is what recalculation computes for an entry that isn't a12+// formula (evaluate.go).13+func literalValue(c *Cell) Value {14+ switch e := c.expr.(type) {15+ case formula.Num:16+ return num(e.V)17+ case formula.Bool:18+ return boolean(e.V)19+ }20+ switch {21+ case c.Input == "":22+ return Value{}23+ case c.Format.Kind == FmtText:24+ return Value{Kind: Text, Str: c.Input}25+ }26+ return Value{Kind: Text, Str: strings.TrimPrefix(c.Input, "'")}27+}28+29+// roundTripCells are cells of every kind: entries of each sort under30+// formats, styles and notes, with the values recalculation gives them.31+func roundTripCells() []*Cell {32+ inputs := []string{33+ "1", "1.50", "-0", "0.1", "1e5", "007", "1.0000000000000000001", "123456789012345678901234567890",34+ "$1,200", "12%", "9/26/2026", "2026-09-26", "13:45", "TRUE", "true", "FALSE", "hello", "'123", "'",35+ "=A1", "=5", "=TRUE", "+5", "-5", "+A1", " 7 ", "ünïcode", "#N/A", "1e400", "",36+ }37+ formats := []Format{{}, Preset(FmtCurrency), {Kind: FmtText}, {Kind: FmtDate, Pattern: "yyyy-mm-dd"}}38+ styles := []Style{{}, {Bold: true}, {Align: AlignRight, own: true}}39+ var out []*Cell40+ for i := range len(inputs) * len(formats) * len(styles) * 4 {41+ input, f := inputs[i%len(inputs)], formats[i/len(inputs)%len(formats)]42+ sty, rest := styles[i/len(inputs)/len(formats)%len(styles)], i/len(inputs)/len(formats)/len(styles)43+ c, err := newCell(input, f, sty, rest&1 == 1)44+ if rest&2 == 2 {45+ c = c.withNote("a note")46+ }47+ if err != nil || c == nil {48+ continue49+ }50+ c.Value = literalValue(c)51+ if c.expr != nil && !c.literal() {52+ c.Value = Value{} // a formula: only recalculation sets it53+ }54+ out = append(out, c)55+ }56+ return out57+}58+59+// Every cell comes back from the store as it went in, whether it is kept60+// as a plain slot or whole: its entry exactly as typed, its value, format,61+// style, note and expression.62+func TestSlotRoundTrip(t *testing.T) {63+ st := newCellStore()64+ a := Addr{Col: 3, Row: 1500}65+ cells := roundTripCells()66+ for _, c := range cells {67+ want := c.clone()68+ st.set(a, c)69+ checkSame(t, st.get(a), want)70+ checkSame(t, st.copyOf(a), want)71+ if v := st.value(a); v != want.Value {72+ t.Errorf("%q: value %v, want %v", want.Input, v, want.Value)73+ }74+ if got := st.filledAt(a); got != !want.Blank() {75+ t.Errorf("%q: filled %v", want.Input, got)76+ }77+ if !st.holds(a, st.get(a)) {78+ t.Errorf("%q: doesn't hold its own view", want.Input)79+ }80+ }81+ st.delete(a)82+ if st.len() != 0 || len(st.strs.index) != 0 || len(st.rich) != len(st.richFree) {83+ t.Errorf("after %d cells: %d stored, %d strings, %d rich", len(cells), st.len(), len(st.strs.index), len(st.rich)-len(st.richFree))84+ }85+}86+87+// literal reports whether c's expression is a typed number or boolean,88+// which recalculation doesn't evaluate as a formula.89+func (c *Cell) literal() bool {90+ switch c.expr.(type) {91+ case formula.Num, formula.Bool:92+ return !strings.HasPrefix(c.Input, "=")93+ }94+ return false95+}96+97+func checkSame(t *testing.T, got, want *Cell) {98+ t.Helper()99+ if got.Input != want.Input || got.Value != want.Value || got.Format != want.Format || got.Style != want.Style ||100+ got.Note != want.Note || got.auto != want.auto || got.derived != want.derived || got.spilled != want.spilled ||101+ !reflect.DeepEqual(got.expr, want.expr) || got.IsFormula() != want.IsFormula() {102+ t.Errorf("stored %+v\ncame back %+v", want, got)103+ }104+}105+106+// Cells sharing a string keep it while any of them uses it, and a cell107+// turning from plain to rich and back keeps its place.108+func TestSlotStrings(t *testing.T) {109+ st := newCellStore()110+ a1, a2 := Addr{}, Addr{Row: 1}111+ st.set(a1, &Cell{Input: "same"})112+ st.set(a2, &Cell{Input: "same"})113+ if len(st.strs.index) != 1 {114+ t.Fatalf("%d strings for one text", len(st.strs.index))115+ }116+ st.delete(a1)117+ if c := st.get(a2); c.Input != "same" || c.Value.Str != "same" {118+ t.Errorf("the other cell is %+v", c)119+ }120+ rich := &Cell{Input: "same", Note: "n"}121+ st.set(a2, rich)122+ if st.get(a2) != rich || st.richAt(a2) != rich || len(st.strs.index) != 0 {123+ t.Errorf("rich cell not kept whole, %d strings", len(st.strs.index))124+ }125+ num, _ := newCell("12.50", Format{}, Style{}, true)126+ st.set(a2, num)127+ if c := st.get(a2); c.Input != "12.50" || c.Value.Num != 12.5 || st.richAt(a2) != nil {128+ t.Errorf("back to plain: %+v", c)129+ }130+ if len(st.rich) != len(st.richFree) {131+ t.Errorf("%d rich cells left", len(st.rich)-len(st.richFree))132+ }133+}134+135+// A plain number costs its slot: no string, no Cell.136+func TestSlotCompact(t *testing.T) {137+ s := New()138+ for row := range 2048 {139+ s.Load(Addr{Row: row}, []string{"1", "2.5", "3.10", "-4"}[row%4], Format{}, Style{})140+ }141+ if n := len(s.cells.rich) + len(s.cells.strs.strs); n != 0 {142+ t.Errorf("%d rich cells and strings for plain numbers", n)143+ }144+}
M internal/sheet/stats.go +9 -11
M internal/sheet/stats_test.go +4 -4
M internal/sheet/store.go +302 -60
@@ -2,71 +2,285 @@ package sheet
22 33 import (44 "iter"5- "maps"5+ "math"66 "math/bits"77 "slices"8+ "strconv"9+10+ "github.com/FelineStateMachine/012/internal/formula"811 )912 1013 // cellStore holds a sheet's cells: those with contents or formatting, by1114 // address. The rest of the engine reads and writes cells only through12-// these methods, never the map beneath, so the representation can change13-// (to column blocks of compact values, with formulas and formats in side14-// tables; see docs/contributing/limits.md) without touching it.15+// these methods, never the representation beneath.16+//17+// Cells live in columns of blocks of blockRows rows: the stored18+// occupancy index (occupancy.go) has a bitmap per block of the rows19+// holding a cell, and each block keeps their slots (slot.go) in row20+// order, so a cell is found by indexing a column, a block and a popcount,21+// without hashing. A plain number costs its 16-byte slot. Rich cells22+// (formulas, notes, pivots' and spills' results) are whole Cells in a23+// side table the slot points at.24+//25+// get returns a rich cell's own Cell, which recalculation updates in26+// place, as ever, and a plain cell as a Cell made for the caller (a27+// view): the caller may keep it, but changing it changes nothing28+// stored; set is the one way to change a cell. Views cost an allocation29+// or three, so what reads many cells asks for what it needs: peek and30+// value for values, has and filledAt for presence, richAt for formulas,31+// look for formatting, and the key iterators for addresses.1532 //16-// Next to the map, two occupancy indexes (occupancy.go) record which17-// cells are stored and which have contents, so a range is read at the18-// cost of what it holds, and the used range and data edges are found19-// without scanning.33+// A second occupancy index records which cells have contents, so the34+// used range and data edges are found without scanning.2035 //21-// Iteration order of all is unspecified; inRange yields in row-major22-// order. Cells may be set or deleted while iterating, with a map's rules:23-// a deleted cell not yet reached isn't yielded, a new one may or may not24-// be.36+// Iteration yields column by column, except inRange and keysIn, which37+// yield in row-major order. Cells may be set or deleted while iterating,38+// with a map's rules: a deleted cell not yet reached isn't yielded, a new39+// one may or may not be.2540 type cellStore struct {26- m map[Addr]*Cell27- stored occupancy // every cell in m41+ stored occupancy // every cell, with the blocks holding their slots2842 filled occupancy // the cells with contents (not Blank), with their statistics; see stats.go2943 statsUsed bool // a selection has been summed from the statistics index44+45+ strs strTable // plain cells' text46+ looks []look // formats and styles, by slot.look47+ lookIdx map[look]uint16 // looks' entries48+ rich []richCell // rich cells, by slot.ref49+ richFree []uint32 // entries of rich not in use50+}51+52+func newCellStore() cellStore { return cellStore{} }53+54+// find returns the block holding a's slot and its position there, or nil.55+func (st *cellStore) find(a Addr) (*rowBlock, int) {56+ b := st.stored.col(a.Col).block(a.Row >> blockShift)57+ if b == nil {58+ return nil, 059+ }60+ i, ok := b.index(a.Row & (blockRows - 1))61+ if !ok {62+ return nil, 063+ }64+ return b, i65+}66+67+// get returns the cell at a, or nil: a rich cell itself, a plain one as68+// a view.69+func (st *cellStore) get(a Addr) *Cell {70+ b, i := st.find(a)71+ if b == nil {72+ return nil73+ }74+ return st.cellOf(b.vals[i])75+}76+77+func (st *cellStore) cellOf(sl slot) *Cell {78+ if sl.kind == slotRich {79+ return st.rich[sl.ref].c80+ }81+ return st.view(sl)82+}83+84+// peek returns the rich cell at a, or else the plain cell's value, and85+// whether a holds a cell at all. It allocates nothing: recalculation86+// reads every cell through it.87+func (st *cellStore) peek(a Addr) (*Cell, Value, bool) {88+ b, i := st.find(a)89+ if b == nil {90+ return nil, Value{}, false91+ }92+ sl := b.vals[i]93+ if sl.kind == slotRich {94+ return st.rich[sl.ref].c, Value{}, true95+ }96+ return nil, st.slotValue(sl), true97+}98+99+// value returns the value of the cell at a.100+func (st *cellStore) value(a Addr) Value {101+ c, v, _ := st.peek(a)102+ if c != nil {103+ return c.Value104+ }105+ return v30106 }31107 32-func newCellStore() cellStore { return cellStore{m: make(map[Addr]*Cell)} }108+// has reports whether a holds a cell, if only formatting.109+func (st *cellStore) has(a Addr) bool { return st.stored.has(a) }33110 34-// get returns the cell at a, or nil.35-func (st *cellStore) get(a Addr) *Cell { return st.m[a] }111+// filledAt reports whether the cell at a has contents: !get(a).Blank().112+func (st *cellStore) filledAt(a Addr) bool { return st.filled.has(a) }36113 37-// set stores c, which isn't nil, at a.114+// richAt returns the cell at a if it is rich (a formula, a note, a115+// pivot's or a spill's result), or nil.116+func (st *cellStore) richAt(a Addr) *Cell {117+ b, i := st.find(a)118+ if b == nil || b.vals[i].kind != slotRich {119+ return nil120+ }121+ return st.rich[b.vals[i].ref].c122+}123+124+// look returns the format and style of the cell at a, and whether there125+// is one.126+func (st *cellStore) look(a Addr) (Format, Style, bool) {127+ b, i := st.find(a)128+ if b == nil {129+ return Format{}, Style{}, false130+ }131+ if sl := b.vals[i]; sl.kind == slotRich {132+ c := st.rich[sl.ref].c133+ return c.Format, c.Style, true134+ } else {135+ l := st.lookOf(sl)136+ return l.f, l.st, true137+ }138+}139+140+// holds reports whether the cell at a is c: the same Cell if rich, the141+// same contents and formatting if plain; nil for none.142+func (st *cellStore) holds(a Addr, c *Cell) bool {143+ b, i := st.find(a)144+ if b == nil || c == nil {145+ return b == nil && c == nil146+ }147+ sl := b.vals[i]148+ if sl.kind == slotRich {149+ return st.rich[sl.ref].c == c150+ }151+ if l := st.lookOf(sl); c.Note != "" || c.derived || c.spilled || !c.auto.IsZero() || c.Format != l.f || c.Style != l.st {152+ return false153+ }154+ switch sl.kind {155+ case slotText:156+ return c.expr == nil && c.Input == st.strs.strs[sl.ref]157+ case slotNum:158+ e, ok := c.expr.(formula.Num)159+ return ok && math.Float64bits(e.V) == math.Float64bits(sl.num) && st.inputIs(sl, c.Input)160+ case slotBool:161+ e, ok := c.expr.(formula.Bool)162+ return ok && e.V == (sl.num != 0) && st.inputIs(sl, c.Input)163+ }164+ return c.expr == nil && c.Input == ""165+}166+167+// inputIs reports whether input is the entry of the plain number or168+// boolean sl, without making it.169+func (st *cellStore) inputIs(sl slot, input string) bool {170+ switch {171+ case sl.ref != 0:172+ return input == st.strs.strs[sl.ref]173+ case sl.kind == slotBool:174+ return input == boolText(sl.num != 0)175+ }176+ var buf [64]byte177+ return string(strconv.AppendFloat(buf[:0], sl.num, 'f', int(sl.dec)-1, 64)) == input178+}179+180+// copyOf is the cell at a as a Cell the caller owns, or nil.181+func (st *cellStore) copyOf(a Addr) *Cell {182+ b, i := st.find(a)183+ if b == nil {184+ return nil185+ }186+ if sl := b.vals[i]; sl.kind == slotRich {187+ return st.rich[sl.ref].c.clone()188+ } else {189+ return st.view(sl)190+ }191+}192+193+// set stores c, which isn't nil, at a. A rich c is kept as it is, so194+// recalculation can update it in place; a plain one is copied into a195+// slot, and its value is its entry's from then on.38196 func (st *cellStore) set(a Addr, c *Cell) {39- old, ok := st.m[a]40- if !ok {41- st.stored.mark(a)197+ b, i := st.find(a)198+ if b == nil {199+ b = st.stored.mark(a)200+ i, _ = b.index(a.Row & (blockRows - 1))201+ b.vals = slices.Insert(b.vals, i, slot{kind: slotBlank})202+ }203+ old := b.vals[i]204+ sl, ok := st.plainSlot(c) // before releasing old, which may share its strings205+ switch {206+ case ok:207+ st.releaseSlot(old)208+ case old.kind == slotRich:209+ st.rich[old.ref] = richCell{c, a}210+ sl = old211+ default:212+ st.releaseSlot(old)213+ sl = slot{kind: slotRich, ref: st.addRich(a, c)}42214 }43- switch was, is := ok && !old.Blank(), !c.Blank(); {215+ b.vals[i] = sl216+ st.filledAs(a, st.filled.has(a), !c.Blank())217+ st.touch(a)218+}219+220+// filledAs updates the index of filled cells for a cell at a that was221+// and is filled or not.222+func (st *cellStore) filledAs(a Addr, was, is bool) {223+ switch {44224 case is && !was:45225 st.filled.mark(a)46226 case was && !is:47227 st.filled.unmark(a)48228 }49- st.m[a] = c50- st.touch(a)51229 }52230 53231 // delete removes the cell at a, if any.54232 func (st *cellStore) delete(a Addr) {55- if old, ok := st.m[a]; ok {56- st.touch(a)57- st.stored.unmark(a)58- if !old.Blank() {59- st.filled.unmark(a)60- }61- delete(st.m, a)233+ b, i := st.find(a)234+ if b == nil {235+ return62236 }237+ st.touch(a)238+ old := b.vals[i]239+ if st.filled.has(a) {240+ st.filled.unmark(a)241+ }242+ b.vals = slices.Delete(b.vals, i, i+1)243+ st.stored.unmark(a)244+ st.releaseSlot(old)63245 }64246 65247 // len is the number of cells stored, formatting-only ones included.66-func (st *cellStore) len() int { return len(st.m) }248+func (st *cellStore) len() int { return st.stored.n }249+250+// filledLen is the number of cells with contents.251+func (st *cellStore) filledLen() int { return st.filled.n }252+253+// all yields every stored cell, plain ones as views.254+func (st *cellStore) all() iter.Seq2[Addr, *Cell] { return st.cellsOf(st.keys()) }255+256+// keys yields the address of every stored cell, column by column.257+func (st *cellStore) keys() iter.Seq[Addr] {258+ return st.anyKeysIn(Rect{To: Addr{Col: MaxCols - 1, Row: MaxRows - 1}})259+}67260 68-// all yields every stored cell.69-func (st *cellStore) all() iter.Seq2[Addr, *Cell] { return maps.All(st.m) }261+// richCells yields every rich cell: the formulas, notes and results of262+// pivots and spills.263+func (st *cellStore) richCells() iter.Seq2[Addr, *Cell] {264+ return func(yield func(Addr, *Cell) bool) {265+ for i := 0; i < len(st.rich); i++ {266+ if rc := st.rich[i]; rc.c != nil && !yield(rc.a, rc.c) {267+ return268+ }269+ }270+ }271+}272+273+// cellsOf yields the cells at the addresses of keys, skipping any no274+// longer stored.275+func (st *cellStore) cellsOf(keys iter.Seq[Addr]) iter.Seq2[Addr, *Cell] {276+ return func(yield func(Addr, *Cell) bool) {277+ for a := range keys {278+ if c := st.get(a); c != nil && !yield(a, c) {279+ return280+ }281+ }282+ }283+}70284 71285 // bounds returns the smallest range holding every stored cell of r, and72286 // false if r holds none.
@@ -86,8 +300,24 @@ func (st *cellStore) colFill(c, r0, r1 int, dst []Addr) int {
86300 87301 // inRange yields the stored cells in r in row-major order, visiting only88302 // the rows of each column that hold cells.89-func (st *cellStore) inRange(r Rect) iter.Seq2[Addr, *Cell] {90- return func(yield func(Addr, *Cell) bool) {303+func (st *cellStore) inRange(r Rect) iter.Seq2[Addr, *Cell] { return st.cellsOf(st.keysIn(r)) }304+305+// valuesIn yields the values of the stored cells in r in row-major306+// order, without making views of them.307+func (st *cellStore) valuesIn(r Rect) iter.Seq2[Addr, Value] {308+ return func(yield func(Addr, Value) bool) {309+ for a := range st.keysIn(r) {310+ if !yield(a, st.value(a)) {311+ return312+ }313+ }314+ }315+}316+317+// keysIn yields the addresses of the stored cells in r in row-major318+// order.319+func (st *cellStore) keysIn(r Rect) iter.Seq[Addr] {320+ return func(yield func(Addr) bool) {91321 cols := slices.Clone(st.stored.colsIn(r.From.Col, r.To.Col))92322 if len(cols) == 0 || r.From.Row > r.To.Row {93323 return
@@ -96,8 +326,8 @@ func (st *cellStore) inRange(r Rect) iter.Seq2[Addr, *Cell] {
96326 for _, id := range st.stored.blockIDs(cols, b0, b1) {97327 var in []colBlock98328 for _, c := range cols {99- if b := st.stored.cols[c].block(id); b != nil {100- in = append(in, colBlock{c, b})329+ if st.stored.col(c).block(id) != nil {330+ in = append(in, colBlock{col: c})101331 }102332 }103333 if !st.yieldBlock(in, id, r, yield) {
@@ -112,7 +342,7 @@ func (st *cellStore) inRange(r Rect) iter.Seq2[Addr, *Cell] {
112342 func (st *cellStore) rangeFill(r Rect, from Addr, dst []Addr) int {113343 n := 0114344 if from.Col > r.From.Col { // the rest of a row first115- for a := range st.inRange(Rect{From: from, To: Addr{Col: r.To.Col, Row: from.Row}}) {345+ for a := range st.keysIn(Rect{From: from, To: Addr{Col: r.To.Col, Row: from.Row}}) {116346 dst[n] = a117347 if n++; n == len(dst) {118348 return n
@@ -120,7 +350,7 @@ func (st *cellStore) rangeFill(r Rect, from Addr, dst []Addr) int {
120350 }121351 from = Addr{Col: r.From.Col, Row: from.Row + 1}122352 }123- for a := range st.inRange(Rect{From: from, To: r.To}) {353+ for a := range st.keysIn(Rect{From: from, To: r.To}) {124354 dst[n] = a125355 if n++; n == len(dst) {126356 break
@@ -129,19 +359,27 @@ func (st *cellStore) rangeFill(r Rect, from Addr, dst []Addr) int {
129359 return n130360 }131361 132-// anyInRange yields the stored cells in r in no particular order,133-// visiting whichever is cheaper: the index, row by row, or every stored134-// cell (iterating the map costs a quarter of a lookup per cell) when r135-// is more than a quarter as large as the sheet's cells are many.362+// anyInRange yields the stored cells in r in no particular order.136363 func (st *cellStore) anyInRange(r Rect) iter.Seq2[Addr, *Cell] {137- area := (r.To.Col - r.From.Col + 1) * (r.To.Row - r.From.Row + 1)138- if area <= len(st.m)/4 {139- return st.inRange(r)140- }141- return func(yield func(Addr, *Cell) bool) {142- for a, c := range st.m {143- if r.Contains(a) && !yield(a, c) {144- return364+ return st.cellsOf(st.anyKeysIn(r))365+}366+367+// anyKeysIn yields the addresses of the stored cells in r column by368+// column, which walks each block's bitmap once.369+func (st *cellStore) anyKeysIn(r Rect) iter.Seq[Addr] {370+ return func(yield func(Addr) bool) {371+ if r.From.Row > r.To.Row {372+ return373+ }374+ b0, b1 := r.From.Row>>blockShift, r.To.Row>>blockShift375+ for _, c := range slices.Clone(st.stored.colsIn(r.From.Col, r.To.Col)) {376+ if st.stored.col(c) == nil {377+ continue378+ }379+ for _, id := range st.stored.blockIDs([]int{c}, b0, b1) {380+ if !st.yieldBlock([]colBlock{{col: c}}, id, r, yield) {381+ return382+ }145383 }146384 }147385 }
@@ -152,15 +390,20 @@ type colBlock struct {
152390 b *rowBlock153391 }154392 155-// yieldBlock yields the cells of block id in the columns in, row by row,156-// limited to the rows of r.157-func (st *cellStore) yieldBlock(in []colBlock, id int, r Rect, yield func(Addr, *Cell) bool) bool {393+// yieldBlock yields the addresses of the cells of block id in the394+// columns in, row by row, limited to the rows of r. Each word is read395+// from the blocks as they are then, and each address checked before it396+// is yielded, so cells may be set and deleted meanwhile.397+func (st *cellStore) yieldBlock(in []colBlock, id int, r Rect, yield func(Addr) bool) bool {158398 base := id << blockShift159399 lo, hi := max(r.From.Row-base, 0), min(r.To.Row-base, blockRows-1)160400 for w := lo >> 6; w <= hi>>6; w++ {161401 var occ uint64162- for _, cb := range in {163- occ |= cb.b.bits[w]402+ for i := range in {403+ in[i].b = st.stored.col(in[i].col).block(id)404+ if in[i].b != nil {405+ occ |= in[i].b.bits[w]406+ }164407 }165408 occ &= wordMask(w, lo, hi)166409 for occ != 0 {
@@ -168,11 +411,10 @@ func (st *cellStore) yieldBlock(in []colBlock, id int, r Rect, yield func(Addr,
168411 occ &^= 1 << bit169412 row := base + w<<6 + bit170413 for _, cb := range in {171- if cb.b.bits[w]&(1<<bit) == 0 {414+ if cb.b == nil || cb.b.bits[w]&(1<<bit) == 0 {172415 continue173416 }174- a := Addr{Col: cb.col, Row: row}175- if c := st.m[a]; c != nil && !yield(a, c) {417+ if a := (Addr{Col: cb.col, Row: row}); st.stored.has(a) && !yield(a) {176418 return false177419 }178420 }
M internal/sheet/store_test.go +77 -24
@@ -6,53 +6,67 @@ import (
66 "testing"77 )88 9-// The occupancy index agrees with the map: inRange yields exactly the10-// stored cells of a range in row-major order, bounds is their bounding11-// box and nextRow finds the neighbours, through random sets and deletes9+// The store agrees with a map of what was stored: get returns each10+// cell's entry, inRange yields exactly the stored cells of a range in11+// row-major order, bounds is their bounding box and nextRow finds the12+// neighbours, through random sets and deletes of plain and rich cells1213 // that cross block boundaries.1314 func TestCellStoreIndex(t *testing.T) {1415 rng := rand.New(rand.NewPCG(1, 2))1516 st := newCellStore()17+ model := map[Addr]string{}1618 rows := []int{0, 1, 63, 64, 1023, 1024, 1025, 5000, 70000, MaxRows - 1}19+ inputs := []string{"x", "", "5", "2.50", "=A1", "TRUE"} // "" is formatting only1720 for i := range 3000 {1821 a := Addr{Col: rng.IntN(40), Row: rows[rng.IntN(len(rows))] + rng.IntN(3)}1922 a.Row = min(a.Row, MaxRows-1)2023 if rng.IntN(3) == 0 {2124 st.delete(a)25+ delete(model, a)2226 } else {23- input := "x"24- if rng.IntN(4) == 0 {25- input = "" // formatting only27+ input := inputs[rng.IntN(len(inputs))]28+ c, err := newCell(input, Format{}, Style{Bold: input == ""}, true)29+ if err != nil {30+ t.Fatal(err)2631 }27- st.set(a, &Cell{Input: input})32+ st.set(a, c)33+ model[a] = input2834 }2935 if i%100 == 0 {30- checkStore(t, &st, rng)36+ checkStore(t, &st, model, rng)3137 }3238 }3339 for a := range st.all() {3440 st.delete(a)3541 }36- if slices.ContainsFunc(st.stored.cols, func(c *colIndex) bool { return c != nil }) || len(st.stored.colIDs) != 0 || len(st.filled.colIDs) != 0 {42+ if st.len() != 0 || slices.ContainsFunc(st.stored.cols, func(c *colIndex) bool { return c != nil }) || len(st.stored.colIDs) != 0 || len(st.filled.colIDs) != 0 {3743 t.Errorf("empty store keeps %d columns", len(st.stored.colIDs))3844 }3945 }4046 41-func checkStore(t *testing.T, st *cellStore, rng *rand.Rand) {47+func checkStore(t *testing.T, st *cellStore, model map[Addr]string, rng *rand.Rand) {4248 t.Helper()49+ if st.len() != len(model) {50+ t.Fatalf("%d cells stored, want %d", st.len(), len(model))51+ }52+ for a, input := range model {53+ if c := st.get(a); c == nil || c.Input != input {54+ t.Fatalf("get(%v) = %+v, want %q", a, c, input)55+ }56+ }4357 for range 20 {4458 r := NewRect(Addr{Col: rng.IntN(45), Row: rng.IntN(80000)}, Addr{Col: rng.IntN(45), Row: rng.IntN(MaxRows)})45- checkRange(t, st, r)46- checkFilled(t, st, r)47- checkNextRow(t, st, rng.IntN(40), rng.IntN(MaxRows))59+ checkRange(t, st, model, r)60+ checkFilled(t, st, model, r)61+ checkNextRow(t, st, model, rng.IntN(40), rng.IntN(MaxRows))4862 }4963 }5064 51-// checkRange compares inRange and bounds with the map.52-func checkRange(t *testing.T, st *cellStore, r Rect) {65+// checkRange compares inRange and bounds with the model.66+func checkRange(t *testing.T, st *cellStore, model map[Addr]string, r Rect) {5367 t.Helper()5468 var want []Addr55- for a := range st.all() {69+ for a := range model {5670 if r.Contains(a) {5771 want = append(want, a)5872 }
@@ -65,6 +79,14 @@ func checkRange(t *testing.T, st *cellStore, r Rect) {
6579 if !slices.Equal(got, want) {6680 t.Fatalf("inRange(%v): %d cells, want %d", r, len(got), len(want))6781 }82+ got = got[:0]83+ for a := range st.anyKeysIn(r) {84+ got = append(got, a)85+ }86+ sortAddrs(got)87+ if !slices.Equal(got, want) {88+ t.Fatalf("anyKeysIn(%v): %d cells, want %d", r, len(got), len(want))89+ }6890 b, ok := st.bounds(r)6991 if ok != (len(want) > 0) {7092 t.Fatalf("bounds(%v) found %v with %d cells", r, ok, len(want))
@@ -80,15 +102,15 @@ func checkRange(t *testing.T, st *cellStore, r Rect) {
80102 }81103 }82104 83-// checkFilled compares the index of cells with contents with the map.84-func checkFilled(t *testing.T, st *cellStore, r Rect) {105+// checkFilled compares the index of cells with contents with the model.106+func checkFilled(t *testing.T, st *cellStore, model map[Addr]string, r Rect) {85107 t.Helper()86108 var filled []Addr87- for a, c := range st.all() {88- if r.Contains(a) && !c.Blank() {109+ for a, input := range model {110+ if r.Contains(a) && input != "" {89111 filled = append(filled, a)90112 }91- if st.filled.has(a) == c.Blank() {113+ if st.filled.has(a) != (input != "") {92114 t.Fatalf("filled index says %v for %v", st.filled.has(a), a)93115 }94116 }
@@ -98,14 +120,14 @@ func checkFilled(t *testing.T, st *cellStore, r Rect) {
98120 }99121 }100122 101-// checkNextRow compares nextRow both ways with the map.102-func checkNextRow(t *testing.T, st *cellStore, c, row int) {123+// checkNextRow compares nextRow both ways with the model.124+func checkNextRow(t *testing.T, st *cellStore, model map[Addr]string, c, row int) {103125 t.Helper()104126 next, ok := st.stored.nextRow(c, row, 1)105127 prev, okp := st.stored.nextRow(c, row, -1)106128 want1, ok1 := MaxRows, false107129 want2, ok2 := -1, false108- for a := range st.all() {130+ for a := range model {109131 if a.Col != c {110132 continue111133 }
@@ -120,3 +142,34 @@ func checkNextRow(t *testing.T, st *cellStore, c, row int) {
120142 t.Fatalf("nextRow(%d, %d) = %d %v / %d %v, want %d %v / %d %v", c, row, next, ok, prev, okp, want1, ok1, want2, ok2)121143 }122144 }145+146+// Cells set and deleted while iterating follow a map's rules: a cell147+// deleted before it is reached isn't yielded, and every cell stored148+// throughout is yielded once.149+func TestCellStoreChangeWhileIterating(t *testing.T) {150+ st := newCellStore()151+ for row := range 3000 {152+ for col := range 3 {153+ st.set(Addr{Col: col, Row: row}, &Cell{Input: "x"})154+ }155+ }156+ seen := map[Addr]bool{}157+ for a := range st.keysIn(NewRect(Addr{}, Addr{Col: 2, Row: 2999})) {158+ if seen[a] {159+ t.Fatalf("%v yielded twice", a)160+ }161+ seen[a] = true162+ if a.Col == 0 {163+ st.delete(Addr{Col: 2, Row: a.Row}) // not yet reached164+ st.delete(Addr{Col: 1, Row: a.Row + 1}) // nor this165+ }166+ }167+ for a := range seen {168+ if !st.has(a) {169+ t.Fatalf("deleted %v was yielded", a)170+ }171+ }172+ if len(seen) != st.len() {173+ t.Errorf("%d yielded, %d stored", len(seen), st.len())174+ }175+}
M internal/sheet/stress_test.go +63 -5
@@ -9,6 +9,7 @@ import (
99 "bytes"1010 "fmt"1111 "runtime"12+ "sync"1213 "testing"1314 1415 "github.com/FelineStateMachine/012/internal/sheet"
@@ -243,17 +244,34 @@ func saveShapes() []namedBuild {
243244 {"dense-8192x26", func() *sheet.Sheet { return stress.Dense(stress.Rows, 26) }},244245 {"dense-8192x256", func() *sheet.Sheet { return stress.Dense(stress.Rows, stress.Cols) }},245246 {"chain-8192", func() *sheet.Sheet { return stress.Chain(stress.Rows) }},247+ {"dense-1Mx10", func() *sheet.Sheet { return stress.Dense(stress.MaxRows, stress.MaxCols) }},246248 }247249 }248250 249-// BenchmarkMemory reports the live heap per non-blank cell after a load.251+// BenchmarkMemory reports the live heap per non-blank cell after a load:252+// built through Load, or opened from a .012 file written beforehand.250253 func BenchmarkMemory(b *testing.B) {251- for _, sh := range []namedBuild{252- {"numbers", func() *sheet.Sheet { return stress.Dense(stress.Rows, 64) }},253- {"formulas", func() *sheet.Sheet { return stress.Chain(stress.Rows) }},254- {"text-200", func() *sheet.Sheet { return stress.LongText(stress.Rows, 200) }},254+ for _, sh := range []struct {255+ name string256+ prep func()257+ build func() *sheet.Sheet258+ }{259+ {"numbers", nil, func() *sheet.Sheet { return stress.Dense(stress.Rows, 64) }},260+ {"numbers-1Mx10", nil, func() *sheet.Sheet { return stress.Dense(stress.MaxRows, stress.MaxCols) }},261+ {"formulas", nil, func() *sheet.Sheet { return stress.Chain(stress.Rows) }},262+ {"text-200", nil, func() *sheet.Sheet { return stress.LongText(stress.Rows, 200) }},263+ {"open-8192x256", func() { denseFile() }, func() *sheet.Sheet {264+ s, err := sheet.Read(bytes.NewReader(denseFile()))265+ if err != nil {266+ b.Fatal(err)267+ }268+ return s269+ }},255270 } {256271 b.Run(sh.name, func(b *testing.B) {272+ if sh.prep != nil {273+ sh.prep()274+ }257275 for b.Loop() {258276 before := heap()259277 s := sh.build()
@@ -265,6 +283,46 @@ func BenchmarkMemory(b *testing.B) {
265283 }266284 }267285 286+// denseFile is the .012 file of 8192x256 numbers.287+var denseFile = sync.OnceValue(func() []byte {288+ var buf bytes.Buffer289+ if err := stress.Dense(stress.Rows, stress.Cols).Write(&buf); err != nil {290+ panic(err)291+ }292+ return buf.Bytes()293+})294+295+// BenchmarkRead is the cost of reading a cell's value, per cell of 8192296+// x 26 numbers: through Sheet.Value, and as formulas read cells in a297+// recalculation (a SUM of each column, read directly).298+func BenchmarkRead(b *testing.B) {299+ s := stress.Dense(stress.Rows, 26)300+ cells := float64(stress.Rows * 26)301+ b.Run("value", func(b *testing.B) {302+ sum := 0.0303+ for b.Loop() {304+ for row := range stress.Rows {305+ for col := range 26 {306+ sum += s.Value(sheet.Addr{Col: col, Row: row}).Num307+ }308+ }309+ }310+ b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(b.N)/cells, "ns/cell")311+ })312+ b.Run("recalc", func(b *testing.B) {313+ for col := range 26 {314+ name := sheet.ColName(col)315+ if err := s.Set(sheet.Addr{Col: col, Row: stress.Rows}, fmt.Sprintf("=SUM(%s1:%s%d)", name, name, stress.Rows)); err != nil {316+ b.Fatal(err)317+ }318+ }319+ for b.Loop() {320+ s.RecalcAll()321+ }322+ b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(b.N)/cells, "ns/cell")323+ })324+}325+268326 // heap is the live heap after a full collection.269327 func heap() uint64 {270328 runtime.GC()
M internal/sheet/validation.go +1 -1
A internal/sheet/validcheck.go +53 -0
@@ -0,0 +1,53 @@
1+package sheet2+3+import "slices"4+5+// Validation of what pastes and fills write, as Sheets does it: they6+// don't ask cell by cell, so the UI checks the range they wrote after7+// the fact, and takes the whole change back (Discard) when a rule that8+// rejects fails.9+10+// InvalidIn lists the cells in r whose contents fail their validation,11+// in reading order: blanks never do. A cell's rule decides whether its12+// entry is refused (Reject) or kept and marked. The cost is the cells13+// stored where r meets a rule's ranges.14+func (s *Sheet) InvalidIn(r Rect) []*InvalidEntry {15+ var out []*InvalidEntry16+ for _, v := range s.rules.validations {17+ for _, vr := range v.Ranges {18+ in := Rect{19+ From: Addr{Col: max(vr.From.Col, r.From.Col), Row: max(vr.From.Row, r.From.Row)},20+ To: Addr{Col: min(vr.To.Col, r.To.Col), Row: min(vr.To.Row, r.To.Row)},21+ }22+ if in.From.Col > in.To.Col || in.From.Row > in.To.Row {23+ continue24+ }25+ for _, a := range s.cellsIn(in) {26+ if c := s.cells.get(a); !c.Blank() && s.Look(a).Invalid {27+ out = append(out, &InvalidEntry{Addr: a, Help: v.HelpText(), Reject: v.Reject})28+ }29+ }30+ }31+ }32+ slices.SortFunc(out, func(a, b *InvalidEntry) int {33+ if a.Addr.Row != b.Addr.Row {34+ return a.Addr.Row - b.Addr.Row35+ }36+ return a.Addr.Col - b.Addr.Col37+ })38+ return out39+}40+41+// Discard takes back the last change and forgets it, so redo can't bring42+// it back: a paste or fill refused after the fact. It reports whether43+// there was a change to take back.44+func (w *Workbook) Discard() bool {45+ if _, ok := w.swap(true); !ok {46+ return false47+ }48+ w.hist.redo = w.hist.redo[:len(w.hist.redo)-1]49+ return true50+}51+52+// Discard takes back the workbook's last change; see Workbook.Discard.53+func (s *Sheet) Discard() bool { return s.wb.Discard() }
M internal/sheet/workbook.go +2 -2
M internal/stress/gen.go +8 -0
M internal/ui/clipboard.go +8 -0
M internal/ui/columns.go +1 -3
M internal/ui/edit.go +7 -1
M internal/ui/fill.go +3 -0
M internal/ui/links.go +1 -1
A internal/ui/monochrome_test.go +302 -0
@@ -0,0 +1,302 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+ "github.com/FelineStateMachine/012/internal/ui/theme"9+)10+11+// Every state reads without color (docs/contributing/ux.md#visual-rules):12+// drawn with a monochrome theme, where every role has the same color,13+// each state still differs from a plain cell in its text or attributes.14+15+// attrs are the attributes a terminal cell is drawn with, colors aside.16+type attrs struct {17+ bold, faint, italic, reverse, strike bool18+ underline string // "" none, or the SGR 4 sub-parameter: 1 single, 3 curly, 4 dotted, 5 dashed19+}20+21+// monoCell is one terminal cell: its text and attributes.22+type monoCell struct {23+ text string24+ attrs25+}26+27+// monoCells splits a rendered line into cells, reading SGR attributes28+// and skipping colors and other escape sequences.29+func monoCells(line string) []monoCell {30+ var out []monoCell31+ var a attrs32+ for i := 0; i < len(line); {33+ switch {34+ case strings.HasPrefix(line[i:], "\x1b["):35+ j := i + 236+ for j < len(line) && (line[j] < 0x40 || line[j] > 0x7e) {37+ j++38+ }39+ if j < len(line) && line[j] == 'm' {40+ sgr(line[i+2:j], &a)41+ }42+ i = j + 143+ case strings.HasPrefix(line[i:], "\x1b]"): // OSC, e.g. a hyperlink, up to ST or BEL44+ end := strings.Index(line[i:], "\x1b\\")45+ bel := strings.IndexByte(line[i:], '\a')46+ switch {47+ case bel >= 0 && (end < 0 || bel < end):48+ i += bel + 149+ case end >= 0:50+ i += end + 251+ default:52+ i = len(line)53+ }54+ default:55+ r := []rune(line[i:])[0]56+ out = append(out, monoCell{text: string(r), attrs: a})57+ i += len(string(r))58+ }59+ }60+ return out61+}62+63+// sgr applies one SGR sequence's parameters to a.64+func sgr(params string, a *attrs) {65+ fields := strings.Split(params, ";")66+ for i := 0; i < len(fields); i++ {67+ p, sub, _ := strings.Cut(fields[i], ":")68+ switch p {69+ case "", "0":70+ *a = attrs{}71+ case "1":72+ a.bold = true73+ case "2":74+ a.faint = true75+ case "3":76+ a.italic = true77+ case "4":78+ a.underline = sub79+ if sub == "" {80+ a.underline = "1"81+ } else if sub == "0" {82+ a.underline = ""83+ }84+ case "7":85+ a.reverse = true86+ case "9":87+ a.strike = true88+ case "22":89+ a.bold, a.faint = false, false90+ case "23":91+ a.italic = false92+ case "24":93+ a.underline = ""94+ case "27":95+ a.reverse = false96+ case "29":97+ a.strike = false98+ case "38", "48", "58":99+ if sub == "" && i+1 < len(fields) {100+ switch fields[i+1] {101+ case "5":102+ i += 2103+ case "2":104+ i += 4105+ }106+ }107+ }108+ }109+}110+111+// monoLine is screen line y drawn in monochrome, as cells.112+func monoLine(m *Model, y int) []monoCell {113+ m.th = theme.Monochrome(m.th)114+ return monoCells(strings.Split(m.View().Content, "\n")[y])115+}116+117+// cellAt is the terminal cells of sheet cell a (in the first screen of118+// an unscrolled sheet).119+func cellAt(m *Model, a sheet.Addr) []monoCell {120+ x := m.hdrW() + a.Col*sheet.DefaultWidth121+ cells := monoLine(m, gridTop+a.Row)122+ return cells[x : x+sheet.DefaultWidth]123+}124+125+func cellText(cs []monoCell) string {126+ var b strings.Builder127+ for _, c := range cs {128+ b.WriteString(c.text)129+ }130+ return b.String()131+}132+133+// every reports whether f holds for every cell of cs; with text, only for134+// those that aren't blank.135+func every(cs []monoCell, text bool, f func(monoCell) bool) bool {136+ for _, c := range cs {137+ if text && strings.TrimSpace(c.text) == "" {138+ continue139+ }140+ if !f(c) {141+ return false142+ }143+ }144+ return true145+}146+147+func isReverse(c monoCell) bool { return c.reverse }148+func plain(c monoCell) bool { return c.attrs == attrs{} }149+150+func TestMonochromePointer(t *testing.T) {151+ m := newModel()152+ m.sheet.Set(addr("B1"), "text")153+ if p := cellAt(m, addr("A1")); !every(p, false, isReverse) {154+ t.Errorf("the pointer isn't in reverse video: %+v", p)155+ }156+ if c := cellAt(m, addr("B1")); !every(c, false, plain) {157+ t.Errorf("a plain cell has attributes: %+v", c)158+ }159+}160+161+func TestMonochromeSelection(t *testing.T) {162+ m := newModel()163+ press(t, m, "<shift+right>", "<shift+down>")164+ for _, a := range []string{"A1", "B1", "A2", "B2"} {165+ if c := cellAt(m, addr(a)); !every(c, false, isReverse) {166+ t.Errorf("%s in the selection isn't in reverse video", a)167+ }168+ }169+ if c := cellAt(m, addr("C3")); !every(c, false, plain) {170+ t.Error("a cell outside the selection has attributes")171+ }172+ // Column headers: the active cell's (the selection's anchor, A1)173+ // bold and reversed, another selected one reversed, the rest plain.174+ hdr := monoLine(m, headerLine)175+ x := m.hdrW()176+ col := func(c int) []monoCell { return hdr[x+c*sheet.DefaultWidth : x+(c+1)*sheet.DefaultWidth] }177+ if !every(col(0), true, func(c monoCell) bool { return c.reverse && c.bold }) {178+ t.Errorf("the active column's header: %+v", col(0))179+ }180+ if !every(col(1), true, func(c monoCell) bool { return c.reverse && !c.bold }) {181+ t.Errorf("a selected column's header: %+v", col(1))182+ }183+ if every(col(2), true, isReverse) {184+ t.Error("an unselected column's header is reversed")185+ }186+ if !strings.Contains(cellText(monoLine(m, formulaLine)), "A1:B2") {187+ t.Error("the name box doesn't name the selection")188+ }189+}190+191+func TestMonochromeError(t *testing.T) {192+ m := newModel()193+ m.sheet.Set(addr("B1"), "=1/0")194+ c := cellAt(m, addr("B1"))195+ if !strings.Contains(cellText(c), "#DIV/0!") || !every(c, true, func(c monoCell) bool { return c.underline == "3" }) {196+ t.Errorf("an error cell: %q %+v", cellText(c), c)197+ }198+}199+200+func TestMonochromeInvalid(t *testing.T) {201+ m := newModel()202+ mustValidate(t, m, `{"ranges":"B1:B9","criteria":"number","condition":"between","values":["1","10"]}`)203+ m.sheet.Set(addr("B1"), "42")204+ c := cellAt(m, addr("B1"))205+ if !every(c, true, func(c monoCell) bool { return c.underline == "4" }) {206+ t.Errorf("an invalid cell isn't dotted: %+v", c)207+ }208+ m.cur = addr("B1")209+ if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "Invalid:") {210+ t.Errorf("no warning in words: %q", l)211+ }212+}213+214+func TestMonochromeCircular(t *testing.T) {215+ m := newModel()216+ m.sheet.Set(addr("A1"), "=A1+1")217+ if l := cellText(monoLine(m, m.height-1)); !strings.Contains(l, "Circular reference") {218+ t.Errorf("status line: %q", l)219+ }220+}221+222+func TestMonochromeSpill(t *testing.T) {223+ m := newModel()224+ m.sheet.Set(addr("B1"), "=SEQUENCE(3)")225+ m.cur = addr("D1")226+ c := cellAt(m, addr("B2"))227+ if strings.TrimSpace(cellText(c)) != "2" || !every(c, true, func(c monoCell) bool { return c.italic }) {228+ t.Errorf("a spilled value isn't italic: %+v", c)229+ }230+ m.cur = addr("B2")231+ if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "Spilled from B1") {232+ t.Errorf("context line: %q", l)233+ }234+}235+236+func TestMonochromeNote(t *testing.T) {237+ m := newModel()238+ m.sheet.Set(addr("B1"), "x")239+ m.sheet.SetNote(addr("B1"), "Checked")240+ if c := cellAt(m, addr("B1")); c[len(c)-1].text != "▝" {241+ t.Errorf("no note mark: %q", cellText(c))242+ }243+}244+245+func TestMonochromeFoundTraced(t *testing.T) {246+ m := traceModel(t)247+ m.cur = addr("B1")248+ press(t, m, "<alt+,>")249+ if c := cellAt(m, addr("A2")); !every(c, false, isReverse) {250+ t.Errorf("a traced cell isn't reversed: %+v", c)251+ }252+ m = newModel()253+ m.sheet.Set(addr("B2"), "Rent")254+ press(t, m, "<ctrl+f>", "Rent")255+ if c := cellAt(m, addr("B2")); !every(c, false, isReverse) {256+ t.Errorf("a match isn't reversed: %+v", c)257+ }258+}259+260+func TestMonochromeCopied(t *testing.T) {261+ m := newModel()262+ m.sheet.Set(addr("A1"), "1")263+ press(t, m, "<ctrl+c>", "<right>")264+ c := cellAt(m, addr("A1"))265+ if !every(c, false, func(c monoCell) bool { return c.underline != "" }) || !every(c, true, func(c monoCell) bool { return c.underline == "5" }) {266+ t.Errorf("the copied range isn't underlined, dashed under its text: %+v", c)267+ }268+}269+270+// States told in words: the mode indicator, recording, protected ranges271+// and pivot tables.272+func TestMonochromeStatesInWords(t *testing.T) {273+ m := newModel()274+ if l := cellText(monoLine(m, menuLine)); !strings.Contains(l, "READY") {275+ t.Errorf("indicator: %q", l)276+ }277+ press(t, m, "1")278+ if l := cellText(monoLine(m, menuLine)); !strings.Contains(l, "ENTER") {279+ t.Errorf("indicator while typing: %q", l)280+ }281+ press(t, m, "<esc>")282+ run(m, m.runCommand("macro.record"))283+ if l := cellText(monoLine(m, menuLine)); !strings.Contains(l, "REC") {284+ t.Errorf("recording: %q", l)285+ }286+287+ m = newModel()288+ press(t, m, "<shift+down>")289+ m.runCommand("data.protect_range")290+ press(t, m, "<enter>", "<esc>", "7")291+ if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "is protected") {292+ t.Errorf("protected: %q", l)293+ }294+295+ m = wideModel()296+ salesTable(t, m)297+ m.runCommand("data.pivot")298+ press(t, m, "<space>", "reg", "<enter>", "<enter>", "<ctrl+home>", "x")299+ if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "Pivot table results can't be edited") {300+ t.Errorf("pivot: %q", l)301+ }302+}
M internal/ui/rowtext/rowtext.go +22 -18
@@ -53,8 +53,8 @@ func Layout(s *sheet.Sheet, row, lo, ncols, minCol, maxCol int) []Span {
5353 l.x[c-l.first+1] = l.x[c-l.first] + s.ColWidth(c)5454 }5555 for c := l.first; c <= l.last; c++ {56- if cell := l.content(c); cell != nil {57- l.place(c, cell)56+ if v, ok := l.content(c); ok {57+ l.place(c, v)5858 }5959 }6060 keepGaps(l.out)
@@ -73,14 +73,18 @@ type rowLayout struct {
7373 out []Span7474 }7575 76-// content is the cell in column c of the row, or nil if it's blank.77-func (l *rowLayout) content(c int) *sheet.Cell {78- if cell := l.s.Cell(sheet.Addr{Col: c, Row: l.row}); !cell.Blank() {79- return cell76+// content is the value in column c of the row, and false if the cell is77+// blank.78+func (l *rowLayout) content(c int) (sheet.Value, bool) {79+ if a := (sheet.Addr{Col: c, Row: l.row}); l.s.Filled(a) {80+ return l.s.Value(a), true8081 }81- return nil82+ return sheet.Value{}, false8283 }8384 85+// filled reports whether the cell in column c of the row has contents.86+func (l *rowLayout) filled(c int) bool { return l.s.Filled(sheet.Addr{Col: c, Row: l.row}) }87+8488 // col returns where column c starts and ends, relative to column first.8589 func (l *rowLayout) col(c int) (int, int) { return l.x[c-l.first], l.x[c-l.first+1] }8690
@@ -105,9 +109,9 @@ func (l *rowLayout) reach(minCol, maxCol int) (first, last int) {
105109 106110 // place lays out the text of the cell in column c over the columns it107111 // reaches.108-func (l *rowLayout) place(c int, cell *sheet.Cell) {112+func (l *rowLayout) place(c int, v sheet.Value) {109113 x0, x1 := l.col(c)110- text, align, pad := l.display(c, cell, x1-x0)114+ text, align, pad := l.display(c, v, x1-x0)111115 tw := ansi.StringWidth(text)112116 start := x0 + pad113117 switch align {
@@ -118,7 +122,7 @@ func (l *rowLayout) place(c int, cell *sheet.Cell) {
118122 case sheet.AlignCenter:119123 start = x0 + (x1-x0-tw)/2120124 }121- from, to := l.room(c, cell, start, tw)125+ from, to := l.room(c, v, start, tw)122126 l.claimed = to123127 cut := textCutter{s: text}124128 style := l.s.CellStyle(sheet.Addr{Col: c, Row: l.row})
@@ -137,15 +141,15 @@ func (l *rowLayout) place(c int, cell *sheet.Cell) {
137141 138142 // display formats the cell in column c for a column w wide, returning139143 // the text, its alignment and the padding on its aligned side.140-func (l *rowLayout) display(c int, cell *sheet.Cell, w int) (string, sheet.Align, int) {144+func (l *rowLayout) display(c int, v sheet.Value, w int) (string, sheet.Align, int) {141145 f := l.s.DisplayFormat(sheet.Addr{Col: c, Row: l.row})142- text, align := sheet.DisplayIn(cell.Value, f, w, l.loc)146+ text, align := sheet.DisplayIn(v, f, w, l.loc)143147 pad := 1144148 // A number one character too wide (12/31/2026 in a default column)145149 // may use the padding when nothing is to its right, rather than146150 // turning into #s.147- if cell.Value.Kind == sheet.Number && strings.Trim(text, "#") == "" && l.content(c+1) == nil {148- if wider, _ := sheet.DisplayIn(cell.Value, f, w+1, l.loc); strings.Trim(wider, "#") != "" {151+ if v.Kind == sheet.Number && strings.Trim(text, "#") == "" && !l.filled(c+1) {152+ if wider, _ := sheet.DisplayIn(v, f, w+1, l.loc); strings.Trim(wider, "#") != "" {149153 text, pad = wider, 0150154 }151155 }
@@ -158,16 +162,16 @@ func (l *rowLayout) display(c int, cell *sheet.Cell, w int) (string, sheet.Align
158162 // room returns where the text of the cell in column c, tw wide from159163 // start, may go: its own column and, for text, the blank neighbors it160164 // runs into, but not over text already placed to its left.161-func (l *rowLayout) room(c int, cell *sheet.Cell, start, tw int) (from, to int) {165+func (l *rowLayout) room(c int, v sheet.Value, start, tw int) (from, to int) {162166 x0, x1 := l.col(c)163167 from, to = max(x0, l.claimed), x1164- if cell.Value.Kind != sheet.Text {168+ if v.Kind != sheet.Text {165169 return from, to166170 }167- for k := c + 1; k <= l.last && start+tw > to && l.content(k) == nil; k++ {171+ for k := c + 1; k <= l.last && start+tw > to && !l.filled(k); k++ {168172 _, to = l.col(k)169173 }170- for k := c - 1; k >= l.first && start < from && l.content(k) == nil; k-- {174+ for k := c - 1; k >= l.first && start < from && !l.filled(k); k-- {171175 kx0, _ := l.col(k)172176 if kx0 < l.claimed {173177 return l.claimed, to
M internal/ui/rules.go +49 -1
@@ -2,6 +2,7 @@ package ui
22 33 import (44 "errors"5+ "fmt"56 "image/color"67 78 tea "charm.land/bubbletea/v2"
@@ -199,7 +200,7 @@ func (m *Model) toggleCheckboxes() tea.Cmd {
199200 continue200201 }201202 for _, vr := range v.Ranges {202- targets = appendCells(targets, intersect(vr, r), m.cur, func(a sheet.Addr) bool { return on || !s.Cell(a).Blank() })203+ targets = appendCells(targets, intersect(vr, r), m.cur, func(a sheet.Addr) bool { return on || s.Filled(a) })203204 }204205 }205206 }
@@ -258,6 +259,53 @@ func (m *Model) checkEntry(input string) (warn string, ok bool) {
258259 return bad.Error(), true259260 }260261 262+// checkWritten checks what a paste or fill (what) wrote into r against263+// the cells' validation, as Sheets does: when a rule that rejects fails,264+// the whole change is taken back and ERROR mode says why; when only265+// rules that warn fail, it stays, the cells are marked, and the context266+// line says how many. It reports whether the change stayed.267+func (m *Model) checkWritten(r sheet.Rect, what string) bool {268+ if !m.sheet.HasRules() {269+ return true270+ }271+ bad := m.sheet.InvalidIn(r)272+ for _, b := range bad {273+ if b.Reject {274+ m.sheet.Discard()275+ m.fail(what + " undone: " + b.Error())276+ return false277+ }278+ }279+ if len(bad) > 0 {280+ m.warn = invalidNote(bad)281+ }282+ return true283+}284+285+// checkEntryFill handles the cells an entry filled (Ctrl+Enter) that fail286+// their validation: a rule that rejects takes the fill back and keeps287+// the entry open with the rule's help, as for one cell; rules that warn288+// let it in, saying so. It reports whether the entry stays open.289+func (m *Model) checkEntryFill(bad []*sheet.InvalidEntry, input string) bool {290+ for _, b := range bad {291+ if b.Reject {292+ m.entrySheet().Discard()293+ m.entryError(b, input)294+ return true295+ }296+ }297+ if len(bad) > 0 {298+ m.warn = invalidNote(bad)299+ }300+ return false301+}302+303+// invalidNote says how many cells a paste or fill left invalid, and why304+// the first is.305+func invalidNote(bad []*sheet.InvalidEntry) string {306+ return fmt.Sprintf("%s invalid, first %s: %s", cellCount(len(bad)), bad[0].Addr, bad[0].Help)307+}308+261309 // The rules panel's host.262310 263311 func (h host) Sheet() *sheet.Sheet { return h.m.sheet }
M internal/ui/theme/contrast.go +1 -0
A internal/ui/theme/highcontrast.go +92 -0
@@ -0,0 +1,92 @@
1+package theme2+3+import "strings"4+5+// HighContrast is the theme name for the high-contrast schemes: the dark6+// one on a dark terminal, the light one on a light terminal, switching7+// when the terminal does. HighContrastDark and HighContrastLight name8+// one of them.9+const (10+ HighContrast = "high-contrast"11+ HighContrastDark = "High Contrast Dark"12+ HighContrastLight = "High Contrast Light"13+)14+15+// The high-contrast schemes: white on black and black on white, with16+// ANSI colors picked so the roles defined on them (black text on the17+// pointer's cyan, the found cells' yellow, the traced cells' green) meet18+// WCAG AAA before correction, and a selection in saturated blue with19+// white text. FromPalette then holds every role to AAA (aaa below).20+const highContrastDark = `# High Contrast Dark: WCAG AAA (7:1) text.21+background = #00000022+foreground = #ffffff23+selection-background = #0033cc24+palette = 0=#00000025+palette = 1=#ff808026+palette = 2=#40e04027+palette = 3=#ffe04028+palette = 4=#80a8ff29+palette = 5=#ff80ff30+palette = 6=#40e0ff31+palette = 7=#e0e0e032+palette = 8=#3a3a3a33+palette = 9=#ffa0a034+palette = 10=#80ff8035+palette = 11=#ffff8036+palette = 12=#a0c0ff37+palette = 13=#ffa0ff38+palette = 14=#a0f0ff39+palette = 15=#ffffff40+`41+42+const highContrastLight = `# High Contrast Light: WCAG AAA (7:1) text.43+background = #ffffff44+foreground = #00000045+selection-background = #0033cc46+palette = 0=#00000047+palette = 1=#a0000048+palette = 2=#b0f0b049+palette = 3=#ffe07050+palette = 4=#0033cc51+palette = 5=#80008052+palette = 6=#a0e8ff53+palette = 7=#d0d0d054+palette = 8=#6a6a6a55+palette = 9=#a0000056+palette = 10=#005a0057+palette = 11=#6a4a0058+palette = 12=#0033cc59+palette = 13=#80008060+palette = 14=#005a6a61+palette = 15=#ffffff62+`63+64+// highContrastPalettes are the two schemes, dark first.65+func highContrastPalettes() []Palette {66+ var out []Palette67+ for _, s := range []struct{ name, text string }{{HighContrastDark, highContrastDark}, {HighContrastLight, highContrastLight}} {68+ p, _, err := parseText(s.text)69+ if err != nil {70+ panic("theme: " + s.name + ": " + err.Error())71+ }72+ p.Name, p.HighContrast = s.name, true73+ out = append(out, p)74+ }75+ return out76+}77+78+// isHighContrast reports whether name is HighContrast, loosely matched79+// as Lookup matches names.80+func isHighContrast(name string) bool {81+ return squash(strings.TrimSpace(name)) == squash(HighContrast)82+}83+84+// highContrastFor is the high-contrast scheme for a dark or light85+// terminal.86+func highContrastFor(dark bool) Palette {87+ ps := highContrastPalettes()88+ if dark {89+ return ps[0]90+ }91+ return ps[1]92+}
A internal/ui/theme/monochrome.go +32 -0
M internal/ui/theme/palette.go +9 -6
@@ -27,6 +27,9 @@ type Palette struct {
2727 Selection color.Color // may be nil2828 Dark bool2929 File string // the file it came from; empty for built-ins30+ // HighContrast holds every role to WCAG AAA (7:1 for text) instead31+ // of AA: the built-in high-contrast schemes.32+ HighContrast bool3033 }3134 3235 // Terminal is the theme name that keeps the terminal's own palette.
@@ -74,7 +77,7 @@ var builtins = sync.OnceValue(func() []Palette {
7477 panic("theme: classic: " + err.Error())7578 }7679 c.Name = ClassicName77- return append([]Palette{c}, ps...)80+ return append(append([]Palette{c}, highContrastPalettes()...), ps...)7881 })7982 8083 // Builtins returns the built-in schemes.
@@ -87,12 +90,12 @@ type Entry struct {
8790 User bool // a file in the themes directory8891 }8992 90-// List returns the themes available: terminal first, then the files in91-// dir (the user's themes directory, may be ""), then the built-ins, each92-// group sorted by name case-insensitively. A file with a built-in's name93-// hides the built-in.93+// List returns the themes available: terminal and high-contrast first,94+// then the files in dir (the user's themes directory, may be ""), then95+// the built-ins, each group sorted by name case-insensitively. A file96+// with a built-in's name hides the built-in.9497 func List(dir string) []Entry {95- out := []Entry{{Name: Terminal, Dark: true}}98+ out := []Entry{{Name: Terminal, Dark: true}, {Name: HighContrast, Dark: true}}9699 seen := map[string]bool{}97100 var user []Entry98101 for _, p := range userPalettes(dir) {
M internal/ui/theme/scheme.go +42 -15
@@ -27,9 +27,12 @@ const barBlend = 0.12
2727 // background is moved toward the scheme's text color just far enough2828 // to meet it, or toward black or white.2929 func FromPalette(p Palette) Theme {30- t := New(p.Dark)30+ t := roles(p.Dark)3131 t.Name, t.Palette = p.Name, &p32- text := readable(p.Foreground, p.Background, nil, minText)32+ if p.HighContrast {33+ t.levels = aaa34+ }35+ text := readable(p.Foreground, p.Background, nil, t.levels.text)3336 t.Screen = lipgloss.NewStyle().Background(p.Background).Foreground(text)3437 eachRole(&t, func(_ string, s *lipgloss.Style) { *s = mapStyle(*s, &p) })3538 if p.Selection != nil && contrast(p.Selection, p.Background) >= minDistinct {
@@ -42,8 +45,9 @@ func FromPalette(p Palette) Theme {
4245 }4346 bar := lipgloss.NewStyle().Background(blend(p.Background, text, barBlend)).Foreground(text)4447 t.MenuBarRow, t.StatusBarRow = bar, bar45- eachRole(&t, func(name string, s *lipgloss.Style) { *s = fixContrast(name, *s, text, &p) })48+ eachRole(&t, func(name string, s *lipgloss.Style) { *s = fixContrast(name, *s, text, &p, t.levels) })4649 t.ColumnHeaderRow = lipgloss.NewStyle().Background(t.Header.GetBackground()).Foreground(t.Header.GetForeground())50+ t.standouts()4751 return t4852 }4953
@@ -92,21 +96,38 @@ func slot(c color.Color, p *Palette) (color.Color, bool) {
9296 return nil, false9397 }9498 99+// levels are the contrast minimums a theme's roles are held to.100+type levels struct {101+ text float64 // cell text, bars, menus, chips: every other role102+ secondary float64 // hints, muted text, marks and chart labels103+ lines float64 // borders, rules, chart axes and swatches, progress104+ disabled float64 // unavailable items105+}106+107+var (108+ // aa is WCAG AA, every scheme's floor.109+ aa = levels{text: minText, secondary: minSecondary, lines: minSecondary, disabled: minDisabled}110+ // aaa is WCAG AAA, the high-contrast schemes': 7:1 for all text,111+ // hints included, and 4.5:1 for lines and unavailable items.112+ aaa = levels{text: minTextAAA, secondary: minTextAAA, lines: minText, disabled: minText}113+)114+95115 // minContrast is the contrast minimum for a role's text.96-func minContrast(role string) float64 {116+func minContrast(role string, lv levels) float64 {97117 switch role {98118 case "Disabled":99- return minDisabled100- case "Hint", "Muted", "Border", "FrozenLine", "ChartFrame", "ChartAxis", "ChartLabel",101- "Progress", "ProgressTodo", "Copied", "Series", "Match", "Dropdown", "NoteMark":102- return minSecondary119+ return lv.disabled120+ case "Hint", "Muted", "ChartLabel", "Match", "Dropdown", "NoteMark":121+ return lv.secondary122+ case "Border", "FrozenLine", "ChartFrame", "ChartAxis", "Progress", "ProgressTodo", "Copied", "Series":123+ return lv.lines103124 }104- return minText125+ return lv.text105126 }106127 107128 // fixContrast keeps a role's background apart from the screen and its108129 // text readable on it.109-func fixContrast(role string, s lipgloss.Style, text color.Color, p *Palette) lipgloss.Style {130+func fixContrast(role string, s lipgloss.Style, text color.Color, p *Palette, lv levels) lipgloss.Style {110131 if role == "Screen" {111132 return s112133 }
@@ -119,11 +140,11 @@ func fixContrast(role string, s lipgloss.Style, text color.Color, p *Palette) li
119140 }120141 }121142 if c := s.GetForeground(); !isNoColor(c) && role != "SeriesBg" {122- s = s.Foreground(readable(c, bg, text, minContrast(role)))143+ s = s.Foreground(readable(c, bg, text, minContrast(role, lv)))123144 } else if !isNoColor(s.GetBackground()) && role != "SeriesBg" {124145 // Text on a background role with no color of its own is drawn in125146 // the screen's text color.126- s = s.Foreground(readable(text, bg, nil, minContrast(role)))147+ s = s.Foreground(readable(text, bg, nil, minContrast(role, lv)))127148 }128149 return s129150 }
@@ -133,13 +154,19 @@ func isNoColor(c color.Color) bool {
133154 return c == nil || none134155 }135156 136-// Resolve returns the theme called name: the terminal theme's variant137-// for a dark or light terminal, or a scheme found by Lookup in dir. An138-// unknown scheme gives the terminal theme and the error.157+// Resolve returns the theme called name: the terminal theme's or the158+// high-contrast scheme's variant for a dark or light terminal, or a159+// scheme found by Lookup in dir. An unknown scheme gives the terminal160+// theme and the error.139161 func Resolve(name string, dark bool, dir string) (Theme, error) {140162 if name == "" || name == Terminal {141163 return New(dark), nil142164 }165+ if isHighContrast(name) {166+ t := FromPalette(highContrastFor(dark))167+ t.Name = HighContrast168+ return t, nil169+ }143170 p, err := Lookup(name, dir)144171 if err != nil {145172 return New(dark), err
M internal/ui/theme/scheme_test.go +66 -33
@@ -48,43 +48,75 @@ func requireContrast(t *testing.T, theme, what string, fg, bg color.Color, min f
4848 4949 // Every built-in scheme, run through the role derivation, keeps text5050 // readable: on cells, on the bars, in the selection and the headers, and51-// every other role at its minimum.51+// every other role at its minimum. The high-contrast schemes are held to52+// WCAG AAA.5253 func TestEveryThemeReadable(t *testing.T) {5354 for _, p := range Builtins() {54- th := FromPalette(p)55- screenBg, text := th.Screen.GetBackground(), th.Screen.GetForeground()56- requireContrast(t, p.Name, "cell text", text, screenBg, minText)57- for _, bar := range []struct {58- name string59- s lipgloss.Style60- }{{"menu bar", th.MenuBarRow}, {"status bar", th.StatusBarRow}, {"column header row", th.ColumnHeaderRow}} {61- requireContrast(t, p.Name, bar.name+" text", bar.s.GetForeground(), bar.s.GetBackground(), minText)62- if c := contrast(bar.s.GetBackground(), screenBg); c < minDistinct-0.01 {63- t.Errorf("%s: %s doesn't stand out from the screen (%.2f)", p.Name, bar.name, c)64- }55+ requireReadable(t, p.Name, FromPalette(p))56+ }57+}58+59+// requireReadable checks a scheme's roles against its minimums.60+func requireReadable(t *testing.T, name string, th Theme) {61+ t.Helper()62+ lv := th.levels63+ screenBg, text := th.Screen.GetBackground(), th.Screen.GetForeground()64+ requireContrast(t, name, "cell text", text, screenBg, lv.text)65+ for _, bar := range []struct {66+ name string67+ s lipgloss.Style68+ }{{"menu bar", th.MenuBarRow}, {"status bar", th.StatusBarRow}, {"column header row", th.ColumnHeaderRow}} {69+ requireContrast(t, name, bar.name+" text", bar.s.GetForeground(), bar.s.GetBackground(), lv.text)70+ if c := contrast(bar.s.GetBackground(), screenBg); c < minDistinct-0.01 {71+ t.Errorf("%s: %s doesn't stand out from the screen (%.2f)", name, bar.name, c)6572 }66- for _, r := range []struct {67- name string68- s lipgloss.Style69- }{70- {"selection", th.Selection}, {"header", th.Header}, {"row header", th.RowHeader},71- {"active header", th.HeaderActive}, {"selected header", th.HeaderSel}, {"pointer", th.Pointer},72- {"key chip", th.KeyChip}, {"menu selection", th.MenuSelected}, {"indicator", th.Indicator},73- } {74- requireContrast(t, p.Name, r.name, r.s.GetForeground(), r.s.GetBackground(), minText)73+ }74+ for _, r := range []struct {75+ name string76+ s lipgloss.Style77+ }{78+ {"selection", th.Selection}, {"header", th.Header}, {"row header", th.RowHeader},79+ {"active header", th.HeaderActive}, {"selected header", th.HeaderSel}, {"pointer", th.Pointer},80+ {"key chip", th.KeyChip}, {"menu selection", th.MenuSelected}, {"indicator", th.Indicator},81+ } {82+ requireContrast(t, name, r.name, r.s.GetForeground(), r.s.GetBackground(), lv.text)83+ }84+ // Muted text and hints on the bars, where the status line shows them.85+ requireContrast(t, name, "muted text", th.Muted.GetForeground(), screenBg, lv.secondary)86+ eachRole(&th, func(role string, s *lipgloss.Style) {87+ fg, bg := s.GetForeground(), s.GetBackground()88+ if role == "SeriesBg" || isNoColor(fg) {89+ return7590 }76- // Muted text and hints on the bars, where the status line shows them.77- requireContrast(t, p.Name, "muted text", th.Muted.GetForeground(), screenBg, minSecondary)78- eachRole(&th, func(role string, s *lipgloss.Style) {79- fg, bg := s.GetForeground(), s.GetBackground()80- if role == "SeriesBg" || isNoColor(fg) {81- return82- }83- if isNoColor(bg) {84- bg = screenBg91+ if isNoColor(bg) {92+ bg = screenBg93+ }94+ requireContrast(t, name, role, fg, bg, minContrast(role, lv))95+ })96+}97+98+// The high-contrast theme follows the terminal's background, and its99+// schemes are AAA: 7:1 for every text role, hints included.100+func TestHighContrast(t *testing.T) {101+ for _, dark := range []bool{true, false} {102+ th, err := Resolve("high-contrast", dark, "")103+ if err != nil || th.Name != HighContrast || th.Palette == nil || th.Palette.Dark != dark || th.levels != aaa {104+ t.Fatalf("dark %v: %q %v", dark, th.Name, err)105+ }106+ requireReadable(t, th.Palette.Name, th)107+ for _, role := range []string{"Hint", "Muted", "Warning", "Error", "ErrorCell", "Link", "Spilled", "NoteMark"} {108+ if minContrast(role, th.levels) < 7 {109+ t.Errorf("%s held to %.1f", role, minContrast(role, th.levels))85110 }86- requireContrast(t, p.Name, role, fg, bg, minContrast(role))87- })111+ }112+ }113+ for _, name := range []string{"High Contrast Dark", "high-contrast-light", "HIGH-CONTRAST"} {114+ if _, err := Resolve(name, true, ""); err != nil {115+ t.Error(err)116+ }117+ }118+ if l := List(""); l[1].Name != HighContrast {119+ t.Errorf("list: %v", l[:3])88120 }89121 }90122
@@ -103,7 +135,8 @@ func TestSchemeUsesPaletteColors(t *testing.T) {
103135 }104136 }105137 })106- if bg := th.Selection.GetBackground(); fmt.Sprint(bg) != fmt.Sprint(p.Selection) {138+ // The selection is in reverse video: its background is its foreground.139+ if bg := th.Selection.GetForeground(); fmt.Sprint(bg) != fmt.Sprint(p.Selection) || !th.Selection.GetReverse() {107140 t.Errorf("selection %v, scheme's %v", bg, p.Selection)108141 }109142 // The terminal theme keeps ANSI colors and no bands.
A internal/ui/theme/standout_test.go +39 -0
M internal/ui/theme/theme.go +39 -1
@@ -131,6 +131,10 @@ type Theme struct {
131131 Series [chart.Colors]lipgloss.Style132132 SeriesBg [chart.Colors]lipgloss.Style133133 SeriesANSI [chart.Colors]int134+135+ // levels are the contrast minimums the roles meet: WCAG AA, or AAA136+ // for a high-contrast scheme.137+ levels levels134138 }135139 136140 // ImageID is the style of an image's Unicode placeholders: the
@@ -161,6 +165,39 @@ func (t *Theme) Text(base lipgloss.Style, st sheet.Style) lipgloss.Style {
161165 162166 // New returns the terminal theme's dark or light variant.163167 func New(dark bool) Theme {168+ t := roles(dark)169+ t.standouts()170+ return t171+}172+173+// standouts draws the roles that mark where the user is (the pointer,174+// the selection, their headers, search matches, traced cells and the175+// highlighted menu item) in reverse video: the colors swapped and SGR 7176+// on, so they look the same in color and still stand out on a terminal177+// without it. Call it last: the roles' colors read reversed afterwards.178+func (t *Theme) standouts() {179+ for _, s := range []*lipgloss.Style{&t.Pointer, &t.Selection, &t.HeaderActive, &t.HeaderSel,180+ &t.Found, &t.Traced, &t.MenuSelected, &t.MenuAccelSelected} {181+ fg, bg := s.GetForeground(), s.GetBackground()182+ *s = s.Foreground(bg).Background(fg).Reverse(true)183+ }184+}185+186+// Drawable returns s as it can be drawn: a standout role with an187+// underline or strikethrough layered on it is drawn in its own colors188+// without reverse video, because lipgloss draws the spaces of underlined189+// or struck-out text without it, which would show their colors swapped.190+// The underline or strikethrough is then that text's cue without color.191+func Drawable(s lipgloss.Style) lipgloss.Style {192+ if !s.GetReverse() || !s.GetUnderline() && !s.GetStrikethrough() {193+ return s194+ }195+ fg, bg := s.GetForeground(), s.GetBackground()196+ return s.Foreground(bg).Background(fg).Reverse(false)197+}198+199+// roles are the terminal theme's roles, before standouts.200+func roles(dark bool) Theme {164201 // Contrast was checked against the reference palettes in e2e: text on165202 // colored backgrounds stays at or above roughly 4.5:1.166203 headerBg, headerFg := lipgloss.BrightBlack, lipgloss.BrightWhite
@@ -205,7 +242,7 @@ func New(dark bool) Theme {
205242 ErrorCell: lipgloss.NewStyle().Foreground(lipgloss.Red),206243 ErrorMark: lipgloss.NewStyle().UnderlineStyle(lipgloss.UnderlineCurly).UnderlineColor(lipgloss.Red),207244 Link: lipgloss.NewStyle().Foreground(link).Underline(true),208- Spilled: lipgloss.NewStyle().Foreground(bar),245+ Spilled: lipgloss.NewStyle().Foreground(bar).Italic(true),209246 Found: lipgloss.NewStyle().Background(lipgloss.Yellow).Foreground(lipgloss.Black),210247 Traced: lipgloss.NewStyle().Background(lipgloss.Green).Foreground(lipgloss.Black),211248 Argument: lipgloss.NewStyle().Bold(true).Underline(true),
@@ -243,6 +280,7 @@ func New(dark bool) Theme {
243280 Dropdown: lipgloss.NewStyle().Foreground(muted),244281 scales: map[scaleKey]Shade{},245282 shades: map[sheet.RuleStyle]Shade{},283+ levels: aa,246284 }247285 ruleRoles(&t, dark)248286 for i, c := range series {
M internal/ui/themepicker/themepicker.go +2 -0
A internal/ui/validpaste_test.go +88 -0
@@ -0,0 +1,88 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// Pastes and fills meet the validation of the cells they write, as in13+// Sheets: a rule that rejects takes the whole change back, one that warns14+// keeps it and says so.15+func TestValidationOnPastesAndFills(t *testing.T) {16+ setup := func(t *testing.T) *Model {17+ m := newModel()18+ mustValidate(t, m, `{"ranges":"B1:B9","criteria":"number","condition":"between","values":["1","10"],"reject":true}`)19+ mustValidate(t, m, `{"ranges":"C1:C9","criteria":"number","condition":"between","values":["1","10"]}`)20+ m.sheet.Set(addr("A1"), "5")21+ m.sheet.Set(addr("A2"), "50")22+ return m23+ }24+25+ t.Run("paste rejected", func(t *testing.T) {26+ m := setup(t)27+ press(t, m, "<shift+down>", "<ctrl+c>", "<right>", "<ctrl+v>")28+ if m.mode != modeError || input(m, "B1") != "" || input(m, "B2") != "" || m.sheet.CanRedo() {29+ t.Fatalf("mode %v B1 %q B2 %q", m.mode, input(m, "B1"), input(m, "B2"))30+ }31+ if m.errMsg != "Paste undone: Invalid entry in B2: Input must be a number between 1 and 10" {32+ t.Errorf("reason %q", m.errMsg)33+ }34+ })35+36+ t.Run("paste warned", func(t *testing.T) {37+ m := setup(t)38+ press(t, m, "<shift+down>", "<ctrl+c>", "<right>", "<right>", "<ctrl+v>")39+ if input(m, "C2") != "50" || !m.sheet.Look(addr("C2")).Invalid {40+ t.Fatalf("C2 %q", input(m, "C2"))41+ }42+ if l := line(m, contextLine); !strings.Contains(l, "1 cell invalid, first C2: Input must be a number between 1 and 10") {43+ t.Errorf("context line %q", l)44+ }45+ })46+47+ t.Run("text paste rejected", func(t *testing.T) {48+ m := setup(t)49+ m.cur = addr("B1")50+ send(m, tea.PasteMsg{Content: "3\n30"})51+ if m.mode != modeError || input(m, "B1") != "" {52+ t.Errorf("mode %v B1 %q", m.mode, input(m, "B1"))53+ }54+ })55+56+ t.Run("fill down rejected", func(t *testing.T) {57+ m := setup(t)58+ m.sheet.Set(addr("B1"), "7")59+ m.sheet.Set(addr("B1"), "=A1*2") // 10, valid; filled down it's 10060+ press(t, m, "<right>", "<shift+down>", "<ctrl+d>")61+ if m.mode != modeError || input(m, "B2") != "" {62+ t.Errorf("mode %v B2 %q", m.mode, input(m, "B2"))63+ }64+ })65+66+ t.Run("ctrl+enter rejected keeps the entry", func(t *testing.T) {67+ m := setup(t)68+ press(t, m, "<right>", "<shift+down>", "=A1*2", "<ctrl+enter>")69+ if m.mode != modeEdit || input(m, "B1") != "" || !strings.Contains(line(m, contextLine), "Invalid entry in B2") {70+ t.Errorf("mode %v B1 %q %q", m.mode, input(m, "B1"), line(m, contextLine))71+ }72+ })73+}74+75+// Cut and paste takes the cells' rules along.76+func TestRulesMoveWithCutAndPaste(t *testing.T) {77+ m := newModel()78+ mustValidate(t, m, `{"ranges":"A1:A2","criteria":"list","items":["Yes","No"]}`)79+ m.sheet.Set(addr("A1"), "Yes")80+ press(t, m, "<shift+down>", "<ctrl+x>", "<right>", "<right>", "<ctrl+v>")81+ vs := m.sheet.Validations()82+ if len(vs) != 1 || sheet.RangesText(vs[0].Ranges) != "C1:C2" || !m.sheet.Look(addr("C1")).Dropdown {83+ t.Fatalf("validations %+v", vs)84+ }85+ if m.mode == modeError {86+ t.Errorf("a move was refused: %s", screen(m))87+ }88+}
M internal/ui/view.go +1 -1
M internal/ui/vim.go +1 -1
A scripts/dist.sh +63 -0
@@ -0,0 +1,63 @@
1+#!/usr/bin/env bash2+# dist.sh - what `make dist VERSION=v1.2.3` does: cross-compile 012 as3+# pure Go for each platform into dist/, stamped with the version, one4+# archive per platform (tar.gz, zip for Windows) and a SHA256SUMS file.5+# Nothing is uploaded. See docs/contributing/releasing.md.6+#7+# VERSION=v1.2.3 required, a semantic version tag8+# PLATFORMS="darwin/arm64 linux/amd64" narrow the list9+set -euo pipefail10+11+root=$(cd "$(dirname "$0")/.." && pwd)12+cd "$root"13+14+version=${VERSION:-}15+if ! [[ $version =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then16+ echo "dist: VERSION must be a version like v1.2.3 (got '${version}')" >&217+ exit 118+fi19+if [ -n "$(git status --porcelain --untracked-files=no 2>/dev/null)" ]; then20+ echo "dist: warning: the working tree has uncommitted changes; the binaries include them" >&221+fi22+if tag=$(git rev-parse -q --verify "refs/tags/$version^{commit}" 2>/dev/null) && [ "$tag" != "$(git rev-parse HEAD)" ]; then23+ echo "dist: $version is tagged at another commit; check out the tag first" >&224+ exit 125+fi26+platforms=${PLATFORMS:-"darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64"}27+28+out="$root/dist"29+rm -rf "$out"30+mkdir -p "$out"31+32+for p in $platforms; do33+ goos=${p%/*}34+ goarch=${p#*/}35+ name="012_${version#v}_${goos}_${goarch}"36+ dir="$out/$name"37+ exe=01238+ if [ "$goos" = windows ]; then39+ exe=012.exe40+ fi41+ mkdir -p "$dir"42+ echo "dist: $name"43+ CGO_ENABLED=0 GOOS=$goos GOARCH=$goarch go build -trimpath \44+ -ldflags "-s -w -X main.version=$version" -o "$dir/$exe" ./cmd/01245+ cp LICENSE NOTICE README.md "$dir/"46+ if [ "$goos" = windows ]; then47+ (cd "$out" && zip -qrX "$name.zip" "$name")48+ else49+ tar -C "$out" -czf "$out/$name.tar.gz" "$name"50+ fi51+ rm -rf "$dir"52+done53+54+(55+ cd "$out"56+ if command -v sha256sum >/dev/null 2>&1; then57+ sha256sum -- 012_* >SHA256SUMS58+ else59+ shasum -a 256 -- 012_* >SHA256SUMS60+ fi61+)62+echo "dist: $(find "$out" -type f | wc -l | tr -d ' ') files in dist/"63+cat "$out/SHA256SUMS"
M scripts/doccheck/tree.go +29 -16
@@ -154,28 +154,41 @@ func checkFrontMatter(docs map[string]*doc) []string {
154154 155155 var goDocPath = regexp.MustCompile(`\bdocs/[a-z0-9_/-]+\.md\b`)156156 157-// checkGoPaths reports doc paths named in Go code, in messages and158-// comments, that don't exist.157+// checkGoPaths reports doc paths named outside the docs, in Go code158+// (messages and comments), the Makefile, scripts and deploy configs,159+// that don't exist.159160 func checkGoPaths() []string {160161 var out []string161- for _, root := range []string{"cmd", "internal", "e2e", "oracle", "demos", "scripts"} {162+ for _, f := range []string{"Makefile", "CLAUDE.md"} {163+ out = append(out, missingDocPaths(f)...)164+ }165+ for _, root := range []string{"cmd", "internal", "e2e", "oracle", "demos", "scripts", "deploy"} {162166 filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {163- if err != nil || d.IsDir() || filepath.Ext(p) != ".go" {164- return nil165- }166- data, err := os.ReadFile(p)167- if err != nil {168- return nil169- }170- for i, l := range strings.Split(string(data), "\n") {171- for _, m := range goDocPath.FindAllString(l, -1) {172- if _, err := os.Stat(filepath.FromSlash(m)); err != nil {173- out = append(out, fmt.Sprintf("%s:%d: %s: no such doc", p, i+1, m))174- }175- }167+ if err == nil && !d.IsDir() && pathBearing[filepath.Ext(p)] {168+ out = append(out, missingDocPaths(p)...)176169 }177170 return nil178171 })179172 }180173 return out181174 }175+176+// pathBearing are the kinds of file outside docs/ that name doc pages.177+var pathBearing = map[string]bool{".go": true, ".sh": true, ".yml": true, ".yaml": true, ".tape": true, ".sql": true, ".json": true}178+179+// missingDocPaths reports the doc paths file p names that don't exist.180+func missingDocPaths(p string) []string {181+ data, err := os.ReadFile(p)182+ if err != nil {183+ return nil184+ }185+ var out []string186+ for i, l := range strings.Split(string(data), "\n") {187+ for _, m := range goDocPath.FindAllString(l, -1) {188+ if _, err := os.Stat(filepath.FromSlash(m)); err != nil {189+ out = append(out, fmt.Sprintf("%s:%d: %s: no such doc", p, i+1, m))190+ }191+ }192+ }193+ return out194+}
M scripts/stress/load.sql +11 -1
A scripts/stress/release.sql +71 -0
@@ -0,0 +1,71 @@
1+-- The latest run against the last release's recorded runs, with a2+-- threshold that allows for noise. Run by scripts/stress/report.sh after3+-- load.sql, with variables threshold, min_delta_ns and release_tags, a4+-- list of 'tag commit' strings, newest release first (from git), or a5+-- single run id in release_run.6+--7+-- The release's runs are every clean run of the newest tagged commit that8+-- has one on the latest run's CPU and OS, other than the latest run9+-- itself; its samples are pooled. A benchmark regresses when its median10+-- is slower than the release's by more than threshold + noise and by more11+-- than min_delta_ns, where noise is the larger spread,12+-- (max - min) / median, of its samples in either side.13+14+CREATE OR REPLACE TEMP TABLE release_tags AS15+SELECT split_part(t, ' ', 1) AS tag, split_part(t, ' ', 2) AS commit, i AS ord16+FROM (SELECT unnest(getvariable('release_tags')) AS t, generate_subscripts(getvariable('release_tags'), 1) AS i)17+WHERE t <> '';18+19+CREATE OR REPLACE TEMP TABLE release_pick AS20+WITH latest AS (SELECT * FROM runs WHERE age = 1),21+cand AS (22+ SELECT t.tag, t.ord, r.run_id23+ FROM release_tags t24+ JOIN runs r ON starts_with(t.commit, r.git_sha) AND length(r.git_sha) >= 725+ JOIN latest l ON r.cpu = l.cpu AND r.goos = l.goos26+ WHERE NOT r.dirty AND r.run_id <> l.run_id AND r.git_sha <> l.git_sha27+),28+tag AS (29+ SELECT tag FROM cand ORDER BY ord LIMIT 130+)31+SELECT run_id, tag FROM cand WHERE getvariable('release_run') = '' AND tag = (SELECT tag FROM tag)32+UNION ALL33+SELECT run_id, run_id AS tag FROM runs WHERE run_id = getvariable('release_run');34+35+CREATE OR REPLACE TEMP TABLE release_compared AS36+WITH l AS (SELECT r.* FROM bench r JOIN runs u USING (run_id) WHERE u.age = 1),37+rel AS (38+ SELECT r.pkg, r.name, count(*) AS samples, median(r.ns_op) AS ns_op,39+ CASE WHEN median(r.ns_op) > 0 THEN (max(r.ns_op) - min(r.ns_op)) / median(r.ns_op) ELSE 0 END AS spread40+ FROM results r JOIN release_pick p USING (run_id)41+ GROUP BY r.pkg, r.name42+)43+SELECT l.pkg, l.name, rel.ns_op AS before_ns, l.ns_op AS after_ns,44+ (l.ns_op - rel.ns_op) / rel.ns_op AS change,45+ greatest(l.spread, rel.spread) AS noise,46+ rel.samples AS release_samples, l.samples AS latest_samples,47+ CASE48+ WHEN (l.ns_op - rel.ns_op) / rel.ns_op > getvariable('threshold') + greatest(l.spread, rel.spread)49+ AND l.ns_op - rel.ns_op > getvariable('min_delta_ns') THEN 'REGRESSION'50+ WHEN (rel.ns_op - l.ns_op) / rel.ns_op > getvariable('threshold') + greatest(l.spread, rel.spread)51+ AND rel.ns_op - l.ns_op > getvariable('min_delta_ns') THEN 'faster'52+ ELSE ''53+ END AS flag54+FROM l JOIN rel USING (pkg, name);55+56+.print57+.print # Latest against the last release (threshold plus each benchmark's noise)58+SELECT (SELECT any_value(tag) FROM release_pick) AS release,59+ (SELECT count(*) FROM release_pick) AS release_runs,60+ count(*) AS compared,61+ count(*) FILTER (WHERE flag = 'REGRESSION') AS regressions,62+ count(*) FILTER (WHERE flag = 'faster') AS faster,63+ printf('%.0f%%', getvariable('threshold') * 100) AS threshold64+FROM release_compared;65+66+SELECT pkg, name, fmt_ns(before_ns) AS release, fmt_ns(after_ns) AS latest,67+ printf('%+.1f%%', change * 100) AS change,68+ printf('%.1f%%', noise * 100) AS noise,69+ release_samples || '/' || latest_samples AS samples, flag70+FROM release_compared WHERE flag <> ''71+ORDER BY flag DESC, change DESC;
M scripts/stress/report.sh +49 -10
@@ -1,11 +1,17 @@
11 #!/usr/bin/env bash2-# report.sh - compare the latest stress run with the previous one and a3-# baseline, using DuckDB over the JSONL run records. No server needed.2+# report.sh - compare the latest stress run with the previous one, a3+# baseline and the last tagged release, using DuckDB over the JSONL run4+# records. No server needed. Exits 1 when the latest run regressed5+# against the last release's recorded run.46 #5-# THRESHOLD=0.10 flag changes over 10%7+# THRESHOLD=0.10 flag changes over 10% (plus the benchmark's noise8+# against the release)69 # MIN_DELTA_NS=2000 ignore changes under 2 us (noise)710 # BASELINE=run_id compare with this run (default: the oldest run)8-# FAIL_ON_REGRESSION=1 exit 1 when the latest run regressed against the previous11+# RELEASE=v1.2.3 compare with this tag's runs (default: the newest12+# tag reachable from HEAD that has a recorded run)13+# RELEASE_RUN=run_id compare with this run instead of a release's14+# FAIL_ON_REGRESSION=1 also exit 1 when the latest run regressed against the previous915 set -euo pipefail1016 1117 root=$(cd "$(dirname "$0")/../.." && pwd)
@@ -19,20 +25,53 @@ if ! command -v duckdb >/dev/null 2>&1; then
1925 exit 12026 fi2127 28+# Release tags, newest version first, as 'tag commit' pairs.29+if [ -n "${RELEASE:-}" ]; then30+ tags=$(git -C "$root" rev-parse -q --verify "refs/tags/$RELEASE^{commit}" | sed "s/^/$RELEASE /") || {31+ echo "stress-report: no tag $RELEASE" >&232+ exit 133+ }34+else35+ tags=$(git -C "$root" tag -l 'v*' --merged HEAD --sort=-v:refname \36+ --format='%(refname:short) %(if)%(*objectname)%(then)%(*objectname)%(else)%(objectname)%(end)' 2>/dev/null || true)37+fi38+list=$(printf '%s\n' "$tags" | awk 'NF == 2 { printf "%s'\''%s'\''", (n++ ? ", " : ""), $0 }')39+2240 vars="SET VARIABLE results = '$results';2341 SET VARIABLE threshold = ${THRESHOLD:-0.10};2442 SET VARIABLE min_delta_ns = ${MIN_DELTA_NS:-2000};25-SET VARIABLE baseline = '${BASELINE:-}';"43+SET VARIABLE baseline = '${BASELINE:-}';44+SET VARIABLE release_run = '${RELEASE_RUN:-}';45+SET VARIABLE release_tags = [$list]::VARCHAR[];"2646 27-cat <(echo "$vars") "$root/scripts/stress/load.sql" "$root/scripts/stress/report.sql" | duckdb47+sql() {48+ cat <(echo "$vars") "$root/scripts/stress/load.sql" "$@"49+}2850 29-if [ "${FAIL_ON_REGRESSION:-}" = 1 ]; then30- n=$(cat <(echo "$vars") "$root/scripts/stress/load.sql" "$root/scripts/stress/report.sql" <(echo "51+sql "$root/scripts/stress/report.sql" "$root/scripts/stress/release.sql" | duckdb52+53+count() {54+ sql "$root/scripts/stress/report.sql" "$root/scripts/stress/release.sql" <(echo "3155 .mode list3256 .header off33-SELECT count(*) FROM compared WHERE kind = 'previous' AND flag = 'REGRESSION';") | duckdb | tail -1)57+$1") | duckdb | tail -158+}59+60+status=061+if [ "$(count 'SELECT count(*) FROM release_pick;')" = 0 ]; then62+ echo "stress-report: no recorded run of a release to compare with; record one with make stress on a tagged commit" >&263+else64+ n=$(count "SELECT count(*) FROM release_compared WHERE flag = 'REGRESSION';")65+ if [ "$n" != 0 ]; then66+ echo "stress-report: $n regressions against the last release" >&267+ status=168+ fi69+fi70+if [ "${FAIL_ON_REGRESSION:-}" = 1 ]; then71+ n=$(count "SELECT count(*) FROM compared WHERE kind = 'previous' AND flag = 'REGRESSION';")3472 if [ "$n" != 0 ]; then3573 echo "stress-report: $n regressions against the previous run" >&236- exit 174+ status=13775 fi3876 fi77+exit $status
M scripts/stress/report.sql +4 -4
@@ -26,7 +26,7 @@ SELECT
2626 (SELECT run_id FROM same WHERE benchmarks >= 0.9 * (SELECT benchmarks FROM latest) ORDER BY age DESC LIMIT 1)) AS baseline;2727 2828 CREATE OR REPLACE TEMP TABLE compared AS29-WITH l AS (SELECT r.* FROM results r, picked p WHERE r.run_id = p.latest)29+WITH l AS (SELECT r.* FROM bench r, picked p WHERE r.run_id = p.latest)3030 SELECT l.pkg, l.name, kind, o.ns_op AS before_ns, l.ns_op AS after_ns,3131 (l.ns_op - o.ns_op) / o.ns_op AS change,3232 l.allocs_op - o.allocs_op AS allocs_change,
@@ -39,9 +39,9 @@ SELECT l.pkg, l.name, kind, o.ns_op AS before_ns, l.ns_op AS after_ns,
3939 END AS flag4040 FROM l4141 JOIN (42- SELECT r.*, 'previous' AS kind FROM results r, picked p WHERE r.run_id = p.previous42+ SELECT r.*, 'previous' AS kind FROM bench r, picked p WHERE r.run_id = p.previous4343 UNION ALL44- SELECT r.*, 'baseline' AS kind FROM results r, picked p WHERE r.run_id = p.baseline AND p.baseline <> p.latest44+ SELECT r.*, 'baseline' AS kind FROM bench r, picked p WHERE r.run_id = p.baseline AND p.baseline <> p.latest4545 ) o USING (pkg, name);4646 4747 .print
@@ -62,7 +62,7 @@ ORDER BY flag DESC, change DESC;
6262 .print # Headline benchmarks over the last 8 runs (time per op)6363 CREATE OR REPLACE TEMP TABLE headline AS6464 SELECT r.name, r.run_id, u.age, fmt_ns(r.ns_op) AS t65-FROM results r JOIN runs u USING (run_id)65+FROM bench r JOIN runs u USING (run_id)6666 WHERE u.age <= 8 AND regexp_matches(r.name,6767 '^(Edit/(chain|fanin|running|volatile)|RecalcAll/(dense|fanin)|Frame/(dense-8192x256|longtext-8192x500)/200x60|Keystroke/(arrow/dense-8192x256|extend-data/dense-8192x256)/80x24|Import/(owid|airport)|JEV/1000|Open/dense-8192x256|Save/dense-8192x256)');6868 PIVOT headline ON age USING first(t) GROUP BY name ORDER BY name;
M scripts/stress/run.sh +3 -1