cat / 012
clone

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

Merge branch 'worktree-agent-a5d84df4c78b3591d'

Dami committed 63b05ef
commit
63b05efb0c62cd1dab9126c0835457804415a19e
parent
2d53d2dee28ed51bb050118f152869e68c74d671
parent
0f0a99af8608bcd372e0f9b3a1196403e98a28b4
author
Dami <dami.etoile@gmail.com>
52 files changed, +1731 -120
M.gitignore1+
MMakefile12+-
MROADMAP.md17+-
Mcmd/012/configcmd.go2+
Mcmd/012/main.go6+-
Mcmd/012/main_test.go12+
Acmd/012/version.go43++++
Mdeploy/observability/grafana/dashboards/runtime.json122++++++++++++-
Mdocs/contributing/README.md1+
Mdocs/contributing/limits.md2+-
Mdocs/contributing/observability.md56+++++-
Adocs/contributing/releasing.md62++++++
Mdocs/contributing/ux.md32+++-
Mdocs/reference/config.md2+-
Mdocs/sheets/rules.md19++-
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.go3+-
Minternal/sheet/move.go1+
Ainternal/sheet/rulemove.go117++++++++++++
Ainternal/sheet/rulemove_test.go75+++++++
Ainternal/sheet/validcheck.go53+++++
Minternal/ui/clipboard.go8+
Minternal/ui/edit.go8+-
Minternal/ui/fill.go3+
Minternal/ui/links.go2+-
Ainternal/ui/monochrome_test.go302++++++++++++++++++++++++++++++
Minternal/ui/rules.go48+++++
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+-
Ascripts/dist.sh63++++++
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 +10 -2
M ROADMAP.md +6 -11
@@ -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
@@ -51,15 +51,6 @@ rises in measured steps; see [Bounds of support](docs/contributing/limits.md#wha
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
@@ -100,7 +91,7 @@ by a gateway dialing the iroh ticket.
10091 10192 - Freeze, multi-column sort, filters with value pickers and conditions: [Freeze, sort and filter](docs/sheets/sort-filter.md#freeze)10293 - 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)94+- 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)10495 - Pivot tables and frequency tables, live, with subtotals and renamed values: [Pivot tables](docs/sheets/pivots.md)10596 - Notes on cells: [Notes and protection](docs/sheets/notes-protection.md#notes)10697 - Protected sheets and ranges that warn on edit: [Notes and protection](docs/sheets/notes-protection.md#protected-sheets-and-ranges)
@@ -112,6 +103,7 @@ by a gateway dialing the iroh ticket.
112103 113104 - System clipboard, hyperlinks, curly error underlines, cursor shapes, progress and notifications, light and dark: [The terminal](docs/terminal/README.md#terminal-features-012-uses)114105 - Color schemes and a config file: [Themes](docs/terminal/themes.md), [Configuration](docs/reference/config.md)106+- 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)115107 - `012 serve` over SSH, with files on the ssh command line and recovery of unsaved work: [Serving over SSH](docs/terminal/ssh.md)116108 117109 **Files**
@@ -121,3 +113,6 @@ by a gateway dialing the iroh ticket.
121113 **Upkeep**122114 123115 - `make check` before every push; demo tapes (`make demos`); annotated version tags with release notes (v0.2.0 onward; v0.1.0 remains on the Go module proxy): [Testing](docs/contributing/testing.md)116+- A release checklist and `make dist`, release archives cross-compiled locally with SHA256SUMS: [Releasing](docs/contributing/releasing.md)117+- `make stress-report` flags regressions against the last release's run, allowing for noise: [Observability](docs/contributing/observability.md#regressions-against-the-last-release)118+- 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/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/limits.md +1 -1
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/reference/config.md +1 -1
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/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 +2 -1
M internal/sheet/move.go +1 -0
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+}
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/ui/clipboard.go +8 -0
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/rules.go +48 -0
@@ -2,6 +2,7 @@ package ui
22 33 import (44 "errors"5+ "fmt"56 "image/color"67 78 tea "charm.land/bubbletea/v2"
@@ -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
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/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