cat / 012
clone

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

Merge branch 'main' into worktree-agent-a5d13858d7c20f6f0

Dami committed 3aef18c
# Conflicts:
#	e2e/testdata/screens/palette-narrow.html
#	e2e/testdata/screens/palette-search-light.html
#	e2e/testdata/screens/palette-search.html
#	e2e/testdata/screens/palette-wide.html
#	e2e/testdata/screens/palette.html
#	internal/sheet/history.go
#	internal/sheet/move.go
#	internal/ui/rowtext/rowtext.go
#	internal/ui/theme/scheme.go
#	internal/ui/view.go
commit
3aef18c9052d72bbc0ec2d7ae47404cf624f49e9
parent
e034dc50a998ec4332f5e54dc92a1bd87564d1a8
parent
27bca8f2d8d879433fa3f84d24911264cb7f36a3
author
Dami <dami.etoile@gmail.com>
164 files changed, +5542 -676
M.gitignore1+
MMakefile18+-
MROADMAP.md29+-
Mcmd/012/configcmd.go2+
Mcmd/012/main.go6+-
Mcmd/012/main_test.go12+
Acmd/012/version.go43++++
Ademos/locale.tape32+++
Mdeploy/observability/docker-compose.yml2+-
Mdeploy/observability/grafana/dashboards/runtime.json122++++++++++-
Mdocs/contributing/README.md1+
Mdocs/contributing/architecture.md44+++-
Mdocs/contributing/extending.md23+-
Mdocs/contributing/limits.md212+++++++++--------
Mdocs/contributing/observability.md56++++-
Adocs/contributing/releasing.md62+++++
Mdocs/contributing/ux.md32++-
Mdocs/files/README.md6+-
Mdocs/files/format.md3+-
Mdocs/formulas/README.md5+
Adocs/media/locale-de.pngbin
Mdocs/reference/config.md24++-
Mdocs/reference/macro-api.md4+-
Mdocs/sheets/README.md1+
Mdocs/sheets/editing.md5+-
Adocs/sheets/locale.md95++++++++
Mdocs/sheets/rules.md19+-
Mdocs/terminal/README.md2-
Mdocs/terminal/ssh.md10+-
Mdocs/terminal/themes.md27++-
Me2e/config_test.go4+-
Me2e/harness_test.go4+-
Ae2e/locale_test.go79+++++++
Me2e/screenfiles_test.go2+-
Me2e/screenlist_test.go2+-
Ae2e/screenlocale_test.go33+++
Ae2e/screenthemes_test.go25++
Ae2e/testdata/screens/locale-de-light.html30++
Ae2e/testdata/screens/locale-de-signature-light.html30++
Ae2e/testdata/screens/locale-de-signature.html30++
Ae2e/testdata/screens/locale-de.html30++
Ae2e/testdata/screens/locale-picker-light.html30++
Ae2e/testdata/screens/locale-picker.html30++
Me2e/testdata/screens/menu-settings.html18+-
Me2e/testdata/screens/palette-narrow.html2+-
Me2e/testdata/screens/palette-search-light.html2+-
Me2e/testdata/screens/palette-search.html2+-
Me2e/testdata/screens/palette-wide.html2+-
Me2e/testdata/screens/palette.html6+-
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+-
Me2e/vim_test.go2+-
Minternal/config/config.go15+
Minternal/config/config_test.go27++
Minternal/config/docs.go3+
Minternal/config/registry.go37++-
Minternal/fileio/builder.go23+-
Minternal/fileio/csv.go40+++-
Minternal/fileio/csv_test.go10+-
Ainternal/fileio/csvlocale.go87+++++++
Ainternal/fileio/csvlocale_test.go116++++++++++
Minternal/fileio/fileio.go4+
Minternal/fileio/snapshot.go10+-
Ainternal/formula/locale.go129+++++++++++
Ainternal/formula/locale_test.go74++++++
Ainternal/locale/locale.go191++++++++++++++++
Ainternal/locale/locale_test.go66+++++
Minternal/numfmt/edit.go35++-
Minternal/numfmt/number.go23+-
Minternal/numfmt/pattern.go11+-
Minternal/numfmt/round.go19++
Minternal/sheet/cellformat.go14+-
Minternal/sheet/chart.go6+-
Minternal/sheet/clipfmt.go4+-
Minternal/sheet/decimal.go2+-
Minternal/sheet/display.go25+-
Minternal/sheet/evaluate.go13+-
Minternal/sheet/file.go19+-
Minternal/sheet/filter.go5+-
Minternal/sheet/history.go26+-
Minternal/sheet/historyrecord.go12+-
Minternal/sheet/historysize.go12+-
Minternal/sheet/historysize_test.go2+-
Minternal/sheet/insertbook.go2+-
Minternal/sheet/lines.go23+-
Ainternal/sheet/locale.go185+++++++++++++++
Ainternal/sheet/locale_test.go133+++++++++++
Minternal/sheet/looks.go8+-
Minternal/sheet/move.go3+-
Minternal/sheet/notes.go6+-
Minternal/sheet/occupancy.go31++-
Minternal/sheet/ops.go29+-
Minternal/sheet/pivotlayout.go2+-
Minternal/sheet/rangememo.go4+-
Minternal/sheet/recalc.go9+-
Minternal/sheet/remote.go2+-
Minternal/sheet/rowlayout.go8+-
Ainternal/sheet/rulemove.go117++++++++++
Ainternal/sheet/rulemove_test.go75++++++
Minternal/sheet/series.go2+-
Minternal/sheet/sheet.go46+--
Ainternal/sheet/slot.go284++++++++++++++++++++++++
Ainternal/sheet/slot_test.go144++++++++++++
Minternal/sheet/stats.go20+-
Minternal/sheet/stats_test.go8+-
Minternal/sheet/store.go362+++++++++++++++++++++++++-----
Minternal/sheet/store_test.go101++++++--
Minternal/sheet/stress_test.go68+++++-
Minternal/sheet/validation.go2+-
Ainternal/sheet/validcheck.go53++++
Minternal/sheet/workbook.go12+-
Minternal/stress/gen.go8+
Minternal/ui/assist.go22+-
Minternal/ui/assist_test.go2+-
Minternal/ui/clipboard.go12+-
Minternal/ui/columns.go4+-
Minternal/ui/edit.go10+-
Minternal/ui/entry.go8+-
Minternal/ui/fill.go3+
Minternal/ui/filter.go4+-
Minternal/ui/links.go2+-
Ainternal/ui/locale.go133+++++++++++
Ainternal/ui/locale_test.go73++++++
Minternal/ui/menubar.go2+-
Ainternal/ui/monochrome_test.go332++++++++++++++++++++++++++++
Minternal/ui/mouse.go2+-
Minternal/ui/panel.go19+-
Minternal/ui/prefs.go4+
Minternal/ui/rows.go2+-
Minternal/ui/rowtext/rowtext.go52++--
Minternal/ui/rules.go50++++-
Minternal/ui/rules/condfmt.go12+-
Minternal/ui/rules/form.go13+
Minternal/ui/rules/validation.go12+-
Minternal/ui/tabs.go2+-
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+
Minternal/ui/transfer.go3+
Ainternal/ui/validpaste_test.go88+++++++
Minternal/ui/view.go2+-
Minternal/ui/vim.go2+-
Minternal/ui/vim_test.go2+-
Ainternal/value/locale.go311++++++++++++++++++++++++++
Ainternal/value/locale_test.go215++++++++++++++++++
Minternal/value/parse.go7+-
Ascripts/dist.sh63+++++
Mscripts/doccheck/tree.go45++-
Mscripts/stress/load.sql12+-
Ascripts/stress/release.sql71++++++
Mscripts/stress/report.sh59++++-
Mscripts/stress/report.sql8+-
Mscripts/stress/run.sh4+-
M .gitignore +1 -0
M Makefile +13 -5
@@ -7,7 +7,7 @@ GHOSTTY_SRC := $(DEPS)/ghostty-src
77 GHOSTTY_OUT := $(DEPS)/ghostty88 GHOSTTY_STAMP := $(GHOSTTY_OUT)/.built-$(GHOSTTY_COMMIT)99 10-.PHONY: check lint build run test fuzz e2e screens oracle demos libghostty clean stress stress-data stress-report obs-up obs-down obs-status stress-load stress-e2e10+.PHONY: check lint build run test fuzz e2e screens oracle demos libghostty clean stress stress-data stress-report obs-up obs-down obs-status stress-load stress-e2e dist1111 1212 build:1313 CGO_ENABLED=0 go build -o bin/012 ./cmd/012
@@ -15,6 +15,12 @@ build:
1515 run: build1616 ./bin/0121717 18+# Release archives: 012 cross-compiled (pure Go, -trimpath, stamped with19+# VERSION) for macOS, Linux and Windows on amd64 and arm64 into dist/,20+# with SHA256SUMS. Nothing is uploaded; see docs/contributing/releasing.md.21+dist:22+ VERSION=$(VERSION) scripts/dist.sh23+1824 test:1925 go test ./...2026
@@ -52,19 +58,21 @@ screens: $(GHOSTTY_STAMP)
5258 # Stress: fetch real datasets into .deps/stress, run the benchmarks built5359 # with -tags stress, print a summary and record the run in5460 # .deps/stress/results/runs.jsonl. BENCH, BENCHTIME and PKGS narrow it;55-# see docs/limits.md. Plain `go test ./...` never runs these.61+# see docs/contributing/limits.md. Plain `go test ./...` never runs these.5662 stress:5763 scripts/stress/run.sh5864 5965 stress-data:6066 scripts/stress-data.sh6167 62-# Compare the latest run with the previous one and a baseline (DuckDB).68+# Compare the latest run with the previous one, a baseline and the last69+# tagged release's run (DuckDB); exits 1 on a regression against the70+# release past THRESHOLD (0.10) plus the benchmark's noise.6371 stress-report:6472 scripts/stress/report.sh6573 6674 # The observability stack (deploy/observability): an OpenTelemetry67-# Collector, ClickHouse and Grafana in Docker. See docs/observability.md.75+# Collector, ClickHouse and Grafana in Docker. See docs/contributing/observability.md.6876 obs-up:6977 scripts/obs.sh up7078
@@ -104,7 +112,7 @@ demos: build
104112 mkdir -p demos/out/stills105113 bin/fakejev -addr $(FAKEJEV_ADDR) & pid=$$!; trap "kill $$pid" EXIT; \106114 export DEMOS_TTYD="$$(command -v ttyd)" PATH="$(CURDIR)/demos/lib:$$PATH"; \107- export XDG_CONFIG_HOME="$$(mktemp -d)" O12_JEV_CREDENTIAL_STORE=false O12_THEME=; \115+ export XDG_CONFIG_HOME="$$(mktemp -d)" O12_JEV_CREDENTIAL_STORE=false O12_THEME= O12_LOCALE=en-US; \108116 cd demos && for d in $(DEMOS); do echo "vhs $$d.tape"; vhs -q $$d.tape || exit 1; done109117 mkdir -p demos/out/media110118 for d in $(DEMOS); do ffmpeg -v error -y -i demos/out/$$d.gif -filter_complex \
M ROADMAP.md +13 -16
@@ -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
@@ -27,19 +27,19 @@ Sizes: S (a day or two), M (about a week), L (weeks).
2727 | Item | Notes | Size |2828 |---|---|---|2929 | Wrap text, row heights, borders, merged cells | Layout changes in the grid renderer | M to L |30-| Locale: decimal comma, date order, list separator in formulas | Sheets' File > Settings > Locale | M |30+| Locale, what's left: month and day names in the locale's language (`mmm`, `dddd`), Excel downloads with the locale's Currency and Date formats | The rest follows the locale: [Locale](docs/sheets/locale.md) | S |3131 3232 ### 3. Scale3333 3434 The grid is Excel's, 1,048,576 x 16,384, and memory is bounded by the35-`max-cells` budget rather than the grid. The budget rises in measured36-steps; see [Bounds of support](docs/contributing/limits.md#what-would-raise-the-bounds).35+`max-cells` budget (ten million cells) rather than the grid. The budget36+rises in measured steps; see [Bounds of support](docs/contributing/limits.md#what-would-raise-the-bounds).3737 3838 | Item | Result | Size |3939 |---|---|---|40-| Compact column storage behind `cellStore`: typed value blocks, formulas and formats in side tables (about 20 to 40 B per cell instead of 300) | A budget of 10 M+ cells in a few hundred MB, cell reads 2 to 3 ns | L |4140 | A streaming or binary `.012` format next to the readable JSON one | Open and save scale with the data | M |42-| Smaller undo steps: formatting changes as diffs | More history in the same memory | S |41+| Smaller undo steps: plain cells' before-images as slots, formatting changes as diffs | More history in the same memory; clearing a full sheet costs what the sheet does | S to M |42+| Compact spilled and pivot cells: derived cells in the compact slot form instead of whole `Cell`s | Large spills and pivots cost what plain cells cost ([Bounds of support](docs/contributing/limits.md#what-would-raise-the-bounds)) | M |4343 | Linked, paged read-only ranges over Parquet and SQLite that feed pivots and formulas by streaming | Sources too big for any grid | L |4444 4545 ### 4. Macros, keys and the terminal
@@ -51,15 +51,6 @@ steps; see [Bounds of support](docs/contributing/limits.md#what-would-raise-the-
5151 | Hold-to-preview and Shift+Enter on terminals with the kitty keyboard protocol (`View.KeyboardEnhancements`) | S |5252 | Sixel chart images, redrawn on resize, for terminals without kitty graphics | M |5353 54-### 5. Distribution and upkeep55-56-| Item | Size |57-|---|---|58-| Prebuilt binaries attached to version tags, built locally (the GitHub repo is hosting only); tags carry release notes in their message | S |59-| `make stress-report` thresholds that flag regressions over a set percentage against the last release | S |60-| Grafana: a trace panel for the nested spans | S |61-| Accessibility: a high-contrast theme, and an audit that every state reads without color (a [UX rule](docs/contributing/ux.md#visual-rules)) | S |62-6354 ## Later: sharing a live sheet (shelved)6455 6556 Explored, not scheduled. `012 serve` gives each SSH session its own
@@ -86,7 +77,9 @@ by a gateway dialing the iroh ticket.
8677 - Sheets' everyday, math, text, lookup, date and finance functions, checked against excelize: [Functions](docs/reference/functions.md), [Testing](docs/contributing/testing.md#the-excelize-oracle)8778 - Dynamic arrays that spill (FILTER, SORT, UNIQUE, SEQUENCE and more, ARRAYFORMULA), LET and LAMBDA, SPLIT and the REGEX functions: [Arrays and spills](docs/formulas/arrays.md)8879 - Opt-in decimal arithmetic for money: [Decimal arithmetic](docs/formulas/decimal.md)80+- A locale per file, as Sheets' File > Settings > Locale: decimal commas, date order, currency and `;` in formulas, typed and shown while files store en-US's form: [Locale](docs/sheets/locale.md)8981 - An Excel-sized grid with a `max-cells` budget; operations cost the data, not the grid: [Bounds of support](docs/contributing/limits.md#sheet-size)82+- Compact column storage and a ten-million-cell `max-cells` budget: [Bounds of support](docs/contributing/limits.md#sheet-size)9083 9184 **Finding and using features**9285
@@ -99,7 +92,7 @@ by a gateway dialing the iroh ticket.
9992 10093 - Freeze, multi-column sort, filters with value pickers and conditions: [Freeze, sort and filter](docs/sheets/sort-filter.md#freeze)10194 - Find and replace, with regular expressions, across sheets: [Find and replace](docs/sheets/find-replace.md)102-- Conditional formatting (single-color rules, color scales) and data validation (dropdowns, checkboxes, bounds): [Conditional formatting and data validation](docs/sheets/rules.md#conditional-formatting)95+- Conditional formatting (single-color rules, color scales) and data validation (dropdowns, checkboxes, bounds), checked on pastes and fills and moving with cut and paste: [Conditional formatting and data validation](docs/sheets/rules.md#conditional-formatting)10396 - Pivot tables and frequency tables, live, with subtotals and renamed values: [Pivot tables](docs/sheets/pivots.md)10497 - Notes on cells: [Notes and protection](docs/sheets/notes-protection.md#notes)10598 - Protected sheets and ranges that warn on edit: [Notes and protection](docs/sheets/notes-protection.md#protected-sheets-and-ranges)
@@ -111,6 +104,7 @@ by a gateway dialing the iroh ticket.
111104 112105 - System clipboard, hyperlinks, curly error underlines, cursor shapes, progress and notifications, light and dark: [The terminal](docs/terminal/README.md#terminal-features-012-uses)113106 - Color schemes and a config file: [Themes](docs/terminal/themes.md), [Configuration](docs/reference/config.md)107+- A high-contrast theme at WCAG AAA, and every state readable without color: [Themes](docs/terminal/themes.md#high-contrast), [UX](docs/contributing/ux.md#reading-without-color)114108 - `012 serve` over SSH, with files on the ssh command line and recovery of unsaved work: [Serving over SSH](docs/terminal/ssh.md)115109 116110 **Files**
@@ -120,3 +114,6 @@ by a gateway dialing the iroh ticket.
120114 **Upkeep**121115 122116 - `make check` before every push; demo tapes (`make demos`); annotated version tags with release notes (v0.2.0 onward; v0.1.0 remains on the Go module proxy): [Testing](docs/contributing/testing.md)117+- A release checklist and `make dist`, release archives cross-compiled locally with SHA256SUMS: [Releasing](docs/contributing/releasing.md)118+- `make stress-report` flags regressions against the last release's run, allowing for noise: [Observability](docs/contributing/observability.md#regressions-against-the-last-release)119+- Grafana: recent traces and a trace view of the nested spans: [Observability](docs/contributing/observability.md#the-stack)
M cmd/012/configcmd.go +2 -0
M cmd/012/main.go +5 -1
M cmd/012/main_test.go +12 -0
A cmd/012/version.go +43 -0
A demos/locale.tape +32 -0
M deploy/observability/docker-compose.yml +1 -1
M deploy/observability/grafana/dashboards/runtime.json +118 -4
@@ -409,7 +409,7 @@
409409 "id": 9,410410 "type": "table",411411 "title": "Slowest spans (OTLP traces)",412- "description": "The slowest spans 012 sent over OTLP; each is the root of its own trace, and the log record of the same event carries its TraceId",412+ "description": "The slowest spans 012 sent over OTLP, roots and nested ones; a trace id opens that trace in the trace view below",413413 "datasource": {414414 "type": "grafana-clickhouse-datasource",415415 "uid": "clickhouse"
@@ -435,17 +435,131 @@
435435 ],436436 "fieldConfig": {437437 "defaults": {},438- "overrides": []438+ "overrides": [439+ {440+ "matcher": {441+ "id": "byName",442+ "options": "trace"443+ },444+ "properties": [445+ {446+ "id": "links",447+ "value": [448+ {449+ "title": "Show this trace",450+ "url": "/d/o12-runtime/012-runtime?var-trace=${__value.raw}&${__url_time_range}"451+ }452+ ]453+ }454+ ]455+ }456+ ]457+ },458+ "options": {459+ "showHeader": true460+ }461+ },462+ {463+ "id": 10,464+ "type": "table",465+ "title": "Recent traces with nested spans",466+ "description": "Traces of more than one span, newest first: the root span, its command id, how many spans it holds, which spans nest in it and how long the root took. A trace id opens it in the trace view below",467+ "datasource": {468+ "type": "grafana-clickhouse-datasource",469+ "uid": "clickhouse"470+ },471+ "gridPos": {472+ "x": 0,473+ "y": 39,474+ "w": 24,475+ "h": 8476+ },477+ "targets": [478+ {479+ "datasource": {480+ "type": "grafana-clickhouse-datasource",481+ "uid": "clickhouse"482+ },483+ "editorType": "sql",484+ "format": 1,485+ "queryType": "table",486+ "refId": "A",487+ "rawSql": "SELECT min(Timestamp) AS time, anyIf(SpanName, ParentSpanId = '') AS root, anyIf(SpanAttributes['id'], ParentSpanId = '') AS command, count() AS spans, arrayStringConcat(arraySort(groupUniqArrayIf(SpanName, ParentSpanId != '')), ', ') AS nested, round(maxIf(Duration, ParentSpanId = '') / 1e6, 2) AS ms, countIf(StatusCode = 'Error') AS errors, TraceId AS trace FROM otel.otel_traces WHERE $__timeFilter(Timestamp) AND ServiceName = '012' GROUP BY TraceId HAVING spans > 1 ORDER BY time DESC LIMIT 100"488+ }489+ ],490+ "fieldConfig": {491+ "defaults": {},492+ "overrides": [493+ {494+ "matcher": {495+ "id": "byName",496+ "options": "trace"497+ },498+ "properties": [499+ {500+ "id": "links",501+ "value": [502+ {503+ "title": "Show this trace",504+ "url": "/d/o12-runtime/012-runtime?var-trace=${__value.raw}&${__url_time_range}"505+ }506+ ]507+ }508+ ]509+ }510+ ]439511 },440512 "options": {441513 "showHeader": true442514 }515+ },516+ {517+ "id": 11,518+ "type": "traces",519+ "title": "Trace ${trace}",520+ "description": "The spans of one trace as a tree with their timing: the trace id in the Trace id box at the top, or the latest trace with nested spans when it is empty",521+ "datasource": {522+ "type": "grafana-clickhouse-datasource",523+ "uid": "clickhouse"524+ },525+ "gridPos": {526+ "x": 0,527+ "y": 47,528+ "w": 24,529+ "h": 14530+ },531+ "targets": [532+ {533+ "datasource": {534+ "type": "grafana-clickhouse-datasource",535+ "uid": "clickhouse"536+ },537+ "editorType": "sql",538+ "format": 3,539+ "queryType": "traces",540+ "refId": "A",541+ "rawSql": "SELECT TraceId AS traceID, SpanId AS spanID, ParentSpanId AS parentSpanID, ServiceName AS serviceName, SpanName AS operationName, multiply(toUnixTimestamp64Nano(Timestamp), 0.000001) AS startTime, multiply(Duration, 0.000001) AS duration, arrayMap(key -> map('key', key, 'value', SpanAttributes[key]), mapKeys(SpanAttributes)) AS tags, arrayMap(key -> map('key', key, 'value', ResourceAttributes[key]), mapKeys(ResourceAttributes)) AS serviceTags, if(StatusCode IN ('Error', 'STATUS_CODE_ERROR'), 2, 0) AS statusCode FROM otel.otel_traces WHERE ServiceName = '012' AND TraceId = if('${trace}' != '', '${trace}', (SELECT TraceId FROM otel.otel_traces WHERE $__timeFilter(Timestamp) AND ServiceName = '012' AND ParentSpanId != '' ORDER BY Timestamp DESC LIMIT 1)) ORDER BY Timestamp LIMIT 1000"542+ }543+ ],544+ "options": {}443545 }444546 ],445547 "templating": {446- "list": []548+ "list": [549+ {550+ "name": "trace",551+ "label": "Trace id",552+ "type": "textbox",553+ "query": "",554+ "current": {555+ "text": "",556+ "value": ""557+ },558+ "description": "The trace the trace view shows; empty shows the latest trace with nested spans"559+ }560+ ]447561 },448562 "annotations": {449563 "list": []450564 }451-}\ No newline at end of file565+}
M docs/contributing/README.md +1 -0
M docs/contributing/architecture.md +35 -9
@@ -16,6 +16,7 @@ internal/functions the function library: the FuncDef table, evaluation, decimal
1616 internal/value cell values, number formats, typed-entry parsing, the clock1717 internal/formula the formula language: references, lexer, parser, printer, rewriting1818 internal/numfmt number formats, rounding, General, date serials19+internal/locale the locales: separators, date order, currency, formula separators1920 internal/fileio import and export: CSV, TSV, XLSX, SQLite, Parquet, Lotus .wk12021 internal/chart chart layout, text rendering and kitty image encoding2122 internal/jev the API key's resolution, answer cache and TypeSafe client
@@ -58,7 +59,21 @@ internal/functions ---> internal/formula |
5859 internal/value ---> internal/numfmt <------+5960 ```6061 61-(`sheet` also uses `formula`, `value` and `numfmt` directly.)62+(`sheet` also uses `formula`, `value` and `numfmt` directly. Below63+them all, `internal/locale` holds the table of locales and depends on64+nothing.)65+66+- `internal/locale` is the conventions of each locale: decimal and67+ thousands separators, date order and patterns, currency symbol and68+ place, and the formula separators that follow from the decimal one.69+ Cells, formulas and files keep en-US's form whatever the locale; the70+ packages above translate at the edges, where entries are typed and71+ shown: `value.Canonicalize` and `Localize` for typed values,72+ `formula.Delocalize` and `Localize` for formulas (one byte for one,73+ so positions carry over), `numfmt.FormatIn` and `Format.CodeIn` for74+ display, and `sheet.CanonicalEntry` and `LocalEntry` for whole75+ entries. The workbook's locale is a file setting beside decimal76+ arithmetic ([Locale](../sheets/locale.md)).6277 6378 - `internal/formula` is the language: `Addr` and `Rect`, A1 references6479 with their `$` markers, sheet names in references and their quoting,
@@ -92,13 +107,24 @@ was typed, its parsed expression, its computed value, and its format and
92107 style.93108 94109 - **Cells.** Each sheet keeps its cells in a `cellStore` (`store.go`):95- get, set, delete, count, iterate all or a range. Nothing else touches96- the map beneath, so the representation can change without the rest of97- the engine noticing. Beside it, occupancy indexes (`occupancy.go`,110+ get, set, delete, count, iterate all or a range, and narrower reads111+ (a value, presence, formatting, the formula cells). Nothing else112+ touches the representation beneath. Occupancy indexes (`occupancy.go`,98113 bitmaps of 1024 rows per column) record which cells are stored and99114 which have contents, so a range of the million-row grid is read at the100- cost of what it holds. Formats of whole columns and rows, and of the101- whole sheet, live on the lines (`lines.go`); a cell falls back on them.115+ cost of what it holds; the stored index's blocks hold the cells116+ themselves, a 16-byte slot each in row order (`slot.go`). A plain cell117+ (a number, boolean or text as typed, with a format and style) lives in118+ its slot, with its text in a table of strings kept once each, its119+ formatting in a table of looks, and its input only when that isn't120+ the value's own text ("1.50" is kept as 1.5 printed with two121+ decimals). Formulas, notes, and what pivots and spills write are whole122+ `Cell`s in a side table, which recalculation updates in place. `get`123+ hands out a plain cell as a `Cell` made for the caller, a copy whose124+ changes reach nothing; `set` is the one way to change a cell. What125+ each costs is in [Bounds of support](limits.md#sheet-size). Formats of126+ whole columns and rows, and of the whole sheet, live on the lines127+ (`lines.go`); a cell falls back on them.102128 Copy, paste and move carry the formatting cells show (`clipfmt.go`):103129 whole lines as line formats, blocks as the cells' own. A line's format104130 changing recalculates the formulas reading any cell of it, found through
@@ -273,9 +299,9 @@ setting (`SetDecimal`, which marks formulas with `functions.Decimalize`).
273299 The package boundaries leave the bounds in [Bounds of support](limits.md) room to274300 move without touching callers:275301 276-- **Storage.** Compact cell storage (column blocks of values, formulas and277- formats in side tables) replaces `cellStore`'s map; its methods are the278- whole contract.302+- **Storage.** `cellStore`'s methods are the whole contract, so what is303+ kept whole or in a slot can change (spilled and pivot cells in slots,304+ plain before-images in the history) without touching callers.279305 - **Range reads.** Shared range results, prefix sums for running totals280306 and column-block scans go behind the engine's `Scan` and `RangeAgg`,281307 the places functions read ranges.
M docs/contributing/extending.md +16 -7
@@ -25,6 +25,7 @@ shows it is derived from the table rather than listing it again.
2525 | `chart.types`, with `sheet.ChartTypes` | a type: name and order (sheet, saved in files), a layout drawing text and image and the series its legend lists (chart) | `chart.Draw`, `chart.Image`, chart editor, Insert > Chart |2626 | theme roles (`theme.Theme`) | a role: dark and light styles on the 16 ANSI colors, with a contrast minimum for schemes (`minContrast`) | every style in the UI, drawn in the terminal's palette or any color scheme (`FromPalette`); `TestEveryThemeReadable` checks each role under every built-in scheme |2727 | `config.Options` | an option: name, type, default, environment variables, flag, live or not, description, check | parsing and warnings, flags, `012 config` and its default file, [Configuration](../reference/config.md), Reload config |28+| `locale.table` | a locale: tag, name, separators, date order and patterns, currency and its place | parsing typed entries, display, formula separators, CSV, File > Settings > Locale, the `locale` option's values |2829 2930 Adding a function, command, format, chart type, option or role means3031 adding an entry (and its file), not editing switch statements elsewhere.
@@ -117,13 +118,21 @@ modified.
117118 118119 Code reads and writes cells through a narrow API (`cellStore` in119120 `internal/sheet/store.go`: get, set, delete, count, iterate everything or120-a range, a column being a range), not the map underneath. That lets the121-store change shape (compact column blocks, side tables for formulas and122-formats; see [Bounds of support](limits.md)) without touching the rest of the123-engine. Beside the map, occupancy indexes (`occupancy.go`) say which rows124-of each column hold a cell, and which hold contents, so a range yields its125-cells in row order at the cost of what it holds, and the used range, data126-edges and the next filled cell are found without scanning.121+a range, a column being a range), never the representation underneath:122+column blocks of 16-byte slots holding plain cells' values, with text,123+formats and whole formula cells in side tables (see124+[Bounds of support](limits.md#sheet-size)). `get` returns a `*Cell`:125+a formula's own, which recalculation updates, or for a plain cell one126+made for the caller, which it may keep but whose changes reach nothing;127+every change goes through `set`. Making that `Cell` costs an allocation128+or three, so code that visits many cells asks for only what it needs:129+`value` and `peek` for values, `has` and `filledAt` for presence,130+`look` for formatting, `richAt` and `richCells` for formulas and notes,131+and the key iterators (`keys`, `keysIn`, `anyKeysIn`) for addresses. The132+stored occupancy index (`occupancy.go`) holds the blocks; a second says133+which rows hold contents, so a range yields its cells in row order at134+the cost of what it holds, and the used range, data edges and the next135+filled cell are found without scanning.127136 128137 ### 7. Read ranges as ranges129138
M docs/contributing/limits.md +111 -101
@@ -25,51 +25,60 @@ it lags, and past a second it stalls.
2525 2626 | Dimension | Comfortable | Degraded | Unsupported | Dominant cost |2727 |---|---|---|---|---|28-| Sheet size | A grid of 1,048,576 x 16,384 (A..XFD), with up to `max-cells` cells (two million by default): navigation, drawing and every command cost what the cells cost, not the grid | Loading and saving two million cells: 1 to 2 s; 600 MB of heap | More than `max-cells` cells: imports keep whole rows up to it and say what they dropped; larger pastes and fills are refused | Heap per cell (about 300 B), JSON file format |29-| Incremental recalc | A change that makes formulas read under about 500,000 cells in total (fan-out, chains and volatiles of 8192 cells: 1 to 2 ms); 1000 SUMs over a full column: 0.75 ms; 8192 running totals: 2.8 ms | 60 criteria functions (SUMIF, COUNTIFS, AVERAGEIF) over whole columns of 8192 rows: 37 ms an edit | | About 20 ns per cell read: two map lookups |30-| Full recalc | Any sheet: under 40 ms for 213 k numbers; 1000 full-column SUMs 1 ms; 8192 running totals 2.5 ms | 60 whole-column criteria functions over 8192 rows: 59 ms | | Same as above |28+| Sheet size | A grid of 1,048,576 x 16,384 (A..XFD), with up to `max-cells` cells (ten million by default): navigation, drawing and every command cost what the cells cost, not the grid; ten million numbers take 205 MB and build in 2.2 s | Opening a `.012` file of ten million cells: 8 s and 2.7 GB at its peak; saving it: 3.7 s | More than `max-cells` cells: imports keep whole rows up to it and say what they dropped; larger pastes and fills are refused | JSON file format, decoded whole; heap per cell (about 20 B for numbers, 20 to 60 B for real data, 750 B for formulas) |29+| Incremental recalc | A change that makes formulas read under about 500,000 cells in total (fan-out, chains and volatiles of 8192 cells: 1.3 to 1.9 ms); 1000 SUMs over a full column: 0.53 ms; 8192 running totals: 2.7 ms | 60 criteria functions (SUMIF, COUNTIFS, AVERAGEIF) over whole columns of 8192 rows: 29 ms an edit | | About 3 ns per cell read: an index into the column's block |30+| Full recalc | Any sheet: numbers and text hold their values and cost nothing; 1000 full-column SUMs 0.5 ms; 8192 running totals 1.8 ms | 60 whole-column criteria functions over 8192 rows: 44 ms | | Same as above |3131 | Rendering | Any sheet at up to 200 x 60: 1 ms a frame; 400 x 120: 4.5 ms; a color scale on every cell shown adds 0.4 ms at 200 x 60 | 20 charts at 400 x 120: 7 ms | | View building styled strings, then Bubble Tea parsing and diffing them |32-| Selection statistics | Any selection: extending one over all 2 M cells costs 0.4 ms a key | | | Per column with data, 1024 or 64 rows at a time from an index on the blocks of filled cells, plus the rows at the selection's ends |33-| Imports | CSV, SQLite, Parquet: 1 to 3 M cells/s (two million cells in about 1 s); XLSX numbers or text: 0.7 to 1.1 M cells/s | XLSX with formulas: 0.4 to 0.6 M cells/s | Data past `max-cells` or the grid (dropped, with a note); XLSX files past the reader's limits (refused) | Building cells one at a time; XML decoding; XLSX formula translation |34-| Undo | One step of any size: undo costs what the edit cost | History capped at 100 steps and 256 MB of before-images: 100 whole-column steps hold 250 MB | | Whole-cell before-images, about 300 B per cell per step |32+| Selection statistics | Any selection: extending one over all 2.1 M cells of 8192 x 256 costs 0.3 ms a key | | | Per column with data, 1024 or 64 rows at a time from an index on the blocks of filled cells, plus the rows at the selection's ends |33+| Imports | CSV, SQLite, Parquet: 2 to 4 M cells/s (a million cells in 0.25 to 0.4 s); XLSX numbers or text: 0.8 to 1.4 M cells/s | XLSX with formulas: 0.4 to 0.6 M cells/s | Data past `max-cells` or the grid (dropped, with a note); XLSX files past the reader's limits (refused) | Building cells one at a time; XML decoding; XLSX formula translation |34+| Undo | One step of any size: undo costs what the edit cost | History capped at 100 steps and 256 MB of before-images: 100 whole-column steps hold 280 MB | | Whole-cell before-images, about 300 B per cell per step |3535 | JEV | 4000 JEV cells: 4 us of CPU per answer, 15 ms to answer them all | | | An answer recalculates the cells that asked it; answers within a frame recalculate together |3636 | Formula depth | 1000 nested parentheses or IFs: under 0.5 ms; chains of formulas through every cell of a sheet | | More than 1024 levels of nesting in one formula: a parse error | Recursive parser; evaluation puts off cells past 65,536 levels |3737 | Macros | Replaying 1000 recorded actions: 2.2 ms, one undo step; a script's call to the sheet: about 1.4 us | | Scripts past 10 M Starlark steps: stopped, with the line | One message per call to the sheet, served in batches on the UI goroutine |3838 | Find, filter, sort, fill | Filter or sort 8191 rows: 1 to 32 ms; find over 213 k cells: 38 ms; fill 8192 rows: 4 ms | Replace all over 213 k cells: 280 ms | | Per-cell string conversion and regexp |3939 | SSH sessions (`012 serve`) | 50 sessions typing at once: frames within one frame interval (p95 8.7 ms), 1.3 MiB per session plus its sheets (upper bound) | | More than `--max-sessions` (8 by default): turned away | Bubble Tea's 120 fps pacing; per session, the terminal's cell buffers |40-| Pivot tables | A pivot over 8191 rows recomputes after an edit to its source in 1 to 4 ms | | Results past the grid (the pivot shows #REF!) | Reading each source cell of its fields: a map lookup each |41-| Arrays and spills | FILTER, SORT or UNIQUE over 8192 rows: about 0.1 ms each per edit; 1000 of them spilling 516 k cells, an edit recomputing 500: 55 ms | Full recalculation of those 1000: 204 ms | An array past `maxArray` values (2,097,152) stored: `#VALUE!`; a spill past the sheet's edge or `max-cells` cells: `#REF!` | Computing each array's values; writing only the spilled cells that changed |40+| Pivot tables | A pivot over 8191 rows recomputes after an edit to its source in 0.8 to 2.5 ms | | Results past the grid (the pivot shows #REF!) | Grouping the source rows |41+| Arrays and spills | FILTER, SORT or UNIQUE over 8192 rows: about 0.1 ms each per edit; 1000 of them spilling 516 k cells, an edit recomputing 500: 52 ms | Full recalculation of those 1000: 217 ms | An array past `maxArray` values (2,097,152) stored: `#VALUE!`; a spill past the sheet's edge or `max-cells` cells: `#REF!` | Computing each array's values; writing only the spilled cells that changed |4242 4343 ## Sheet size4444 4545 Hard limits: `sheet.MaxRows = 1,048,576`, `sheet.MaxCols = 16,384`4646 (A..XFD), as in Excel. Memory is bounded by `max-cells` (the config file,47-two million cells by default): imports keep whole rows up to it, pastes47+ten million cells by default): imports keep whole rows up to it, pastes4848 and fills write at most that many cells at once. WK1 files keep their own49-8192 x 256. Past about two million cells the costs below grow with the50-cells; the grid itself costs nothing (see [The grid](#the-grid)).49+8192 x 256. The costs below grow with the cells; the grid itself costs50+nothing (see [The grid](#the-grid)). At ten million cells the file51+format is what degrades: a `.012` file is JSON decoded whole.5152 52-| Measure | 8192 x 26 (213 k cells) | 8192 x 256 (2.1 M cells) |53-|---|---|---|54-| Build through `Load`, then full recalc | 90 ms | 1.4 s |55-| Live heap | 64 MB | 602 MB (about 300 B per number cell) |56-| Save `.012` (JSON, one line per cell) | 74 ms, 4.6 MB | 1.04 s, 47 MB |57-| Open `.012` | 120 to 130 ms (116 MB allocated) | 1.9 to 2 s (1.3 GB allocated) |58-| Export CSV / TSV / XLSX / SQLite | 41 / 55 / 62 / 40 ms | 0.71 / 0.66 / 0.81 / 0.72 s |59-| Frame at 200 x 60, whole | 1.06 ms | 1.05 ms |53+| Measure | 8192 x 26 (213 k cells) | 8192 x 256 (2.1 M cells) | 1,000,000 x 10 (10 M cells) |54+|---|---|---|---|55+| Build through `Load`, then full recalc | 48 ms | 0.47 s | 2.2 s (396 MB peak resident) |56+| Live heap | 4.4 MB | 43 MB (20.5 B per number cell) | 205 MB |57+| Save `.012` (JSON, one line per cell) | 77 ms, 4.6 MB | 0.83 s, 47 MB | 3.7 s, 236 MB |58+| Open `.012` | 108 ms (118 MB allocated) | 1.5 s (1.3 GB allocated) | 8.1 to 8.3 s (5.7 GB allocated, 2.7 GB peak resident) |59+| Export CSV / TSV / XLSX / SQLite | 33 / 31 / 45 / 38 ms | 0.51 / 0.52 / 0.65 / 0.68 s | |60+| Edit a number, undo and redo it | 0.7 us, 1.2 us | | 0.7 us, 1.2 us |61+| Frame at 200 x 60, whole | 0.88 ms | 0.87 ms | |6062 6163 XLSX exports are written by 012 with archive/zip: 1.9 MB allocated for6264 8192 x 26 and 17 MB for 8192 x 256, whose time is mostly compression.6365 6466 Heap per non-blank cell after loading (`BenchmarkMemory`,65-`BenchmarkImport`): numbers 301 B, formulas 707 B (the parsed tree and66-reference lists), 200-character text 790 B, imported CSV 275 to 300 B.67-A `Cell` struct is 216 B; the rest is the map entry, the input string68-and a boxed number for the parsed literal.67+`BenchmarkImport`): numbers 20.5 B, opened from a file too; formulas 75068+B (the parsed tree and reference lists); imported CSV, SQLite and69+Parquet 21 to 63 B; text about 60 B plus its length, once per distinct string70+(a column of 90 texts repeated takes 27 B a cell). A plain cell (a71+number, boolean or text as typed, with a format and style) is a 16-byte72+slot in its column's block of 1024 rows; formulas, notes, and the73+results pivots and spills write are whole `Cell`s, about 300 B each with74+their entry and input (the `Cell` itself is 216 B). How the store lays75+them out is in [Architecture](architecture.md#the-engine). Reading a76+cell's value is an index into its column's block: 2.8 ns77+(`BenchmarkRead`), 8.6 ns a cell read by a SUM in a full recalculation.6978 7079 ## The grid7180 72-Nothing costs what the grid costs. Beside the cell map, occupancy81+Nothing costs what the grid costs. The cells' occupancy7382 indexes (bitmaps of 1024 rows per column) say which cells are stored and7483 which have contents; ranges are read through them, so a whole column of7584 ten cells is ten visits. Formats of whole columns, rows and the sheet
@@ -101,7 +110,7 @@ What each part of 012 visits when given a range or selection:
101110 | XLSX column widths and styles | the data's width (at least 256), a style on A and XFD as the sheet's |102111 103112 A sheet of 10,000 numbers spread down a million rows under 3000104-whole-column formulas (sparse-1M) edits in 2.5 ms, draws a 200 x 60113+whole-column formulas (sparse-1M) edits in 1.6 ms, draws a 200 x 60105114 frame in 0.68 ms, and answers arrow keys at XFD1048576 in 0.65 ms.106115 Typing an entry under 1000 SUMs over a column, through to its frame at107116 200 x 60, takes 0.91 ms.
@@ -119,19 +128,22 @@ read once cost nothing extra.
119128 120129 | Topology | Edit | Full recalc | Undo + redo |121130 |---|---|---|---|122-| Independent cell in 8192 x 26 numbers | 0.66 us | 32 to 38 ms | 0.8 us |123-| Chain: A(n) = A(n-1)+1 down 8192 rows | 1.85 ms | 1.1 to 1.4 ms | 3.4 ms |124-| Fan-out: one cell read by 8192 formulas | 1.4 ms | 0.9 ms | 2.7 ms |125-| Volatile: 8192 TODAY() and RAND() | 1.3 ms | 0.8 ms | 2.5 ms |126-| 1000 named ranges, one formula each | 48 us | 2.8 ms | 99 us |127-| Fan-in: 1000 x SUM(A1:A8192), or SUM(A:A) | 0.75 ms | 1 ms | 1.4 ms |128-| Running totals: 8192 x SUM($A$1:An) | 2.85 ms | 2.5 ms | 5.4 ms |129-| Sparse: 10,000 numbers spread down a million rows, 1000 each of SUM(A:A), running totals and VLOOKUP over A:B | 2.45 ms | 3.6 ms | 5.1 ms |130-| Criteria: 60 SUMIF, COUNTIFS and AVERAGEIF over whole columns of 8192 rows | 37 ms | 59 ms | |131-| Lookup: 300 VLOOKUP, MATCH and XLOOKUP into 8192 keys | 25 ms | 26.5 ms | |132-133-Every volatile formula is recomputed on every change, as in Sheets, so134-8192 volatile cells add about 1.2 ms to every edit anywhere. Criteria131+| Independent cell in 8192 x 26 numbers | 0.7 us | nothing to compute | 1.2 us |132+| Chain: A(n) = A(n-1)+1 down 8192 rows | 1.9 ms | 1 ms | 3.8 ms |133+| Fan-out: one cell read by 8192 formulas | 1.3 ms | 0.8 ms | 2.6 ms |134+| Volatile: 8192 TODAY() and RAND() | 1.3 ms | 0.8 ms | 2.6 ms |135+| 1000 named ranges, one formula each | 55 us | 1.2 ms | 110 us |136+| Fan-in: 1000 x SUM(A1:A8192), or SUM(A:A) | 0.53 ms | 0.48 ms | 1.1 to 1.2 ms |137+| Running totals: 8192 x SUM($A$1:An) | 2.7 ms | 1.8 ms | 5.8 ms |138+| Sparse: 10,000 numbers spread down a million rows, 1000 each of SUM(A:A), running totals and VLOOKUP over A:B | 1.6 ms | 1.5 ms | 3.2 ms |139+| Criteria: 60 SUMIF, COUNTIFS and AVERAGEIF over whole columns of 8192 rows | 29 ms | 44 ms | 59 ms |140+| Lookup: 300 VLOOKUP, MATCH and XLOOKUP into 8192 keys | 12 ms | 12 ms | 27 ms |141+| 10 M numbers (1,000,000 x 10) | 0.7 us | nothing to compute | 1.2 us |142+143+A full recalculation computes only formulas and what pivots and spills144+write: numbers and text hold their values from the moment they are145+stored. Every volatile formula is recomputed on every change, as in146+Sheets, so 8192 volatile cells add about 1.2 ms to every edit anywhere. Criteria135147 and lookups cost what their ranges hold, not what the grid holds: the136148 criteria collect positions from the occupancy index, and lookups walk137149 the stored entries with a cursor.
@@ -169,7 +181,7 @@ terminal (`BenchmarkFrame`).
169181 | Sheet | 80 x 24 | 200 x 60 | 400 x 120 |170182 |---|---|---|---|171183 | Empty | 0.19 ms | 0.89 ms | 3.2 ms |172-| 8192 x 256 numbers | 0.20 ms | 1.05 ms | 4.1 ms |184+| 8192 x 256 numbers | 0.18 ms | 0.87 ms | 3.4 ms |173185 | 8192 rows of 500-character unicode text | 0.34 ms | 1.37 ms | 4.4 ms |174186 | 1000 named ranges | 0.30 ms | 1.06 ms | 3.5 ms |175187 | 20 charts (text) | 0.82 ms | 2.9 ms | 7.3 ms |
@@ -189,7 +201,7 @@ every cell on screen a shade), at 200 x 60 (`BenchmarkFrame`,
189201 | | Without rules | With the scale |190202 |---|---|---|191203 | A frame, or an arrow key through to it | 0.89 ms | 1.28 ms |192-| Typing a number and Enter, through to the frame | 0.91 ms | 2.9 ms (the scale's percentile over 213 k numbers) |204+| Typing a number and Enter, through to the frame | 0.89 ms | 2.3 ms (the scale's percentile over 213 k numbers) |193205 194206 Shades and rule colors keep their escape codes, so a plain cell on one195207 costs a string concatenation, not a style render. A custom formula is
@@ -202,8 +214,8 @@ slices, 0.44 ms, is the worst). An image for kitty graphics is redrawn
202214 when its data, size or theme changes: 25 to 260 us at 24 x 10 cells and203215 1 to 1.5 ms at 120 x 40, a pie 8.6 ms (`internal/chart`,204216 `BenchmarkDraw`, `BenchmarkImage`). Key presses add little: an arrow key205-through to its frame is 0.21 ms at 80 x 24 and 1.06 ms at 200 x 60;206-Page Down 1.26 ms.217+through to its frame is 0.19 ms at 80 x 24 and 0.9 ms at 200 x 60;218+Page Down 1.1 ms.207219 208220 ## Selection statistics209221
@@ -223,9 +235,9 @@ a selection over the whole sheet, through to its frame
223235 224236 | Key, 8192 x 256 numbers | 80 x 24 | 200 x 60 |225237 |---|---|---|226-| Shift+Up and Shift+Down with the data selected (`extend-data`) | 0.41 to 0.53 ms | 2.1 ms |227-| Ctrl+Shift+Down and Ctrl+Shift+Up over the first row (`extend-edge`) | 0.43 ms | 1.7 ms |228-| Ctrl+A, the selection standing (`select-data`) | 0.28 ms | 1.8 ms |238+| Shift+Up and Shift+Down with the data selected (`extend-data`) | 0.33 ms | 1.85 ms |239+| Ctrl+Shift+Down and Ctrl+Shift+Up over the first row (`extend-edge`) | 0.29 ms | 1.56 ms |240+| Ctrl+A, the selection standing (`select-data`) | 0.29 ms | 1.78 ms |229241 230242 The index costs memory only on sheets that had a large selection, in231243 proportion to their data, and a nil check per stored or recalculated
@@ -238,13 +250,13 @@ Real, openly licensed datasets fetched by `scripts/stress-data.sh`
238250 239251 | File | Shape | Import | Throughput | Heap per cell |240252 |---|---|---|---|---|241-| OWID energy (CSV) | 23,377 x 130, 1.04 M cells | 0.84 to 0.95 s | 1.1 to 1.2 M cells/s | 277 B |242-| Airport codes (CSV) | 86,134 x 13, 855 k cells | 0.78 to 0.80 s | 1.1 M cells/s | 274 B |243-| NOAA daily CO2 (CSV) | 18,304 x 2 | 6 ms | 2.7 M cells/s | 294 B |244-| Country codes (CSV, Arabic, CJK) | 249 x 56 | 5.6 ms | 2.2 M cells/s | 272 B |245-| Apache POI formula tests (XLSX) | 788 rows, 1189 formulas, 3828 cells | 9.5 to 9.7 ms | 395 k cells/s | 425 B |246-| Chinook PlaylistTrack (SQLite) | 8715 x 2 | 5.5 ms | 3.0 M cells/s | 293 B |247-| Parquet alltypes_tiny_pages | 7300 x 13 | 40 ms | 2.4 M cells/s | 276 B |253+| OWID energy (CSV) | 23,377 x 130, 1.04 M cells | 0.25 s | 4.2 M cells/s | 23 B |254+| Airport codes (CSV) | 86,134 x 13, 855 k cells | 0.39 s | 2.2 M cells/s | 55 B |255+| NOAA daily CO2 (CSV) | 18,304 x 2 | 12 ms | 3.1 M cells/s | 63 B |256+| Country codes (CSV, Arabic, CJK) | 249 x 56 | 5.4 ms | 2.3 M cells/s | 59 B |257+| Apache POI formula tests (XLSX) | 788 rows, 1189 formulas, 3828 cells | 8.5 to 10 ms | 380 to 450 k cells/s | 239 B |258+| Chinook PlaylistTrack (SQLite) | 8715 x 2 | 6.1 ms | 2.9 M cells/s | 21 B |259+| Parquet alltypes_tiny_pages | 7300 x 13 | 27 ms | 3.5 M cells/s | 44 B |248260 249261 Past `max-cells` (or the grid), imports keep whole rows up to it and250262 say how many rows they left out. Parquet files and SQLite tables stop
@@ -263,10 +275,10 @@ importing, per cell kept, and includes garbage not yet collected:
263275 264276 | Workbook | Time | Allocated | Allocations | Heap per cell after | Peak heap per cell |265277 |---|---|---|---|---|---|266-| 8192 x 26 numbers (213 k cells) | 189 to 199 ms | 170 MB | 3.3 M | 290 B | 589 B |267-| 8192 x 26 table, text and numbers | 296 to 312 ms | 234 MB | 7.4 M | 297 B | 722 B |268-| 8192 formulas (`=A1+1` down a column) | 13.8 ms | 17 MB | 321 k | 746 B | 2188 B |269-| Apache POI formula tests | 9.5 to 9.7 ms | 9.7 MB | 179 k | 425 B | 1288 B |278+| 8192 x 26 numbers (213 k cells) | 147 to 154 ms | 171 MB | 3.3 M | 20.5 B | 114 B |279+| 8192 x 26 table, text and numbers | 262 to 275 ms | 246 MB | 7.7 M | 21 B | 119 B |280+| 8192 formulas (`=A1+1` down a column) | 14.5 ms | 18.6 MB | 321 k | 755 B | 2061 B |281+| Apache POI formula tests | 8.5 to 10 ms | 9 MB | 162 k | 239 B | 965 B |270282 271283 The reader refuses files past `xlsxLimits` (in `internal/fileio/xlsxpkg.go`),272284 with a message naming the limit: 10,000 files in the zip; 1 GB
@@ -282,9 +294,12 @@ entities, so entity bombs fail as unknown entities.
282294 283295 ## Undo284296 285-Each step keeps a copy of every cell it changed. Undoing costs about297+Each step keeps a copy of every cell it changed, as a whole `Cell`298+(about 300 B, where the sheet keeps a number in 20). Undoing costs about286299 what the change cost, since it recalculates the same cells. Clearing287-213 k cells and undoing it takes 158 to 164 ms.300+213 k cells and undoing it takes 144 ms; clearing all ten million cells301+of a full `max-cells` sheet would keep about 3 GB of before-images, the302+one step the history always keeps however large.288303 289304 The history is bounded by steps (`MaxUndo = 100`) and by an estimate of290305 the memory its before-images hold (`MaxUndoBytes = 256 MB`); past either,
@@ -298,9 +313,9 @@ measured heap within about 10% (`BenchmarkHistoryFull`,
298313 299314 | Benchmark | Result |300315 |---|---|301-| 100 edits rewriting a whole column (819 k before-images) | 250 MB held (239 MB estimated), all 100 kept |302-| 12 edits rewriting all of 8192 x 26 (2.6 M before-images) | 230 MB held (248 MB estimated), the last 4 kept |303-| Undo + redo of one cell (dense, names) | 1.0 us, 106 us |316+| 100 edits rewriting a whole column (819 k before-images) | 281 MB held (252 MB estimated), all 100 kept |317+| 12 edits rewriting all of 8192 x 26 (2.6 M before-images) | 199 MB held (196 MB estimated), the last 3 kept |318+| Undo + redo of one cell (dense, names) | 1.2 us, 110 us |304319 305320 ## JEV306321
@@ -372,8 +387,8 @@ read-heavy recalculation.
372387 373388 | Benchmark (`internal/sheet`, `go test -bench SheetEdit`) | Edit |374389 |---|---|375-| 5000 formulas on one sheet reading `A(n)` and `SUM(A1:A10)` | 2.7 ms |376-| The same 5000 formulas reading `Data!A(n)` and `SUM(Data!A1:A10)` | 3.5 ms |390+| 5000 formulas on one sheet reading `A(n)` and `SUM(A1:A10)` | 1.65 ms |391+| The same 5000 formulas reading `Data!A(n)` and `SUM(Data!A1:A10)` | 2 ms |377392 378393 ## Serving over SSH379394
@@ -388,10 +403,10 @@ for 120 frames a second, as the local app does (`-benchtime 200x`):
388403 389404 | Sessions, sheet | Heap per session | Key to frame p50 / p95 / max | CPU per frame |390405 |---|---|---|---|391-| 10, new sheet | 1.30 MiB | 5.1 / 8.3 / 9.6 ms | 0.67 ms |392-| 10, 1000 x 26 numbers | 8.5 MiB | 4.3 / 8.3 / 8.5 ms | 0.71 ms |406+| 10, new sheet | 1.35 MiB | 5.1 / 8.3 / 9.8 ms | 0.83 ms |407+| 10, 1000 x 26 numbers | 1.9 MiB | 3.9 / 8.3 / 10.8 ms | 0.79 ms |393408 | 50, new sheet | 1.30 MiB | 5.0 / 8.7 / 17.8 ms | 0.53 ms |394-| 50, 1000 x 26 numbers | 8.5 MiB | 5.1 / 8.9 / 12.8 ms | 0.66 ms |409+| 50, 1000 x 26 numbers | 1.9 MiB | 5.1 / 8.9 / 12.8 ms | 0.66 ms |395410 396411 Latency doesn't move from 10 to 50 sessions: it's Bubble Tea's frame397412 pacing (at most one frame every 8.3 ms, so a key waits half a frame on
@@ -400,7 +415,7 @@ average), not load. A heap profile with 50 sessions open
400415 Bubble Tea's render buffers (about 1 MiB at 120 x 40, growing with the401416 window), 130 KiB in its input key table and under 50 KiB in both ends402417 of the SSH connection, so the figure is close to the server's own. On403-top come about 300 B per cell of the sheets a session has open;418+top come 20 to 60 B per cell of data of the sheets a session has open;404419 sessions opening the same file each hold a copy. CPU per frame covers405420 both ends of the connection (the encryption twice, the client reading406421 the frame); at 0.7 ms, 50 sessions typing continuously at 120 frames a
@@ -420,10 +435,10 @@ times the edit, the recalculation and the pivot together:
420435 421436 | Pivot | Result rows | Edit | Allocations |422437 |---|---|---|---|423-| Rows: 8 categories; SUM and COUNTA | 10 | 0.77 to 0.95 ms | 196 |424-| Rows: 8 x 8 categories with subtotals; columns: 8; SUM and AVERAGE | 75 | 2.7 to 2.9 ms | 2.5 k |425-| Rows: 1000 items sorted by their sum; SUM and COUNTUNIQUE | 1001 | 3.5 to 4.2 ms | 14 k |426-| Frequency table of the 1000 items | 1001 | 2.5 to 2.8 ms | 24 k |438+| Rows: 8 categories; SUM and COUNTA | 10 | 0.79 ms | 196 |439+| Rows: 8 x 8 categories with subtotals; columns: 8; SUM and AVERAGE | 75 | 2.2 ms | 2.5 k |440+| Rows: 1000 items sorted by their sum; SUM and COUNTUNIQUE | 1001 | 2.5 ms | 14 k |441+| Frequency table of the 1000 items | 1001 | 1.8 ms | 24 k |427442 428443 ## Arrays and spills429444
@@ -448,10 +463,10 @@ FILTERs, 63 of whose arrays change:
448463 449464 | Measure | Time | Allocated |450465 |---|---|---|451-| Edit a number | 55 ms | 161 MB, 8.3 k allocations |452-| Undo and redo it | 110 ms | |453-| Full recalculation | 204 ms | |454-| Build (load and recalculate) | 310 ms | |466+| Edit a number | 52 ms | 161 MB, 7.9 k allocations |467+| Undo and redo it | 103 ms | |468+| Full recalculation | 217 ms | |469+| Build (load and recalculate) | 298 ms | |455470 456471 Most of an edit is the FILTERs' own work: comparing 8192 categories each457472 (`B1:B8192="alpha"`, about 20 ns a value) and picking the rows that
@@ -481,39 +496,33 @@ computation.
481496 482497 ## What would raise the bounds483498 484-In order of value for effort. The first two are what raising485-`max-cells` toward Google Sheets' 10 M cells would take; without them a486-10 M-cell sheet would need about 3 GB of heap and take 10 s to open.487-488-1. **Compact cell storage** (L, 2 to 3 weeks). Cells live in a489- `map[Addr]*Cell` behind `cellStore` (`internal/sheet/store.go`), 300 B490- each: a 216 B struct carrying formula-only491- fields (expression, reference lists, inferred format) on every cell,492- plus the map entry, the input string and a boxed literal. Storing493- columns in row blocks (say 1024 rows) of compact values, with494- formulas, formats and styles in side tables keyed by address, and495- the input string kept only when it differs from the canonical text496- of the value, would bring numbers to about 20 to 40 B per cell and497- turn every cell read in recalc from two hash lookups (20 ns) into an498- index (2 to 3 ns). Cells are already per sheet in a workbook, so the499- storage can change sheet by sheet, and nothing outside `cellStore`500- touches the map, so the change stays inside it.501-2. **Streaming, compact file format** (M, 1 week). The `.012` file is502- JSON decoded whole (1.3 GB allocated to open 47 MB). A streaming499+In order of value for effort; sizes and scheduling are in the500+[roadmap](../../ROADMAP.md#3-scale). `max-cells` stands at ten million,501+as Google Sheets' limit does; the sheet itself would hold several times502+that in a few GB, and these are what stand in the way.503+504+1. **Streaming, compact file format.** The `.012` file is JSON decoded505+ whole: ten million cells take 8 s and 2.7 GB at the peak to open,506+ 5.7 GB allocated, for a sheet that then holds 205 MB. A streaming503507 decoder over the same format would roughly halve open time and cut504508 allocation tenfold; a columnar or gzip-compressed variant would cut505- the size about fourfold. The format is `internal/sheet/file.go`, apart506- from the cell store.507-3. **Smaller undo steps** (S, 1 day). The history is capped by the508- memory its before-images hold; storing formatting-only changes as509- diffs rather than whole cells would let it keep more of them.509+ the size about fourfold. The format is `internal/sheet/file.go`,510+ apart from the cell store.511+2. **Smaller undo steps.** A step keeps a whole `Cell` for each cell it512+ changed, formatting-only changes included (see [Undo](#undo)).513+ Keeping plain cells' before-images as slots, and formatting changes514+ as diffs, would let a step over a full sheet cost what the sheet515+ does.516+3. **Compact spilled and pivot cells.** What arrays and pivots write is517+ kept as whole `Cell`s (516 k of them in the arrays stress shape);518+ their values and inferred formats would fit in slots.510519 511520 ## Measuring512521 513522 ```sh514523 make stress # fetch datasets, run everything, record the run (5 minutes)515524 BENCH='Edit|Frame' make stress # a subset; BENCHTIME=2s for steadier numbers516-make stress-report # latest against previous and baseline, with trends525+make stress-report # latest against previous, baseline and last release, with trends517526 make stress-e2e # key press to screen through libghostty518527 ```519528
@@ -531,5 +540,6 @@ overhead), `internal/fileio/stress_test.go` (imports and exports),
531540 `internal/chart/stress_test.go` (charts as text and images),532541 `internal/serve/stress_test.go` (concurrent SSH sessions) and533542 `e2e/stress_test.go`; the synthetic sheets are built by534-`internal/stress`. Results accumulate in `.deps/stress/results` and, with543+`internal/stress`, up to `dense-1Mx10`, ten million numbers, the544+default `max-cells`. Results accumulate in `.deps/stress/results` and, with535545 the observability stack up, in ClickHouse.
M docs/contributing/observability.md +46 -10
@@ -278,18 +278,49 @@ FROM read_json_auto('events.jsonl') WHERE msg = 'jev';
278278 `git_sha`, `git_dirty`, `go_version`, `goos`, `goarch`, `cpu`, `host`,279279 `pkg`, `name`, `procs`, `n`, `ns_op`, `b_op`, `allocs_op`, `mb_s`, and280280 `metrics`, a map of custom units such as `B/cell`, `cells/s`,281-`MB-file` or `us/answer`. `make stress-report` loads them with282-`scripts/stress/load.sql` and runs `scripts/stress/report.sql`: the283-latest run against the previous one on the same CPU and against a284-baseline (`BASELINE=run_id`, default the oldest run that covered most285-of the same benchmarks), flagging changes past `THRESHOLD` (0.10) and281+`MB-file` or `us/answer`. `COUNT=n` runs each benchmark n times, one282+record per sample. `make stress-report` loads them with283+`scripts/stress/load.sql` (a benchmark's time in a run is the median of284+its samples) and runs `scripts/stress/report.sql`: the latest run285+against the previous one on the same CPU and against a baseline286+(`BASELINE=run_id`, default the oldest run that covered most of the287+same benchmarks), flagging changes past `THRESHOLD` (0.10) and286288 `MIN_DELTA_NS` (2000), and the headline benchmarks over the last eight287289 runs. With the default 500 ms per benchmark, runs on a busy machine (say288290 with the Docker stack ingesting) differ by 10 to 30% on benchmarks of a289291 few tens of milliseconds; use `BENCHTIME=2s` on a quiet machine before290-trusting a single regression. `FAIL_ON_REGRESSION=1` makes it291-exit 1 on a regression, for CI. The same records load into ClickHouse292-(`stress.results`) with the same columns.292+trusting a single regression. `FAIL_ON_REGRESSION=1` also makes it exit293+1 on a regression against the previous run. The same records load into294+ClickHouse (`stress.results`) with the same columns; there, samples of295+one benchmark in one run collapse into one row.296+297+### Regressions against the last release298+299+`make stress-report` then compares the latest run with the last300+release's (`scripts/stress/release.sql`) and exits 1 when anything301+regressed, so a release checklist ([Releasing](releasing.md)) can stop302+on it:303+304+- **The release** is the newest `v*` tag reachable from HEAD whose305+ commit has a clean recorded run on the latest run's CPU and OS (other306+ than the latest run, and not on the latest run's commit). All such307+ runs of that commit are pooled. `RELEASE=v1.2.3` picks the tag,308+ `RELEASE_RUN=run_id` one run instead. With none recorded, the report309+ says so and exits 0: record one with `make stress` on the tagged310+ commit.311+- **Noise** is each benchmark's spread, `(max - min) / median` of its312+ samples, the larger of the release's and the latest run's. It is 0313+ for a benchmark with one sample on each side, so `COUNT=3` or more on314+ both runs gives the threshold room where a benchmark is noisy.315+- **A regression** is a median slower than the release's by more than316+ `THRESHOLD` plus the noise (10% plus, by default) and by more than317+ `MIN_DELTA_NS`. Faster past the same bar is listed as `faster`.318+319+```sh320+COUNT=3 BENCHTIME=2s make stress # on the tagged commit, then on the change321+make stress-report # exit 1 on a regression against the tag322+THRESHOLD=0.2 RELEASE=v0.2.0 make stress-report323+```293324 294325 ## The stack295326
@@ -376,8 +407,13 @@ The `012 runtime` dashboard shows recalculation time (p50, p95, max),
376407 frame and key-to-frame p95 per second, heap and cells, JEV answer time377408 and queue, imports and exports with throughput, the slowest operations,378409 and events per minute (all from the logs, so either way in works), and379-from OTLP the key-to-frame latency summary and the slowest spans with380-their trace ids. `012 stress runs` shows edit latency per410+from OTLP the key-to-frame latency summary, the slowest spans, the411+recent traces with nested spans (root span, command id, span count and412+which spans nest in it), and a trace view: the spans of one trace as a413+tree on a timeline, in Grafana's trace panel (the ClickHouse plugin's414+trace format). A trace id in either table links to it, through the415+dashboard's Trace id box; with the box empty the view shows the latest416+trace with nested spans. `012 stress runs` shows edit latency per381417 topology, frame and keystroke times, full recalc and file operations,382418 memory per cell (log scales where the spread is wide), the latest run383419 against the previous one, and the runs.
A docs/contributing/releasing.md +62 -0
@@ -0,0 +1,62 @@
1+---2+title: "Releasing"3+sidebar_position: 84+---5+6+# Releasing7+8+A release is an annotated version tag whose message is the release9+notes, and archives of the binaries built from it on a maintainer's10+machine. The GitHub repository only hosts the code: nothing builds or11+publishes there, and `make dist` uploads nothing.12+13+## Checklist14+15+1. **`make check`** passes on the commit to be tagged (see16+ [Testing](testing.md)), with the golden screens reviewed in the17+ gallery.18+2. **Stress** (optional for small releases): `make stress` on a quiet19+ machine, then `make stress-report`, which compares the run with the20+ last release's and exits non-zero on a regression past the threshold21+ (see [Observability](observability.md#regressions-against-the-last-release)).22+ Record the run on the release commit so the next release compares23+ against it.24+3. **XLSX in spreadsheet apps.** Export a workbook that uses what the25+ release touches (formats, notes, frozen panes, filters, validation,26+ conditional formats, charts' data) with File > Download, and open it in27+ Excel, LibreOffice Calc and Google Sheets: it opens without a repair28+ prompt, and values, formulas and formats read as they did in 012. The29+ automated checks read XLSX with excelize and 012's own reader only.30+4. **Tag** with the release notes as the message, what changed since the31+ previous tag for a user, with the install line last:32+33+ ```sh34+ git tag -a v1.2.3 # the editor opens for the notes35+ git push origin v1.2.336+ ```37+38+ The Go module proxy picks the tag up, so `go install39+ github.com/FelineStateMachine/012/cmd/012@v1.2.3` works from then on.40+5. **Binaries**: `make dist VERSION=v1.2.3` on the tagged commit.41+42+## make dist43+44+`make dist VERSION=v1.2.3` (`scripts/dist.sh`) cross-compiles 012 with45+`CGO_ENABLED=0` and `-trimpath` for macOS, Linux and Windows on amd6446+and arm64, stamping the version with `-ldflags -X main.version=...`, so47+`012 version` prints it. Each platform gets an archive in `dist/`48+(gitignored) holding the binary, `LICENSE`, `NOTICE` and `README.md`:49+50+```51+dist/012_1.2.3_darwin_arm64.tar.gz52+dist/012_1.2.3_linux_amd64.tar.gz53+dist/012_1.2.3_windows_amd64.zip54+...55+dist/SHA256SUMS56+```57+58+`PLATFORMS="darwin/arm64 linux/amd64"` narrows the list. The script59+refuses a version that isn't `vMAJOR.MINOR.PATCH` (with an optional60+`-suffix`) or that is tagged at another commit than the one checked61+out, and warns when the tree has uncommitted changes. The archives and62+`SHA256SUMS` are what a release attaches, wherever it is published.
M docs/contributing/ux.md +30 -2
@@ -58,8 +58,8 @@ follow-up task.
5858 at odd widths and under overlays; draw them with `theme.Fill`.5959 - Text on a colored background needs about 4.5:1 contrast against both6060 reference palettes (`e2e/palette_test.go`). Check it in the gallery.61-- Never rely on color alone: state also shows as text (mode indicator,62- `modified`, `#DIV/0!`, `Circular reference`).61+- Never rely on color alone: every state also shows as text, a glyph or62+ an attribute, listed in [Reading without color](#reading-without-color).6363 - Numbers right-aligned, text left, booleans and errors centered, one6464 column of padding, text overflowing into empty neighbors: as Sheets.6565 Headers centered. Menu bar titles separated by two spaces. Keys shown
@@ -77,6 +77,34 @@ follow-up task.
7777 - Render only what's visible. A keystroke on a 10,000-cell sheet must feel7878 instant.7979 80+## Reading without color81+82+Each state has a cue that survives a terminal without color, or a reader83+who can't tell the colors apart. `internal/ui/monochrome_test.go` draws84+each with `theme.Monochrome` (every role in one gray) and checks the cue85+is in the cell's text or attributes; a new state gets a cue and a case86+there.87+88+| State | Cue without color |89+|---|---|90+| The pointer (active cell), the selection, their row and column headers | Reverse video; the active cell's headers are also bold, and the name box names the cell or range |91+| Errors | The error's text (`#DIV/0!`) with a curly underline, explained on the context line |92+| Warnings | Text on the context line or status line (`Invalid: ...`, `Circular reference`) |93+| Entries failing validation | A dotted underline, and `Invalid:` with the rule on the context line |94+| Values an array spilled | Italic, and `Spilled from B2` on the context line |95+| Pivot table results | Their headings (`SUM of Units`, `Grand Total`), and a note in words when an edit is refused |96+| Protected ranges | A question in words before an edit (`A1:B2 is protected.`) |97+| Notes | A `▝` in the cell's top-right corner |98+| Macro recording | `REC` beside the mode indicator |99+| Mode | The mode indicator's word (`READY`, `ENTER`, `POINT`, `MENU`) |100+| Search matches, traced cells | Reverse video, with a count or the list on the context line |101+| The copied range | A dashed underline |102+| Dropdowns, checkboxes, active filters | `▾`; `[ ]` and `[✓]`; `▼` instead of `▾` |103+104+The reverse-video roles (`Theme.standouts`) keep their look in color:105+their colors are stored swapped, so SGR 7 swaps them back. Conditional106+formats and color scales are colors the user chose, and stay colors.107+80108 ## Required with every user-facing change81109 82110 - [ ] Registered command(s) with title and description; key binding in
M docs/files/README.md +3 -3
@@ -20,7 +20,7 @@ it back in its format.
2020 2121 | Format | Import | Download |2222 |---|---|---|23-| CSV, TSV | Delimiter (`,` `;` tab `\|`), UTF-8 BOM, UTF-16 and Windows-1252 detected; entries become numbers, dates, currency and percentages as if typed; formulas stay text | Values as shown, of the sheet shown, as Sheets' Download does; a selection of whole columns or rows downloads their data, not a million blank lines |23+| CSV, TSV | Delimiter (`,` `;` tab `\|`), UTF-8 BOM, UTF-16 and Windows-1252 detected; entries become numbers, dates, currency and percentages as if typed in the sheet's [locale](../sheets/locale.md) (`;` wins a tie there when it has a decimal comma), or with the other decimal separator when the file's numbers are written that way; formulas stay text | Values as shown in the locale, of the sheet shown, as Sheets' Download does, CSV with `;` between fields where the locale has a decimal comma; a selection of whole columns or rows downloads their data, not a million blank lines |2424 | Excel `.xlsx` | Every sheet, with formulas, formats and most of what a sheet holds: see [Excel files](excel.md) | The same |2525 | SQLite | Pick a table or view, or type a query; a header row names the columns | The sheet shown or the selection as a table, first row as column names; a table of that name is replaced |2626 | Parquet | Every column, with dates and timestamps; lists joined with commas | |
@@ -49,9 +49,9 @@ which kept their range, and undo brings the removed ones back.
4949 5050 **Size.** A sheet is 1,048,576 rows by 16,384 columns (A to XFD), as in5151 Excel. Imports keep at most `max-cells` cells ([Configuration](../reference/config.md#max-cells),52-two million by default, about 600 MB): whole rows, as many as fit, and52+ten million by default, a few hundred MB): whole rows, as many as fit, and5353 the context line says how many rows were left out, e.g. `only the first54-166,666 rows fit in max-cells (2,000,000 cells); 12,000 rows left out`.54+833,333 rows fit in max-cells (10,000,000 cells); 12,000 rows left out`.5555 Data past the grid's edges is left out the same way. WK1 files keep their5656 own 8,192 by 256. See [Bounds of support](../contributing/limits.md#imports) for speeds.5757
M docs/files/format.md +2 -1
M docs/formulas/README.md +5 -0
A docs/media/locale-de.png +0 -0

Binary file.

M docs/reference/config.md +19 -5
@@ -33,7 +33,8 @@ config-file = ?local.conf
3333 line (and in `012 config`).3434 3535 Settings are process-wide. Settings that belong to a workbook, such as36-decimal arithmetic, are saved in the workbook's `.012` file instead.36+decimal arithmetic and its locale, are saved in the workbook's `.012` file37+instead; `locale` here is the default for workbooks without one.3738 3839 **Precedence:** command-line flags win over environment variables, which3940 win over the config file, which wins over the defaults. `012 config` shows
@@ -71,7 +72,8 @@ start.
7172 | [`chart-images`](#chart-images) | `true` | `O12_CHART_IMAGES` |7273 | [`notifications`](#notifications) | `true` | `O12_NOTIFICATIONS` |7374 | [`keymap`](#keymap) | `default` | `O12_KEYMAP` |74-| [`max-cells`](#max-cells) | `2000000` | `O12_MAX_CELLS` |75+| [`locale`](#locale) | `en-US` | `O12_LOCALE` |76+| [`max-cells`](#max-cells) | `10000000` | `O12_MAX_CELLS` |7577 | [`jev-api-key-command`](#jev-api-key-command) | | |7678 | [`jev-credential-store`](#jev-credential-store) | `true` | `O12_JEV_CREDENTIAL_STORE` |7779 | [`jev-base-url`](#jev-base-url) | | `TYPESAFE_BASE_URL` |
@@ -90,7 +92,7 @@ start.
9092 9193 #### `theme`9294 93-Colors. `terminal` uses the terminal's own 16-color palette. Any other name is a terminal color scheme, built in (`012 config themes` lists them) or a file in the themes directory, drawn in its own colors with solid menu and status bars. `light:NAME,dark:NAME` picks one by the terminal's background and follows it when it changes.95+Colors. `terminal` uses the terminal's own 16-color palette. `high-contrast` draws white on black or black on white by the terminal's background, with WCAG AAA contrast. Any other name is a terminal color scheme, built in (`012 config themes` lists them) or a file in the themes directory, drawn in its own colors with solid menu and status bars. `light:NAME,dark:NAME` picks one by the terminal's background and follows it when it changes.9496 9597 | | |9698 |---|---|
@@ -135,14 +137,26 @@ Keys in the grid. `default` works like Google Sheets; `vim` adds hjkl, counts, o
135137 136138 ### Data137139 140+#### `locale`141+142+How new sheets and files without a locale of their own are typed and shown: decimal and thousands separators, date order, the currency symbol and the formula argument separator (`;` where the decimal separator is a comma), as in Sheets' File > Settings > Locale, which sets a file's own. Files store the same thing in every locale. When unset, the POSIX locale (`LC_ALL`, `LC_NUMERIC`, then `LANG`, e.g. `de_DE.UTF-8`) picks it if it's one of these.143+144+| | |145+|---|---|146+| Type | one of `en-US`, `en-GB`, `en-CA`, `en-AU`, `de-DE`, `de-CH`, `fr-FR`, `fr-CA`, `es-ES`, `es-MX`, `it-IT`, `pt-BR`, `pt-PT`, `nl-NL`, `sv-SE`, `da-DK`, `nb-NO`, `fi-FI`, `pl-PL`, `cs-CZ`, `ru-RU`, `tr-TR`, `ja-JP`, `zh-CN` |147+| Default | `en-US` |148+| Environment | `O12_LOCALE` |149+| When unset | `LC_ALL`, `LC_NUMERIC`, `LANG` |150+| Applies | File > Settings > Reload config |151+138152 #### `max-cells`139153 140-The most cells an import keeps, and a paste or fill writes at once. A sheet takes about 300 bytes a cell, so the default of two million is about 600 MB. Imports keep whole rows up to the budget and say how many they left out; larger pastes and fills are refused. The grid itself is 1,048,576 rows by 16,384 columns (A to XFD) whatever this is.154+The most cells an import keeps, and a paste or fill writes at once. Numbers and text take 20 to 60 bytes a cell and formulas about 750, so the default of ten million cells of data is 200 to 600 MB. Imports keep whole rows up to the budget and say how many they left out; larger pastes and fills are refused. The grid itself is 1,048,576 rows by 16,384 columns (A to XFD) whatever this is.141155 142156 | | |143157 |---|---|144158 | Type | number |145-| Default | `2000000` |159+| Default | `10000000` |146160 | Environment | `O12_MAX_CELLS` |147161 | Applies | File > Settings > Reload config |148162
M docs/reference/macro-api.md +2 -2
@@ -62,8 +62,8 @@ is already in the formulas that use it.
6262 | `move(cols=0, rows=0)` | Move the active cell, dropping the selection |6363 | `extend(cols=0, rows=0, whole=None)` | Select from the active cell to `cols`, `rows` away; `whole="columns"` or `"rows"` selects whole ones |6464 | `jump(to, extend=False)` | Move as keys do: `"up"`, `"down"`, `"left"`, `"right"` (Ctrl+arrows, to the edge of the data), `"home"` (column A), `"start"` (A1), `"end"` (the last used cell) |65-| `enter(text, fill=False, origin=None)` | Type into the active cell and accept, as Enter does without moving; `fill=True` fills every selected cell (Ctrl+Enter). With `origin`, a formula is taken as typed at that cell and its relative references move with the distance to the active cell |66-| `paste_text(text)` | Paste tab-separated text from the active cell, as pasting from another program |65+| `enter(text, fill=False, origin=None)` | Type into the active cell and accept, as Enter does without moving; `fill=True` fills every selected cell (Ctrl+Enter). With `origin`, a formula is taken as typed at that cell and its relative references move with the distance to the active cell. `text` is in en-US's form (`1.5`, `9/26/2026`, `=ROUND(A1, 2)`) whatever the file's [locale](../sheets/locale.md), as a recording writes it, so a macro does the same everywhere |66+| `paste_text(text)` | Paste tab-separated text from the active cell, as pasting from another program: read in the file's [locale](../sheets/locale.md) |6767 | `fill(to=None, rows=0, cols=0)` | Drag the fill handle: continue the selection's series or copy it over `to`, or `rows` down (up if negative), or `cols` right (left if negative) |6868 6969 ## Sheets
M docs/sheets/README.md +1 -0
M docs/sheets/editing.md +3 -2
A docs/sheets/locale.md +95 -0
@@ -0,0 +1,95 @@
1+---2+title: "Locale"3+sidebar_position: 124+---5+6+# Locale7+8+A file's locale says how its numbers, dates, currency and formulas are9+typed and shown, as File > Settings > Locale does in Google Sheets. In10+German (Germany) a sheet shows `1.234,56 €` and `26.9.2026`, takes11+`1,5` as a number, and formulas separate their arguments with `;`:12+`=ROUND(A1*1,19; 2)`.13+14+![A sheet in German (Germany)](../media/locale-de.png)15+16+## Choosing one17+18+File > Settings > Locale lists the locales with a sample of each; type19+a language, country or tag to narrow it, Enter picks. The first entry,20+Default, follows the [`locale` setting](../reference/config.md#locale),21+which new sheets use and which reads `LC_ALL`, `LC_NUMERIC` or `LANG`22+(`de_DE.UTF-8`) when nothing sets it. A locale picked here is saved in23+the file, marks it modified and undoes with Ctrl+Z.24+25+| Tag | Locale | Number | Date | Currency |26+|---|---|---|---|---|27+| `en-US` | English (United States) | 1,234.56 | 9/26/2026 | $1,234.56 |28+| `en-GB` | English (United Kingdom) | 1,234.56 | 26/9/2026 | £1,234.56 |29+| `en-CA` | English (Canada) | 1,234.56 | 2026-09-26 | $1,234.56 |30+| `en-AU` | English (Australia) | 1,234.56 | 26/9/2026 | $1,234.56 |31+| `de-DE` | German (Germany) | 1.234,56 | 26.9.2026 | 1.234,56 € |32+| `de-CH` | German (Switzerland) | 1’234.56 | 26.9.2026 | CHF 1’234.56 |33+| `fr-FR` | French (France) | 1 234,56 | 26/9/2026 | 1 234,56 € |34+| `fr-CA` | French (Canada) | 1 234,56 | 2026-09-26 | 1 234,56 $ |35+| `es-ES` | Spanish (Spain) | 1.234,56 | 26/9/2026 | 1.234,56 € |36+| `es-MX` | Spanish (Mexico) | 1,234.56 | 26/9/2026 | $1,234.56 |37+| `it-IT` | Italian (Italy) | 1.234,56 | 26/9/2026 | 1.234,56 € |38+| `pt-BR` | Portuguese (Brazil) | 1.234,56 | 26/9/2026 | R$ 1.234,56 |39+| `pt-PT` | Portuguese (Portugal) | 1 234,56 | 26/9/2026 | 1 234,56 € |40+| `nl-NL` | Dutch (Netherlands) | 1.234,56 | 26-9-2026 | € 1.234,56 |41+| `sv-SE` | Swedish (Sweden) | 1 234,56 | 2026-09-26 | 1 234,56 kr |42+| `da-DK` | Danish (Denmark) | 1.234,56 | 26.9.2026 | 1.234,56 kr. |43+| `nb-NO` | Norwegian (Norway) | 1 234,56 | 26.9.2026 | kr 1 234,56 |44+| `fi-FI` | Finnish (Finland) | 1 234,56 | 26.9.2026 | 1 234,56 € |45+| `pl-PL` | Polish (Poland) | 1 234,56 | 26.9.2026 | 1 234,56 zł |46+| `cs-CZ` | Czech (Czechia) | 1 234,56 | 26.9.2026 | 1 234,56 Kč |47+| `ru-RU` | Russian (Russia) | 1 234,56 | 26.9.2026 | 1 234,56 ₽ |48+| `tr-TR` | Turkish (Türkiye) | 1.234,56 | 26.9.2026 | ₺1.234,56 |49+| `ja-JP` | Japanese (Japan) | 1,234.56 | 2026/9/26 | ¥1,234.56 |50+| `zh-CN` | Chinese (China) | 1,234.56 | 2026/9/26 | ¥1,234.56 |51+52+Dates show day and month without leading zeros, as en-US's do, so most53+fit a default column; ISO dates keep theirs. Spaces in numbers are54+no-break spaces, so a number never breaks across lines; when typing55+one, an ordinary space does.56+57+## What follows the locale58+59+- **Typing.** Numbers, percentages, currency and dates are read the60+ locale's way: `1.234,5`, `12,5 %`, `12,50 €` and `26.09.2026` in61+ German. A date of numbers follows the locale's order (day first in62+ most of Europe, year first in Japan and Sweden); `2026-09-26`, month63+ names in English (`26 Sep 2026`) and times (`14:30`) read the same64+ everywhere. What isn't a value in the locale stays text: `$5` is text65+ in German, `9/26/2026` in British English.66+- **Showing.** Automatic and every number format use the locale's67+ decimal and thousands separators. Currency, Date, Time and Date time68+ (Format > Number) show the locale's symbol and order; a69+ [custom format](formatting.md) keeps its own symbols and order, only70+ its separators follow the locale, as in Sheets.71+- **Formulas.** Where the decimal separator is a comma, arguments are72+ separated by `;` and the values of an array's row by `\`: `=IF(A1>1,5;73+ "yes"; "no")`, `={1\2;3\4}`. Function names stay in English, as in74+ Sheets. The formula bar, editing a cell and the function hints75+ (`ROUND(value; [places])`) all use the locale's form, as do formulas76+ in [conditional formatting, data validation](rules.md) and filter77+ conditions.78+- **The clipboard and CSV files.** Copying writes numbers with the79+ locale's decimal separator and pasting reads text the locale's way.80+ CSV files are [read and written](../files/README.md) in the locale81+ too.82+- **The status line.** Sum and Avg of the selection.83+84+## What doesn't85+86+The file stores everything the same way in every locale, so a sheet87+opens with the same values wherever it's opened: entries as typed in88+en-US (`1,234.5`, `09/26/2026`, `=ROUND(A1*1.19, 2)`; see89+[the file format](../files/format.md)). Switching the locale redraws90+the sheet and changes no value. Functions compute the same everywhere91+too: `VALUE("1,5")` and `TEXT(A1, "0.00")` read and write en-US's form92+in any locale, and macros and their [API](../reference/macro-api.md)93+set and read entries in en-US's form. [Excel files](../files/excel.md)94+are written with en-US's Currency (`$`) and Date (`m/d/yyyy`) formats95+whatever the locale, so their formats read back as the same formats.
M docs/sheets/rules.md +18 -1
@@ -53,6 +53,16 @@ results. They follow inserted and deleted rows and columns, and a rule
5353 whose cells are all deleted goes. Format > Clear conditional formats5454 takes the selection out of every rule.5555 56+Rules, conditional formats and validation alike, move with cut and57+paste on a sheet, as in Sheets: the cut cells take their rules to where58+they land, and the cells they land on lose theirs. A rule whose cells59+partly move keeps both parts; with a custom formula it becomes two60+rules, each with the formula written for its own first cell, so the61+cells that stayed read what they read and the moved ones read the cells62+beside their new places. Formulas in rules follow cells that63+move. Cut on one sheet and pasted on another, the cells leave their64+rules behind and meet the rules where they land.65+5666 ## Data validation5767 5868 Data > Data validation opens the sheet's validation rules in the same
@@ -87,7 +97,14 @@ are always valid.
8797 8898 Each cell has one rule: a rule added over cells that had another takes8999 them from it. Validation checks what's typed, picked from a dropdown and90-entered by macros; pastes, fills and imports aren't refused, and what100+entered by macros. Pastes (Ctrl+V, paste values, text pasted from101+another program) and fills (Ctrl+D, Ctrl+R, Ctrl+Enter, the fill102+handle) are checked after they're written, as in Sheets: when a cell's103+rule rejects what landed there, the whole paste or fill is undone and104+ERROR mode says which cell and why ("Paste undone: Invalid entry in105+B2: ..."; Ctrl+Enter keeps the entry open instead); when only rules that106+warn fail, it stays, the cells are marked, and the context line says how107+many ("2 cells invalid, first B2: ..."). Imports aren't checked, and what91108 they leave invalid is marked. Validation can't go on a pivot table's92109 results, which take no entries. Data > Remove data validation takes the93110 selection out of every rule.
M docs/terminal/README.md +0 -2
M docs/terminal/ssh.md +5 -5
M docs/terminal/themes.md +23 -4
@@ -25,6 +25,9 @@ theme = light:Catppuccin Latte,dark:Catppuccin Mocha # follow the terminal
2525 background, and draws the menu bar, the column headers and the status2626 line as solid bands, in the manner of 1-2-3 and classic terminal2727 programs. The terminal's own palette is never changed.28+- **`high-contrast`** is white on black on a dark terminal and black on29+ white on a light one, switching when the terminal does, with every30+ role at WCAG AAA contrast (see [High contrast](#high-contrast)).2831 - **`light:A,dark:B`** picks A on a light terminal and B on a dark one,2932 and switches when the terminal does (for example with the system's3033 appearance, in terminals that report it).
@@ -49,6 +52,21 @@ takes, plus **1-2-3 Classic**: CGA colors on a blue screen. Names match
4952 without regard to case, and then without regard to spaces, hyphens and5053 underscores, so `tokyo-night` finds `TokyoNight`.5154 55+## High contrast56+57+`theme = high-contrast` draws with two built-in schemes, **High Contrast58+Dark** and **High Contrast Light**, picked by the terminal's background;59+name one of them (`theme = high-contrast-light`) to keep it. They hold60+every role to WCAG AAA rather than AA: 7:1 for all text, hints and61+muted text included, and 4.5:1 for lines, borders, chart axes and62+unavailable menu items. The pointer is black on cyan, the selection63+white on blue, errors a light red on black (a dark red on white).64+65+States never rest on color alone in any theme: the pointer and the66+selection are in reverse video, errors have a curly underline, invalid67+entries a dotted one, spilled values are italic, and so on68+([Reading without color](../contributing/ux.md#reading-without-color)).69+5270 ## Your own schemes5371 5472 Put a file in the `themes` directory next to the config file
@@ -91,13 +109,14 @@ well:
91109 text color; the column header row is filled with the header color;92110 - a background role too close to the screen's background is moved apart;93111 - text below its contrast minimum against its background (WCAG 4.5:1 for94- text, 3:1 for hints, lines and chart axes, 2:1 for unavailable items)95- is moved toward the scheme's text color, or toward black or white, just96- far enough to reach it.112+ text, 3:1 for hints, lines and chart axes, 2:1 for unavailable items;113+ the high-contrast schemes' AAA minimums above) is moved toward the114+ scheme's text color, or toward black or white, just far enough to115+ reach it.97116 98117 A test runs every built-in scheme through this and checks those minimums99118 for cell text, text on the bars, the selection, the headers and every100-other role (`TestEveryThemeReadable`). Charts, as text and as kitty119+other role (`TestEveryThemeReadable`, and `TestHighContrast` for AAA). Charts, as text and as kitty101120 images, use the scheme's colors too.102121 103122 Conditional formats ([Conditional formatting and data validation](../sheets/rules.md#conditional-formatting)) name
M e2e/config_test.go +2 -2
M e2e/harness_test.go +2 -2
A e2e/locale_test.go +79 -0
@@ -0,0 +1,79 @@
1+package e2e2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+)9+10+// germanBudget types a small budget in English (United States), then11+// picks German (Germany) in File > Settings > Locale from the palette.12+func germanBudget(s *session) {13+ s.keys("Item", "<tab>", "Amount", "<tab>", "Due", "<enter>")14+ s.keys("Rent", "<tab>", "$950.00", "<tab>", "9/26/2026", "<enter>")15+ s.keys("Power", "<tab>", "$84.20", "<tab>", "10/3/2026", "<enter>")16+ s.keys("Total", "<tab>", "=SUM(B2:B3)", "<enter>")17+ s.keys("VAT", "<tab>", "=ROUND(B4*0.19, 2)", "<enter>")18+ s.keys("<ctrl+k>", "locale", "<enter>")19+ s.waitFor("Type a language, country or tag")20+ s.keys("germany", "<enter>")21+ s.waitFor("Locale German (Germany)")22+}23+24+// Switching the locale redraws the sheet German, takes entries and25+// formulas typed the German way, saves them as en-US writes them, and26+// undoes back to en-US.27+func TestLocaleSwitch(t *testing.T) {28+ dir := t.TempDir()29+ s := start(t, dir)30+ germanBudget(s)31+ s.waitFor("1.034,20")32+ s.waitFor("26.9.2026")33+ s.keys("<up>", "<right>")34+ s.waitForBar("B5", "=ROUND(B4*0,19; 2)")35+36+ s.keys("<down>", "<left>", "Rate", "<tab>", "12,5 %", "<enter>")37+ s.keys("Share", "<tab>", "=ROUND(B5/B4; ")38+ s.waitFor("ROUND(value; [places])")39+ s.keys("3)", "<enter>")40+ s.keys("<up>", "<right>")41+ s.waitForBar("B7", "=ROUND(B5/B4; 3)")42+ s.waitFor("0,19")43+44+ s.keys("<ctrl+s>", "budget", "<enter>")45+ s.eventually("saved", func() bool { return s.title() == "012 - budget.012" })46+ data, err := os.ReadFile(filepath.Join(dir, "budget.012"))47+ if err != nil {48+ t.Fatal(err)49+ }50+ for _, want := range []string{`"locale": "de-DE"`, `"=ROUND(B4*0.19, 2)"`, `"12.5%"`, `"=ROUND(B5/B4, 3)"`, `"9/26/2026"`} {51+ if !strings.Contains(string(data), want) {52+ t.Errorf("the file lacks %s:\n%s", want, data)53+ }54+ }55+56+ // Undo the entries and the locale: the sheet is en-US's again.57+ s.keys("<ctrl+z>", "<ctrl+z>", "<ctrl+z>", "<ctrl+z>", "<ctrl+z>")58+ s.waitFor("$1,034.20")59+ s.waitFor("9/26/2026")60+}61+62+// The config's locale, here from LANG, is the default of new sheets;63+// a file saved without a locale of its own opens in the reader's.64+func TestLocaleFromLANG(t *testing.T) {65+ dir := t.TempDir()66+ s := startWith(t, options{dir: dir, env: []string{"LANG=de_DE.UTF-8"}})67+ s.keys("1,5", "<enter>", "=A1*2", "<enter>")68+ s.keys("<up>")69+ s.waitForBar("A2", "=A1*2")70+ s.keys("<up>")71+ s.waitForBar("A1", "1,5")72+ s.keys("<ctrl+s>", "half", "<enter>")73+ s.eventually("saved", func() bool { return s.title() == "012 - half.012" })74+ s.keys("<ctrl+q>")75+ s.waitExit()76+77+ r := start(t, dir, "half.012")78+ r.waitForBar("A1", "1.5")79+}
M e2e/screenfiles_test.go +1 -1
M e2e/screenlist_test.go +1 -1
A e2e/screenlocale_test.go +33 -0
A e2e/screenthemes_test.go +25 -0
A e2e/testdata/screens/locale-de-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B5</span><span style="background:var(--vt-palette-7)"> </span> =ROUND(B4*0,19; 2)3+4+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A</span><span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</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)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span> Item Amount Due6+<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 950,00 € 26.9.20267+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span> Power 84,20 € 3.10.20268+<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> Total 1.034,20 €9+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">5</span><span style="background:var(--vt-palette-6)"> </span> VAT <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">196,50</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">€</span><span style="background:var(--vt-palette-6)"> </span>10+<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>11+<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>12+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-0)">modified</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+K</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">search</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+/</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">shortcuts</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/locale-de-signature-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">ENTER</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B6</span><span style="background:var(--vt-palette-7)"> </span> =ROUND(B5/B4;3+<span style="font-weight:bold">ROUND</span>(value; <span style="font-weight:bold;text-decoration:underline">[places]</span>) <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">accept</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">cancel</span>4+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A</span><span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</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)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span> Item Amount Due6+<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 950,00 € 26.9.20267+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span> Power 84,20 € 3.10.20268+<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> Total 1.034,20 €9+<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> VAT 196,50 €10+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">6</span><span style="background:var(--vt-palette-6)"> </span> Rate <span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">…D(B5/B4;</span><span style="background:var(--vt-palette-6)"> </span>11+<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>12+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-0)">modified</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+K</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">search</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+/</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">shortcuts</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/locale-de-signature.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">ENTER</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B6</span><span style="background:var(--vt-palette-8)"> </span> =ROUND(B5/B4;3+<span style="font-weight:bold">ROUND</span>(value; <span style="font-weight:bold;text-decoration:underline">[places]</span>) <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">accept</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">cancel</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</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)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> Item Amount Due6+<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 950,00 € 26.9.20267+<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> Power 84,20 € 3.10.20268+<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> Total 1.034,20 €9+<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> VAT 196,50 €10+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">6</span><span style="background:var(--vt-palette-6)"> </span> Rate <span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">…D(B5/B4;</span><span style="background:var(--vt-palette-6)"> </span>11+<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>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/locale-de.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B5</span><span style="background:var(--vt-palette-8)"> </span> =ROUND(B4*0,19; 2)3+4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</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)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> Item Amount Due6+<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 950,00 € 26.9.20267+<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> Power 84,20 € 3.10.20268+<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> Total 1.034,20 €9+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">5</span><span style="background:var(--vt-palette-6)"> </span> VAT <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">196,50</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">€</span><span style="background:var(--vt-palette-6)"> </span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span>11+<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>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/locale-picker-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A1</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Locale</span> <span style="color:var(--vt-palette-8)">───────────────────────────────────────────────────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> de <span style="color:var(--vt-palette-8)">│</span>4+<span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">├────────────────────────────────────────────────────────────────────────────────┤</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">German</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">(Germany)</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">de-DE</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">1.234,56</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">26.9.2026</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">=ROUND(1,5;</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">1)</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> German (Switzerland) <span style="color:var(--vt-palette-0)">de-CH</span> <span style="color:var(--vt-palette-0)">1’234.56</span> <span style="color:var(--vt-palette-0)">26.9.2026</span> <span style="color:var(--vt-palette-0)">=ROUND(1.5,</span> <span style="color:var(--vt-palette-0)">1)</span> <span style="color:var(--vt-palette-8)">│</span>7+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">2</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">25</span> <span style="color:var(--vt-palette-8)">─┘</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>9+<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>10+<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>11+<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>12+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30+Numbers 1.234,56 €, dates 26.9.2026, formulas =ROUND(1,5… <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Up/Down</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">move</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">set</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">close</span></pre>\ No newline at end of file
A e2e/testdata/screens/locale-picker.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A1</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Locale</span> <span style="color:var(--vt-palette-8)">───────────────────────────────────────────────────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> de <span style="color:var(--vt-palette-8)">│</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">├────────────────────────────────────────────────────────────────────────────────┤</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">German</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">(Germany)</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">de-DE</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">1.234,56</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">26.9.2026</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">=ROUND(1,5;</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">1)</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> German (Switzerland) <span style="color:var(--vt-palette-8)">de-CH</span> <span style="color:var(--vt-palette-8)">1’234.56</span> <span style="color:var(--vt-palette-8)">26.9.2026</span> <span style="color:var(--vt-palette-8)">=ROUND(1.5,</span> <span style="color:var(--vt-palette-8)">1)</span> <span style="color:var(--vt-palette-8)">│</span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">2</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">25</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>9+<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>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span>11+<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>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+Numbers 1.234,56 €, dates 26.9.2026, formulas =ROUND(1,5… <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">set</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/menu-settings.html +9 -9
@@ -9,15 +9,15 @@
99 <span style="color:var(--vt-palette-8)">│</span> Download › <span style="color:var(--vt-palette-8)">│</span>1010 <span style="color:var(--vt-palette-8)">├─────────────────────┤┌────────────────────────┐</span>1111 <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Settings</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">›</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">││</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Decimal</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">arithmetic</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">✓</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>12-<span style="color:var(--vt-palette-8)">├─────────────────────┤│</span> Vim keys <span style="color:var(--vt-palette-8)">│</span>13-<span style="color:var(--vt-palette-8)">│</span> Quit <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Q</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│├────────────────────────┤</span>14-<span style="color:var(--vt-palette-8)">└─────────────────────┘│</span> Theme <span style="color:var(--vt-palette-8)">│</span>15-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> JEV API key <span style="color:var(--vt-palette-8)">│</span>16-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├────────────────────────┤</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Open config file <span style="color:var(--vt-palette-8)">│</span>18-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Reload config <span style="color:var(--vt-palette-8)">│</span>19-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────┘</span>20-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="color:var(--vt-palette-8)">├─────────────────────┤│</span> Locale <span style="color:var(--vt-palette-8)">│</span>13+<span style="color:var(--vt-palette-8)">│</span> Quit <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Q</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">││</span> Vim keys <span style="color:var(--vt-palette-8)">│</span>14+<span style="color:var(--vt-palette-8)">└─────────────────────┘├────────────────────────┤</span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Theme <span style="color:var(--vt-palette-8)">│</span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> JEV API key <span style="color:var(--vt-palette-8)">│</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├────────────────────────┤</span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Open config file <span style="color:var(--vt-palette-8)">│</span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Reload config <span style="color:var(--vt-palette-8)">│</span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────┘</span>2121 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>2222 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>2323 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>
M e2e/testdata/screens/palette-narrow.html +1 -1
@@ -12,5 +12,5 @@
1212 <span style="color:var(--vt-palette-8)">│</span> Sort <span style="color:var(--vt-palette-3);font-weight:bold">s</span>heet Z to <span style="color:var(--vt-palette-3);font-weight:bold">A</span> <span style="color:var(--vt-palette-8)">│</span>1313 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Ask</span> <span style="color:var(--vt-palette-8)">JEV</span> <span style="color:var(--vt-palette-8)">again</span> <span style="color:var(--vt-palette-8)">│</span>1414 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>ort r<span style="color:var(--vt-palette-3);font-weight:bold">a</span>nge <span style="color:var(--vt-palette-8)">│</span>15-<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">40</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">162</span> <span style="color:var(--vt-palette-8)">─┘</span>15+<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">32</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">143</span> <span style="color:var(--vt-palette-8)">─┘</span>1616 Save the sheet u… <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/palette-search-light.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns right <span style="color:var(--vt-palette-0)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete rows or <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">Edit</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Alt+-</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert rows or <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">Insert</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Alt+=</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">24</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">162</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">23</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">143</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>1919 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>2020 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>
M e2e/testdata/screens/palette-search.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns right <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete rows or <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">Edit</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+-</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert rows or <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">Insert</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+=</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">24</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">162</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">23</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">143</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>
M e2e/testdata/screens/palette-wide.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt rows be<span style="color:var(--vt-palette-3);font-weight:bold">l</span>ow <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Re<span style="color:var(--vt-palette-3);font-weight:bold">se</span>t co<span style="color:var(--vt-palette-3);font-weight:bold">l</span>umn width <span style="color:var(--vt-palette-8)">Format</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt co<span style="color:var(--vt-palette-3);font-weight:bold">l</span>umns right <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">22</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">162</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">22</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">143</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>
M e2e/testdata/screens/palette.html +3 -3
@@ -12,9 +12,9 @@
1212 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Download as XLSX <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1313 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Download as SQLite <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Decimal arithmetic <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Settings</span> <span style="color:var(--vt-palette-8)">│</span>15-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Vim keys <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Settings</span> <span style="color:var(--vt-palette-8)">│</span>16-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Theme <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Settings</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">162</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">162</span> <span style="color:var(--vt-palette-8)">─┘</span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Locale <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Settings</span> <span style="color:var(--vt-palette-8)">│</span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Vim keys <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Settings</span> <span style="color:var(--vt-palette-8)">│</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">143</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">143</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>
M e2e/testdata/screens/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 e2e/vim_test.go +1 -1
M internal/config/config.go +15 -0
M internal/config/config_test.go +27 -0
M internal/config/docs.go +3 -0
M internal/config/registry.go +28 -9
@@ -6,7 +6,8 @@
66 // from it.77 //88 // Settings are process-wide. Settings that belong to a workbook (decimal9-// arithmetic) stay in the workbook's file. Secrets never go here: the JEV9+// arithmetic, its locale) stay in the workbook's file; the locale option is10+// only the default. Secrets never go here: the JEV1011 // API key lives in the OS credential store (see internal/keyring).1112 package config1213
@@ -18,6 +19,8 @@ import (
1819 "strconv"1920 "strings"2021 "time"22+23+ "github.com/FelineStateMachine/012/internal/locale"2124 )2225 2326 // Kind is an option's type.
@@ -47,10 +50,16 @@ type Option struct {
4750 Default string // as it would be written in the file4851 Values []string // for Enum4952 Env []string // environment variables that set it, first set wins50- Flag string // command-line flag, e.g. "--log"51- Repeat bool // may be given more than once; each adds a value52- Live bool // Reload config applies it without restarting53- Desc string // what it does, one or more sentences53+ // Fallback are environment variables read, first set wins, when54+ // nothing else sets the option, through Adopt, which turns their55+ // value into one of the option's or "" to pass over it quietly (LANG56+ // may name a locale 012 lacks).57+ Fallback []string58+ Adopt func(string) string59+ Flag string // command-line flag, e.g. "--log"60+ Repeat bool // may be given more than once; each adds a value61+ Live bool // Reload config applies it without restarting62+ Desc string // what it does, one or more sentences5463 // Check validates a value beyond its kind. Invalid values are5564 // reported as warnings and the option keeps its previous value.5665 Check func(string) error
@@ -77,7 +86,8 @@ var Groups = []string{GroupAppearance, GroupData, GroupJEV, GroupTelemetry, Grou
7786 // (go test ./internal/config -update-docs) and Settings follow.7887 var Options = []Option{7988 {Name: "theme", Group: GroupAppearance, Default: "terminal", Env: []string{"O12_THEME"}, Flag: "--theme", Live: true,80- Desc: "Colors. `terminal` uses the terminal's own 16-color palette. Any other name is a " +89+ Desc: "Colors. `terminal` uses the terminal's own 16-color palette. `high-contrast` draws white on " +90+ "black or black on white by the terminal's background, with WCAG AAA contrast. Any other name is a " +8191 "terminal color scheme, built in (`012 config themes` lists them) or a file in the themes " +8292 "directory, drawn in its own colors with solid menu and status bars. " +8393 "`light:NAME,dark:NAME` picks one by the terminal's background and follows it when it changes.",
@@ -92,9 +102,18 @@ var Options = []Option{
92102 Desc: "Keys in the grid. `default` works like Google Sheets; `vim` adds hjkl, counts, operators, " +93103 "visual selection and a : command line (File > Settings > Vim keys)."},94104 95- {Name: "max-cells", Kind: Int, Group: GroupData, Default: "2000000", Env: []string{"O12_MAX_CELLS"}, Live: true,96- Desc: "The most cells an import keeps, and a paste or fill writes at once. A sheet takes about " +97- "300 bytes a cell, so the default of two million is about 600 MB. Imports keep whole rows " +105+ {Name: "locale", Kind: Enum, Group: GroupData, Default: "en-US", Values: locale.Tags(), Env: []string{"O12_LOCALE"},106+ Fallback: []string{"LC_ALL", "LC_NUMERIC", "LANG"}, Adopt: locale.FromPOSIX, Live: true,107+ Desc: "How new sheets and files without a locale of their own are typed and shown: decimal and " +108+ "thousands separators, date order, the currency symbol and the formula argument separator " +109+ "(`;` where the decimal separator is a comma), as in Sheets' File > Settings > Locale, " +110+ "which sets a file's own. Files store the same thing in every locale. " +111+ "When unset, the POSIX locale (`LC_ALL`, `LC_NUMERIC`, then `LANG`, e.g. `de_DE.UTF-8`) picks it if it's one of these."},112+113+ {Name: "max-cells", Kind: Int, Group: GroupData, Default: "10000000", Env: []string{"O12_MAX_CELLS"}, Live: true,114+ Desc: "The most cells an import keeps, and a paste or fill writes at once. Numbers and text " +115+ "take 20 to 60 bytes a cell and formulas about 750, so the default of ten million cells of " +116+ "data is 200 to 600 MB. Imports keep whole rows " +98117 "up to the budget and say how many they left out; larger pastes and fills are refused. " +99118 "The grid itself is 1,048,576 rows by 16,384 columns (A to XFD) whatever this is."},100119
M internal/fileio/builder.go +14 -9
@@ -7,6 +7,7 @@ import (
77 "strconv"88 "strings"99 10+ "github.com/FelineStateMachine/012/internal/locale"1011 "github.com/FelineStateMachine/012/internal/sheet"1112 "github.com/FelineStateMachine/012/internal/telemetry"1213 )
@@ -17,7 +18,8 @@ import (
1718 // back to a formula's cached value when the formula can't be translated,1819 // and counts what didn't fit so the notes can say.1920 type builder struct {20- s *sheet.Sheet21+ s *sheet.Sheet22+ loc *locale.Locale // what entries are typed in (nil for en-US)2123 2224 rowsOut, colsOut int // how far past the sheet's edges the data went2325 values int // formulas kept as their values
@@ -129,16 +131,19 @@ func textInput(s string) string {
129131 return s130132 }131133 132-// entryInput is s as if typed into Sheets, for text formats: numbers,133-// dates, currency and percentages become values with formats. Formulas134-// stay text: a data file shouldn't run formulas (or ask JEV questions)135-// just by being opened.136-func entryInput(s string) string {134+// entryInput is s as if typed into Sheets in loc, for text formats:135+// numbers, dates, currency and percentages become values with formats.136+// Formulas stay text: a data file shouldn't run formulas (or ask JEV137+// questions) just by being opened.138+func entryInput(s string, loc *locale.Locale) string {137139 s = flatten(s)138140 if strings.HasPrefix(s, "'") || sheet.IsFormulaEntry(s) {139141 return "'" + s140142 }141- return s143+ if in := sheet.CanonicalEntry(s, loc); !sheet.IsFormulaEntry(in) {144+ return in145+ }146+ return "'" + s // a formula in loc's syntax142147 }143148 144149 // numInput writes v so it parses back to the same number.
@@ -164,9 +169,9 @@ func (b *builder) text(a sheet.Addr, s string, f sheet.Format, st sheet.Style) {
164169 b.put(a, textInput(s), f, st)165170 }166171 167-// entry stores s as if typed (see entryInput).172+// entry stores s as if typed in the builder's locale (see entryInput).168173 func (b *builder) entry(a sheet.Addr, s string) {169- b.put(a, entryInput(s), sheet.Format{}, sheet.Style{})174+ b.put(a, entryInput(s, b.loc), sheet.Format{}, sheet.Style{})170175 }171176 172177 // number stores v with format f. Plain text formats keep the digits.
M internal/fileio/csv.go +31 -9
@@ -14,6 +14,7 @@ import (
1414 "golang.org/x/text/encoding/charmap"1515 "golang.org/x/text/encoding/unicode"1616 17+ "github.com/FelineStateMachine/012/internal/locale"1718 "github.com/FelineStateMachine/012/internal/sheet"1819 )1920
@@ -32,10 +33,13 @@ type Dialect struct {
3233 3334 // sniff picks the delimiter that splits the sample's records into the3435 // same number of fields most consistently, preferring more fields.35-// Quoted fields may contain delimiters and line breaks.36-func sniff(sample []byte, truncated bool) rune {37- best, bestScore := ',', 0.038- for _, d := range delimiters {36+// Quoted fields may contain delimiters and line breaks. Ties go to37+// first, the locale's own separator (see csvComma), then to delimiters'38+// order.39+func sniff(sample []byte, truncated bool, first rune) rune {40+ best, bestScore := first, 0.041+ order := append([]rune{first}, delimiters...)42+ for _, d := range order {3943 counts := fieldCounts(sample, byte(d), truncated)4044 if len(counts) == 0 {4145 continue
@@ -54,6 +58,11 @@ func sniff(sample []byte, truncated bool) rune {
5458 continue5559 }5660 score := float64(freq[mode]) / float64(len(counts)) * (1 + float64(min(mode, 20))/100)61+ if d == first && first != ',' {62+ // 1,5;2,5 splits evenly on both in a locale with decimal63+ // commas; its own separator wins.64+ score *= 1.2565+ }5766 if score > bestScore {5867 best, bestScore = d, score5968 }
@@ -166,7 +175,7 @@ func importDelimited(ctx context.Context, name string, k Kind, opt Options) (*Re
166175 size = st.Size()167176 }168177 counter := &countingReader{r: f}169- s, rows, notes, err := readDelimited(ctx, counter, k, opt.MaxCells, func(rows int) {178+ s, rows, notes, err := readDelimited(ctx, counter, k, opt.MaxCells, opt.Locale, func(rows int) {170179 prog.setRows(rows)171180 prog.setFrac(counter.n, size)172181 })
@@ -178,8 +187,10 @@ func importDelimited(ctx context.Context, name string, k Kind, opt Options) (*Re
178187 179188 // readDelimited reads CSV or TSV text, sniffing the delimiter of CSV,180189 // keeping up to maxCells cells (see newBuilder). Each field is entered as181-// if typed. progress is called every few hundred rows.182-func readDelimited(ctx context.Context, in io.Reader, k Kind, maxCells int, progress func(rows int)) (*sheet.Sheet, int, []string, error) {190+// if typed in loc, or with the other decimal separator when the file191+// writes its numbers that way (see numberLocale). progress is called192+// every few hundred rows.193+func readDelimited(ctx context.Context, in io.Reader, k Kind, maxCells int, loc *locale.Locale, progress func(rows int)) (*sheet.Sheet, int, []string, error) {183194 br := bufio.NewReaderSize(in, sniffSize)184195 text, enc, err := decode(br)185196 if err != nil {
@@ -189,7 +200,7 @@ func readDelimited(ctx context.Context, in io.Reader, k Kind, maxCells int, prog
189200 sample, _ := tr.Peek(sniffSize)190201 comma := '\t'191202 if k == CSV {192- comma = sniff(sample, len(sample) == sniffSize)203+ comma = sniff(sample, len(sample) == sniffSize, csvComma(loc))193204 }194205 cr := csv.NewReader(tr)195206 cr.Comma = comma
@@ -198,6 +209,8 @@ func readDelimited(ctx context.Context, in io.Reader, k Kind, maxCells int, prog
198209 cr.ReuseRecord = true199210 200211 b := newBuilder(ctx, maxCells)212+ var otherDecimal bool213+ b.loc, otherDecimal = numberLocale(sample, comma, loc)201214 row := 0202215 for ; ; row++ {203216 // Report before reading on: a pipe may keep the next read waiting.
@@ -229,6 +242,9 @@ func readDelimited(ctx context.Context, in io.Reader, k Kind, maxCells int, prog
229242 if k == CSV && comma != ',' {230243 notes = append(notes, "separated by "+delimiterName(comma))231244 }245+ if otherDecimal {246+ notes = append(notes, decimalNote(b.loc))247+ }232248 if enc != "UTF-8" {233249 notes = append(notes, "read as "+enc)234250 }
@@ -249,12 +265,15 @@ func delimiterName(r rune) string {
249265 }250266 251267 // exportDelimited writes the snapshot's displayed values, as Sheets'252-// Download as CSV does: formulas become their results, formats show.268+// Download as CSV does: formulas become their results, formats show, in269+// the snapshot's locale, with ; between fields where its decimal270+// separator is a comma.253271 // Rows are written as they are formatted, never all held at once.254272 func exportDelimited(name string, k Kind, snap *Snapshot) (*ExportResult, error) {255273 rows := 0256274 err := writeFile(name, func(out io.Writer) error {257275 w := csv.NewWriter(out)276+ w.Comma = csvComma(snap.Locale)258277 if k == TSV {259278 w.Comma = '\t'260279 }
@@ -277,6 +296,9 @@ func exportDelimited(name string, k Kind, snap *Snapshot) (*ExportResult, error)
277296 }278297 }279298 res := &ExportResult{Rows: rows}299+ if k == CSV && csvComma(snap.Locale) != ',' {300+ res.Notes = append(res.Notes, "separated by semicolons")301+ }280302 if formulas > 0 {281303 res.Notes = append(res.Notes, count(formulas, "formula", "formulas")+" saved as values")282304 }
M internal/fileio/csv_test.go +5 -5
@@ -68,7 +68,7 @@ func TestSniff(t *testing.T) {
6868 {"empty", "", ','},6969 } {7070 t.Run(tc.name, func(t *testing.T) {71- if got := sniff([]byte(tc.text), false); got != tc.want {71+ if got := sniff([]byte(tc.text), false, ','); got != tc.want {7272 t.Errorf("sniff = %q, want %q", got, tc.want)7373 }7474 })
@@ -77,7 +77,7 @@ func TestSniff(t *testing.T) {
7777 7878 func readText(t *testing.T, k Kind, text string) (*sheet.Sheet, []string) {7979 t.Helper()80- s, _, notes, err := readDelimited(context.Background(), strings.NewReader(text), k, 0, func(int) {})80+ s, _, notes, err := readDelimited(context.Background(), strings.NewReader(text), k, 0, nil, func(int) {})8181 if err != nil {8282 t.Fatal(err)8383 }
@@ -188,7 +188,7 @@ func TestReadDelimitedTruncates(t *testing.T) {
188188 for range sheet.MaxRows + 4 {189189 b.WriteString("x\n")190190 }191- s, _, notes, err := readDelimited(context.Background(), strings.NewReader(b.String()), CSV, sheet.MaxRows+sheet.MaxCols, func(int) {})191+ s, _, notes, err := readDelimited(context.Background(), strings.NewReader(b.String()), CSV, sheet.MaxRows+sheet.MaxCols, nil, func(int) {})192192 if err != nil {193193 t.Fatal(err)194194 }
@@ -206,7 +206,7 @@ func TestReadDelimitedTruncates(t *testing.T) {
206206 for range 100 {207207 b.WriteString(strings.Repeat("7,", 9) + "7\n")208208 }209- s, _, notes, err = readDelimited(context.Background(), strings.NewReader(b.String()), CSV, 255, func(int) {})209+ s, _, notes, err = readDelimited(context.Background(), strings.NewReader(b.String()), CSV, 255, nil, func(int) {})210210 if err != nil {211211 t.Fatal(err)212212 }
@@ -280,6 +280,6 @@ func FuzzReadDelimited(f *testing.F) {
280280 if tsv {281281 k = TSV282282 }283- readDelimited(context.Background(), strings.NewReader(string(data)), k, 0, func(int) {})283+ readDelimited(context.Background(), strings.NewReader(string(data)), k, 0, nil, func(int) {})284284 })285285 }
A internal/fileio/csvlocale.go +87 -0
@@ -0,0 +1,87 @@
1+package fileio2+3+import (4+ "bytes"5+ "encoding/csv"6+7+ "github.com/FelineStateMachine/012/internal/locale"8+ "github.com/FelineStateMachine/012/internal/value"9+)10+11+// Delimited files are read and written in the importing workbook's12+// locale, as Sheets does: 1,5 is a number in a CSV file read in de-DE.13+// A file whose numbers are written with the other decimal separator is14+// noticed from its sample and read that way, keeping the locale's date15+// order and currency; and in a locale with a decimal comma, CSV files16+// are written with ; between fields, as its spreadsheets write them.17+18+// numberSample is how many fields of the sample decide how numbers are19+// written.20+const numberSample = 200021+22+// numberLocale is the locale a delimited file's fields are read in: loc,23+// or loc with the other decimal separator (1.5 and 1,5) when at least24+// two of the sample's fields, and more than twice as many as the other25+// way round, read as numbers only that way (a lone 1,5 may be text).26+// other reports the second.27+func numberLocale(sample []byte, comma rune, loc *locale.Locale) (read *locale.Locale, other bool) {28+ loc = loc.Or()29+ alt := swapDecimal(loc)30+ r := csv.NewReader(bytes.NewReader(sample))31+ r.Comma, r.LazyQuotes, r.FieldsPerRecord, r.ReuseRecord = comma, true, -1, true32+ mine, theirs := 0, 033+ for seen := 0; seen < numberSample; {34+ rec, err := r.Read()35+ if err != nil {36+ break37+ }38+ for _, f := range rec {39+ seen++40+ _, _, inMine := value.ParseValueIn(f, loc)41+ _, _, inTheirs := value.ParseValueIn(f, alt)42+ switch {43+ case inMine && !inTheirs:44+ mine++45+ case inTheirs && !inMine:46+ theirs++47+ }48+ }49+ }50+ if theirs >= 2 && theirs > 2*mine {51+ return alt, true52+ }53+ return loc, false54+}55+56+// swapDecimal is loc with the other decimal separator, grouping with the57+// one it gave up: a comma for a point, a point for a comma.58+func swapDecimal(loc *locale.Locale) *locale.Locale {59+ alt := *loc60+ if loc.Decimal == ',' {61+ alt.Decimal, alt.Group = '.', ","62+ } else {63+ alt.Decimal, alt.Group = ',', "."64+ if alt.DateSep == '.' {65+ alt.DateSep = '/'66+ }67+ }68+ return &alt69+}70+71+// csvComma separates the fields of a CSV file written in loc: ; where72+// the decimal separator is a comma.73+func csvComma(loc *locale.Locale) rune {74+ if loc.Or().Decimal == ',' {75+ return ';'76+ }77+ return ','78+}79+80+// decimalNote says how a file's numbers were read when not in the81+// locale's own way.82+func decimalNote(loc *locale.Locale) string {83+ if loc.Decimal == ',' {84+ return "numbers with decimal commas"85+ }86+ return "numbers with decimal points"87+}
A internal/fileio/csvlocale_test.go +116 -0
@@ -0,0 +1,116 @@
1+package fileio2+3+import (4+ "context"5+ "os"6+ "path/filepath"7+ "slices"8+ "strings"9+ "testing"10+11+ "github.com/FelineStateMachine/012/internal/locale"12+ "github.com/FelineStateMachine/012/internal/numfmt"13+ "github.com/FelineStateMachine/012/internal/sheet"14+)15+16+func mustLocale(t *testing.T, tag string) *locale.Locale {17+ t.Helper()18+ l, ok := locale.Lookup(tag)19+ if !ok {20+ t.Fatalf("no locale %s", tag)21+ }22+ return l23+}24+25+// TestReadDelimitedInLocales reads CSV files in a locale: its own26+// separators and date order, or the other decimal separator when the27+// file writes numbers that way.28+func TestReadDelimitedInLocales(t *testing.T) {29+ date := numfmt.DateSerial(2026, 9, 26)30+ cases := []struct {31+ name, tag, text string32+ values map[string]float6433+ texts map[string]string34+ note string35+ }{36+ {"en-US reads decimal commas", "en-US", "name;price\napple;1,5\npear;2,25\n",37+ map[string]float64{"B2": 1.5, "B3": 2.25}, nil, "numbers with decimal commas"},38+ {"de-DE reads its own", "de-DE", "a;b;c\n1.234,5;26.09.2026;12,50 €\n",39+ map[string]float64{"A2": 1234.5, "B2": date, "C2": 12.5}, nil, ""},40+ {"de-DE ties go to semicolons", "de-DE", "1,5;2,5\n3,5;4,5\n",41+ map[string]float64{"A1": 1.5, "B2": 4.5}, nil, ""},42+ {"de-DE reads decimal points", "de-DE", "a,b\n1.25,2.75\n3.5,4.125\n",43+ map[string]float64{"A2": 1.25, "B2": 2.75, "B3": 4.125}, nil, "numbers with decimal points"},44+ {"en-GB reads its date order", "en-GB", "d\n26/09/2026\n",45+ map[string]float64{"A2": date}, nil, ""},46+ {"de-DE keeps its formulas text", "de-DE", "=SUM(1;2)\t-A1*0,5\n",47+ nil, map[string]string{"A1": "=SUM(1;2)", "B1": "-A1*0,5"}, ""},48+ }49+ for _, c := range cases {50+ t.Run(c.name, func(t *testing.T) {51+ k := CSV52+ if strings.Contains(c.text, "\t") {53+ k = TSV54+ }55+ s, _, notes, err := readDelimited(context.Background(), strings.NewReader(c.text), k, 0, mustLocale(t, c.tag), func(int) {})56+ if err != nil {57+ t.Fatal(err)58+ }59+ want := map[string]sheet.Value{}60+ for a, n := range c.values {61+ want[a] = sheet.Value{Kind: sheet.Number, Num: n}62+ }63+ for a, s := range c.texts {64+ want[a] = sheet.Value{Kind: sheet.Text, Str: s}65+ }66+ for a, w := range want {67+ if v := s.Value(addr(t, a)); v != w {68+ t.Errorf("%s = %v, want %v", a, v, w)69+ }70+ }71+ if got := numbersNote(notes); got != c.note {72+ t.Errorf("notes %q, want %q", notes, c.note)73+ }74+ })75+ }76+}77+78+// numbersNote is the note saying how numbers were read, or "".79+func numbersNote(notes []string) string {80+ for _, n := range notes {81+ if strings.HasPrefix(n, "numbers") {82+ return n83+ }84+ }85+ return ""86+}87+88+// TestExportDelimitedInLocale writes CSV as a locale shows it, with ;89+// between fields where the decimal separator is a comma.90+func TestExportDelimitedInLocale(t *testing.T) {91+ src := build(t, map[string]string{"A1": "1234.5", "B1": "9/26/2026", "C1": "Tee, schwarz", "A2": "=A1/4"})92+ src.SetFormat(sheet.NewRect(addr(t, "A1"), addr(t, "A1")), sheet.Preset(sheet.FmtCurrency))93+ src.Book().SetLocale("de-DE")94+ name := filepath.Join(t.TempDir(), "x.csv")95+ res, err := Export(context.Background(), name, CSV, Snap(src, sheet.Rect{}, "x"), ExportOptions{})96+ if err != nil {97+ t.Fatal(err)98+ }99+ data, _ := os.ReadFile(name)100+ if want := "1.234,50 €;26.9.2026;Tee, schwarz\n308,63\u00a0€;;\n"; string(data) != want {101+ t.Errorf("wrote %q, want %q", data, want)102+ }103+ if !slices.Contains(res.Notes, "separated by semicolons") {104+ t.Errorf("notes %q", res.Notes)105+ }106+ // Read back in de-DE, the values are the same.107+ back, _, _, err := readDelimited(context.Background(), strings.NewReader(string(data)), CSV, 0, mustLocale(t, "de-DE"), func(int) {})108+ if err != nil {109+ t.Fatal(err)110+ }111+ for _, a := range []string{"A1", "B1"} {112+ if got, want := back.Value(addr(t, a)), src.Value(addr(t, a)); got != want {113+ t.Errorf("%s read back as %v, want %v", a, got, want)114+ }115+ }116+}
M internal/fileio/fileio.go +4 -0
M internal/fileio/snapshot.go +7 -3
@@ -4,6 +4,7 @@ import (
44 "iter"55 "strconv"66 7+ "github.com/FelineStateMachine/012/internal/locale"78 "github.com/FelineStateMachine/012/internal/sheet"89 )910
@@ -17,6 +18,8 @@ type Snapshot struct {
1718 Cells map[sheet.Addr]SnapCell1819 Widths map[int]int // non-default column widths1920 Name string // what to call the data: a sheet or table name21+ // Locale is the sheet's, which text formats (CSV) are written in.22+ Locale *locale.Locale2023 2124 // ColFormats and RowFormats are the formats of whole columns and2225 // rows, for formats that keep them (XLSX).
@@ -92,7 +95,7 @@ func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {
9295 } else {9396 r.To = r.From9497 }95- snap := &Snapshot{Range: r, Cells: map[sheet.Addr]SnapCell{}, Widths: s.Widths(), Name: name,98+ snap := &Snapshot{Range: r, Cells: map[sheet.Addr]SnapCell{}, Widths: s.Widths(), Name: name, Locale: s.Locale(),9699 ColFormats: snapLines(s, false), RowFormats: snapLines(s, true), Filter: s.Filter(),97100 CondFormats: s.CondFormats(), Validations: s.Validations()}98101 snap.FrozenRows, snap.FrozenCols = s.Frozen()
@@ -208,7 +211,8 @@ func excelRange(ws string, r sheet.Rect) string {
208211 }209212 210213 // textRows yields the snapshot's displayed text row by row across its211-// range, for text formats. The slice is reused from row to row.214+// range as shown in its locale, for text formats. The slice is reused215+// from row to row.212216 func (s *Snapshot) textRows() iter.Seq[[]string] {213217 return func(yield func([]string) bool) {214218 r := s.Range
@@ -217,7 +221,7 @@ func (s *Snapshot) textRows() iter.Seq[[]string] {
217221 for col := r.From.Col; col <= r.To.Col; col++ {218222 line[col-r.From.Col] = ""219223 if c, ok := s.Cells[sheet.Addr{Col: col, Row: row}]; ok {220- line[col-r.From.Col] = c.Text()224+ line[col-r.From.Col] = sheet.FormatTextIn(c.Value, c.Format, s.Locale.Or())221225 }222226 }223227 if !yield(line) {
A internal/formula/locale.go +129 -0
@@ -0,0 +1,129 @@
1+package formula2+3+import "github.com/FelineStateMachine/012/internal/locale"4+5+// Formulas are stored and parsed in en-US's syntax: 1.5 for a number, ,6+// between arguments and ; between an array's rows. A locale whose7+// decimal separator is a comma writes 1,5, puts ; between arguments and8+// \ between the values of an array's row, as Sheets does: =ROUND(1,5; 0)9+// and {1\2;3\4}. Function names stay in English. Localize and10+// Delocalize translate at the edges, where formulas are shown and typed;11+// each separator is one byte in both, so a position in one is the same12+// position in the other (a parse error's, the caret's).13+14+// Localize writes a formula (or any part of one) as stored in loc's15+// syntax. Strings and sheet names are left alone.16+func Localize(src string, loc *locale.Locale) string {17+ return translate(src, loc, true)18+}19+20+// Delocalize reads a formula typed in loc's syntax into the syntax it is21+// stored and parsed in. It works on unfinished formulas too.22+func Delocalize(src string, loc *locale.Locale) string {23+ return translate(src, loc, false)24+}25+26+// SameSyntax reports whether loc writes formulas as they are stored.27+func SameSyntax(loc *locale.Locale) bool {28+ return loc == nil || loc.Decimal == '.' && loc.ArgSep() == ',' && loc.ColSep() == ','29+}30+31+// separators are the characters a translation swaps, from and to.32+type separators struct {33+ dec, arg, col byte // in the syntax read34+ toDec, toArg, toCol byte // in the syntax written35+}36+37+func translate(src string, loc *locale.Locale, out bool) string {38+ if SameSyntax(loc) {39+ return src40+ }41+ sep := separators{dec: '.', arg: ',', col: ',', toDec: loc.Decimal, toArg: loc.ArgSep(), toCol: loc.ColSep()}42+ if !out {43+ sep = separators{dec: loc.Decimal, arg: loc.ArgSep(), col: loc.ColSep(), toDec: '.', toArg: ',', toCol: ','}44+ }45+ b := []byte(src)46+ var open []byte // the ( and { around the position, innermost last47+ for i := 0; i < len(b); {48+ switch c := b[i]; {49+ case c == '"' || c == '\'':50+ i = skipQuoted(b, i)51+ case c == '@' || isIdentStart(c):52+ i = skipIdent(b, i+1)53+ case isDigit(c) || c == sep.dec && i+1 < len(b) && isDigit(b[i+1]):54+ i = sep.number(b, i)55+ case c == '(' || c == '{':56+ open = append(open, c)57+ i++58+ case c == ')' || c == '}':59+ if len(open) > 0 {60+ open = open[:len(open)-1]61+ }62+ i++63+ default:64+ inArray := len(open) > 0 && open[len(open)-1] == '{'65+ switch {66+ case inArray && c == sep.col:67+ b[i] = sep.toCol68+ case !inArray && c == sep.arg:69+ b[i] = sep.toArg70+ }71+ i++72+ }73+ }74+ return string(b)75+}76+77+// skipQuoted skips a string or quoted sheet name starting at i, with its78+// quote doubled inside; an unclosed one runs to the end.79+func skipQuoted(b []byte, i int) int {80+ q := b[i]81+ for i++; i < len(b); i++ {82+ if b[i] != q {83+ continue84+ }85+ if i+1 < len(b) && b[i+1] == q {86+ i++87+ continue88+ }89+ return i + 190+ }91+ return len(b)92+}93+94+// skipIdent skips the rest of a reference, name or function name.95+func skipIdent(b []byte, i int) int {96+ for i < len(b) && (isIdentPart(b[i]) || b[i] >= 0x80) {97+ i++98+ }99+ return i100+}101+102+// number rewrites the decimal separator of the number at i, returning103+// where it ends: digits, one decimal separator, and an exponent.104+func (sep separators) number(b []byte, i int) int {105+ point := false106+ for ; i < len(b); i++ {107+ switch c := b[i]; {108+ case isDigit(c):109+ case c == sep.dec && !point && (i+1 >= len(b) || b[i+1] != sep.dec):110+ b[i], point = sep.toDec, true111+ case (c == 'e' || c == 'E') && exponentAt(b, i+1):112+ i++113+ if b[i] == '+' || b[i] == '-' {114+ i++115+ }116+ default:117+ return i118+ }119+ }120+ return i121+}122+123+// exponentAt reports whether an exponent's sign or digits start at i.124+func exponentAt(b []byte, i int) bool {125+ if i < len(b) && (b[i] == '+' || b[i] == '-') {126+ i++127+ }128+ return i < len(b) && isDigit(b[i])129+}
A internal/formula/locale_test.go +74 -0
@@ -0,0 +1,74 @@
1+package formula2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/012/internal/locale"7+)8+9+func mustLocale(t *testing.T, tag string) *locale.Locale {10+ t.Helper()11+ l, ok := locale.Lookup(tag)12+ if !ok {13+ t.Fatalf("no locale %s", tag)14+ }15+ return l16+}17+18+// TestFormulaLocaleRoundTrip types formulas in each locale, stores them19+// canonically, parses them, and prints them back in the locale.20+func TestFormulaLocaleRoundTrip(t *testing.T) {21+ cases := []struct{ tag, typed, stored string }{22+ {"de-DE", "=ROUND(1,5; 0)", "=ROUND(1.5, 0)"},23+ {"de-DE", "=SUM(A1:A3;0,25)*2", "=SUM(A1:A3,0.25)*2"},24+ {"de-DE", `=IF(A1>1,5;"a,b;c";'Q3; x'!B2)`, `=IF(A1>1.5,"a,b;c",'Q3; x'!B2)`},25+ {"de-DE", "=SUM({1\\2,5;3\\4})", "=SUM({1,2.5;3,4})"},26+ {"de-DE", "=1,5E-3+,5", "=1.5E-3+.5"},27+ {"de-DE", "=SUM(Sheet2!A1..B3;Tax.Rate)", "=SUM(Sheet2!A1..B3,Tax.Rate)"},28+ {"fr-FR", "=SUM(1,25; 2)", "=SUM(1.25, 2)"},29+ {"pt-BR", "=IF(A1;1,5;2,5)", "=IF(A1,1.5,2.5)"},30+ {"en-GB", "=ROUND(1.5, 0)", "=ROUND(1.5, 0)"},31+ {"ja-JP", "={1,2;3,4}", "={1,2;3,4}"},32+ {"en-US", "=ROUND(1.5, 0)", "=ROUND(1.5, 0)"},33+ }34+ for _, c := range cases {35+ l := mustLocale(t, c.tag)36+ stored := Delocalize(c.typed, l)37+ if stored != c.stored {38+ t.Errorf("%s: %q stored as %q, want %q", c.tag, c.typed, stored, c.stored)39+ continue40+ }41+ if len(stored) != len(c.typed) {42+ t.Errorf("%s: %q changed length", c.tag, c.typed)43+ }44+ if back := Localize(stored, l); back != c.typed {45+ t.Errorf("%s: %q shown as %q, want %q", c.tag, stored, back, c.typed)46+ }47+ n, err := Parse(stored, testFuncs)48+ if err != nil {49+ t.Errorf("%s: %q: %v", c.tag, stored, err)50+ continue51+ }52+ // The printer's canonical text, localized, reads back the same.53+ printed := Localize(Text(n), l)54+ n2, err := Parse(Delocalize(printed, l), testFuncs)55+ if err != nil || Text(n2) != Text(n) {56+ t.Errorf("%s: printed %q reads back as %v %v", c.tag, printed, n2, err)57+ }58+ }59+}60+61+// TestLocalizePartial translates formulas still being typed.62+func TestLocalizePartial(t *testing.T) {63+ de := mustLocale(t, "de-DE")64+ for typed, want := range map[string]string{65+ "=SUM(1,5;": "=SUM(1.5,",66+ `=IF(A1;"x;`: `=IF(A1,"x;`,67+ "={1\\": "={1,",68+ "=ROUND(": "=ROUND(",69+ } {70+ if got := Delocalize(typed, de); got != want {71+ t.Errorf("%q = %q, want %q", typed, got, want)72+ }73+ }74+}
A internal/locale/locale.go +191 -0
@@ -0,0 +1,191 @@
1+// Package locale is the table of spreadsheet locales, as Sheets' File >2+// Settings > Locale offers them: how numbers, dates and currency are3+// typed and shown, and how formulas separate their arguments. It holds4+// only the conventions, with no dependencies (no CLDR data): the5+// packages that parse and render use them at their edges, while cells,6+// formulas and files stay in the canonical form of en-US.7+package locale8+9+import (10+ "strings"11+)12+13+// DateOrder is the order of day, month and year in a date typed or14+// shown with numbers only.15+type DateOrder uint816+17+const (18+ MDY DateOrder = iota // 9/26/202619+ DMY // 26.09.202620+ YMD // 2026-09-2621+)22+23+func (o DateOrder) String() string { return [...]string{"MDY", "DMY", "YMD"}[o] }24+25+// Locale is one locale's conventions.26+type Locale struct {27+ Tag string // BCP 47, as written in files and the config: "de-DE"28+ Name string // as the picker lists it: "German (Germany)"29+30+ Decimal byte // the decimal separator: '.' or ','31+ Group string // the thousands separator shown: ",", ".", a no-break space, an apostrophe32+33+ Order DateOrder34+ DateSep byte // between the numbers of a date: '/', '.' or '-'35+ Date string // the Date format's pattern: "dd.mm.yyyy"36+ Time string // the Time format's pattern: "hh:mm:ss"37+38+ Currency string // the currency symbol: "€"39+ After bool // the symbol follows the number: "1.234,56 €"40+ Space bool // a space between the symbol and the number: "R$ 1.234,56"41+}42+43+// Canonical is en-US: what cells, formulas and files store, whatever the44+// locale shown.45+var Canonical = &table[0]46+47+// nbsp and nnbsp are the no-break spaces locales group digits with, so48+// a number never breaks across lines.49+const (50+ nbsp = " "51+ nnbsp = " "52+)53+54+// table lists the locales, en-US first. Date, Time and Currency are the55+// formats of Format > Number in each. Dates show day and month without56+// leading zeros, as en-US's m/d/yyyy does, so most fit a default column57+// (26.9.2026); where a country writes ISO dates they keep theirs.58+var table = []Locale{59+ {Tag: "en-US", Name: "English (United States)", Decimal: '.', Group: ",", Order: MDY, DateSep: '/', Date: "m/d/yyyy", Time: "h:mm:ss am/pm", Currency: "$"},60+ {Tag: "en-GB", Name: "English (United Kingdom)", Decimal: '.', Group: ",", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "£"},61+ {Tag: "en-CA", Name: "English (Canada)", Decimal: '.', Group: ",", Order: YMD, DateSep: '-', Date: "yyyy-mm-dd", Time: "h:mm:ss am/pm", Currency: "$"},62+ {Tag: "en-AU", Name: "English (Australia)", Decimal: '.', Group: ",", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "h:mm:ss am/pm", Currency: "$"},63+ {Tag: "de-DE", Name: "German (Germany)", Decimal: ',', Group: ".", Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},64+ {Tag: "de-CH", Name: "German (Switzerland)", Decimal: '.', Group: "’", Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "CHF", Space: true},65+ {Tag: "fr-FR", Name: "French (France)", Decimal: ',', Group: nnbsp, Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},66+ {Tag: "fr-CA", Name: "French (Canada)", Decimal: ',', Group: nbsp, Order: YMD, DateSep: '-', Date: "yyyy-mm-dd", Time: "hh:mm:ss", Currency: "$", After: true, Space: true},67+ {Tag: "es-ES", Name: "Spanish (Spain)", Decimal: ',', Group: ".", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "h:mm:ss", Currency: "€", After: true, Space: true},68+ {Tag: "es-MX", Name: "Spanish (Mexico)", Decimal: '.', Group: ",", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "$"},69+ {Tag: "it-IT", Name: "Italian (Italy)", Decimal: ',', Group: ".", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},70+ {Tag: "pt-BR", Name: "Portuguese (Brazil)", Decimal: ',', Group: ".", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "R$", Space: true},71+ {Tag: "pt-PT", Name: "Portuguese (Portugal)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},72+ {Tag: "nl-NL", Name: "Dutch (Netherlands)", Decimal: ',', Group: ".", Order: DMY, DateSep: '-', Date: "d-m-yyyy", Time: "hh:mm:ss", Currency: "€", Space: true},73+ {Tag: "sv-SE", Name: "Swedish (Sweden)", Decimal: ',', Group: nbsp, Order: YMD, DateSep: '-', Date: "yyyy-mm-dd", Time: "hh:mm:ss", Currency: "kr", After: true, Space: true},74+ {Tag: "da-DK", Name: "Danish (Denmark)", Decimal: ',', Group: ".", Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "kr.", After: true, Space: true},75+ {Tag: "nb-NO", Name: "Norwegian (Norway)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "kr", Space: true},76+ {Tag: "fi-FI", Name: "Finnish (Finland)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "h:mm:ss", Currency: "€", After: true, Space: true},77+ {Tag: "pl-PL", Name: "Polish (Poland)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "zł", After: true, Space: true},78+ {Tag: "cs-CZ", Name: "Czech (Czechia)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "h:mm:ss", Currency: "Kč", After: true, Space: true},79+ {Tag: "ru-RU", Name: "Russian (Russia)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "h:mm:ss", Currency: "₽", After: true, Space: true},80+ {Tag: "tr-TR", Name: "Turkish (Türkiye)", Decimal: ',', Group: ".", Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "₺"},81+ {Tag: "ja-JP", Name: "Japanese (Japan)", Decimal: '.', Group: ",", Order: YMD, DateSep: '/', Date: "yyyy/m/d", Time: "h:mm:ss", Currency: "¥"},82+ {Tag: "zh-CN", Name: "Chinese (China)", Decimal: '.', Group: ",", Order: YMD, DateSep: '/', Date: "yyyy/m/d", Time: "h:mm:ss", Currency: "¥"},83+}84+85+// All returns every locale, en-US first, then as the table lists them.86+func All() []*Locale {87+ out := make([]*Locale, len(table))88+ for i := range table {89+ out[i] = &table[i]90+ }91+ return out92+}93+94+// Tags are the tags of every locale, in the table's order.95+func Tags() []string {96+ out := make([]string, len(table))97+ for i := range table {98+ out[i] = table[i].Tag99+ }100+ return out101+}102+103+// Lookup finds a locale by its tag, ignoring case and taking _ for -, so104+// "de-DE", "de_de" and "DE-de" are the same. A language alone ("de")105+// means its first locale in the table.106+func Lookup(tag string) (*Locale, bool) {107+ tag = strings.ReplaceAll(strings.TrimSpace(tag), "_", "-")108+ if tag == "" {109+ return nil, false110+ }111+ for i := range table {112+ if strings.EqualFold(table[i].Tag, tag) {113+ return &table[i], true114+ }115+ }116+ if !strings.Contains(tag, "-") {117+ for i := range table {118+ if lang, _, _ := strings.Cut(table[i].Tag, "-"); strings.EqualFold(lang, tag) {119+ return &table[i], true120+ }121+ }122+ }123+ return nil, false124+}125+126+// FromPOSIX reads a POSIX locale, as LANG and LC_ALL hold it127+// ("de_DE.UTF-8", "fr_FR@euro"), returning the tag of the locale in the128+// table it names. C, POSIX and locales outside the table give "".129+func FromPOSIX(v string) string {130+ v, _, _ = strings.Cut(v, ".")131+ v, _, _ = strings.Cut(v, "@")132+ if v == "C" || v == "POSIX" {133+ return ""134+ }135+ if l, ok := Lookup(v); ok {136+ return l.Tag137+ }138+ return ""139+}140+141+// IsCanonical reports whether l types and shows everything as stored:142+// en-US, or nil.143+func (l *Locale) IsCanonical() bool { return l == nil || l == Canonical }144+145+// Or returns l, or the canonical locale when l is nil.146+func (l *Locale) Or() *Locale {147+ if l == nil {148+ return Canonical149+ }150+ return l151+}152+153+// ArgSep separates a formula's arguments: ; where the decimal separator154+// is a comma, as in Sheets, so 1,5 stays a number.155+func (l *Locale) ArgSep() byte {156+ if l != nil && l.Decimal == ',' {157+ return ';'158+ }159+ return ','160+}161+162+// ColSep separates the values of a row in an array literal: \ where the163+// argument separator is ;, which also separates an array's rows.164+func (l *Locale) ColSep() byte {165+ if l != nil && l.Decimal == ',' {166+ return '\\'167+ }168+ return ','169+}170+171+// IsGroup reports whether r separates thousands in l as typed: its own172+// separator, any space where it groups with a space, and ' for ’.173+func (l *Locale) IsGroup(r rune) bool {174+ if string(r) == l.Group {175+ return true176+ }177+ switch l.Group {178+ case nbsp, nnbsp:179+ return r == ' ' || r == ' ' || r == ' '180+ case "’":181+ return r == '\''182+ }183+ return false184+}185+186+// DateTime is the Date time format's pattern: the date, then the time on187+// the 24-hour clock, as Sheets shows it in every locale.188+func (l *Locale) DateTime() string {189+ t := strings.TrimSpace(strings.Replace(l.Time, "am/pm", "", 1))190+ return l.Date + " " + t191+}
A internal/locale/locale_test.go +66 -0
@@ -0,0 +1,66 @@
1+package locale_test2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/012/internal/locale"7+)8+9+func TestLookup(t *testing.T) {10+ for in, want := range map[string]string{11+ "de-DE": "de-DE", "de_de": "de-DE", "DE-de": "de-DE", " en-GB ": "en-GB",12+ "de": "de-DE", "fr": "fr-FR", "xx-YY": "", "": "", "de-AT": "",13+ } {14+ l, ok := locale.Lookup(in)15+ if got := ""; ok {16+ got = l.Tag17+ if got != want {18+ t.Errorf("Lookup(%q) = %s, want %q", in, got, want)19+ }20+ } else if want != "" {21+ t.Errorf("Lookup(%q) found nothing, want %s", in, want)22+ }23+ }24+}25+26+func TestFromPOSIX(t *testing.T) {27+ for in, want := range map[string]string{28+ "de_DE.UTF-8": "de-DE", "fr_FR@euro": "fr-FR", "en_US.UTF-8": "en-US", "C": "", "POSIX": "",29+ "C.UTF-8": "", "": "", "xx_YY.UTF-8": "", "pt_BR": "pt-BR",30+ } {31+ if got := locale.FromPOSIX(in); got != want {32+ t.Errorf("FromPOSIX(%q) = %q, want %q", in, got, want)33+ }34+ }35+}36+37+// TestTable checks every locale is whole: a separator a formula can tell38+// from its arguments, a date pattern in its own order, and tags that39+// look themselves up.40+func TestTable(t *testing.T) {41+ seen := map[string]bool{}42+ for _, l := range locale.All() {43+ if seen[l.Tag] {44+ t.Errorf("%s twice", l.Tag)45+ }46+ seen[l.Tag] = true47+ if got, ok := locale.Lookup(l.Tag); !ok || got != l {48+ t.Errorf("%s doesn't look itself up", l.Tag)49+ }50+ if l.Decimal != '.' && l.Decimal != ',' {51+ t.Errorf("%s: decimal %q", l.Tag, l.Decimal)52+ }53+ if l.ArgSep() == l.Decimal || l.ColSep() == l.Decimal || l.Group == string(l.Decimal) {54+ t.Errorf("%s: separators clash with the decimal %q", l.Tag, l.Decimal)55+ }56+ if l.Name == "" || l.Currency == "" || l.Date == "" || l.Time == "" {57+ t.Errorf("%s is missing a field", l.Tag)58+ }59+ }60+ if !locale.Canonical.IsCanonical() || locale.Canonical.Tag != "en-US" {61+ t.Error("en-US isn't canonical")62+ }63+ if got := locale.Canonical.DateTime(); got != "m/d/yyyy h:mm:ss" {64+ t.Errorf("en-US date time %q", got)65+ }66+}
M internal/numfmt/edit.go +28 -7
@@ -1,6 +1,11 @@
11 package numfmt22 3-import "strings"3+import (4+ "strings"5+ "unicode/utf8"6+7+ "github.com/FelineStateMachine/012/internal/locale"8+)49 510 // AdjustDecimals adds (delta > 0) or removes zeros after the decimal611 // point of every section of a number pattern, as Sheets' Increase and
@@ -63,25 +68,41 @@ func adjustSection(sec string, delta int) string {
6368 // for a closing parenthesis, negatives in parentheses and zero as a dash.6469 // It reports false when the number doesn't fit.6570 func Accounting(v float64, dec, width int) (string, bool) {71+ return AccountingIn(v, dec, width, locale.Canonical)72+}73+74+// AccountingIn is Accounting as shown in loc: its separators, and its75+// currency symbol at the left of the cell, or after the number where loc76+// writes the symbol after it ("1.234,56 € ").77+func AccountingIn(v float64, dec, width int, loc *locale.Locale) (string, bool) {6678 whole, frac := Fixed(v, dec, HalfUp)6779 var num string6880 switch {6981 case strings.Trim(whole+frac, "0") == "":7082 num = "-" + strings.Repeat(" ", dec) + " "7183 case v < 0:72- num = "(" + Format(-v, "#,##0"+decimals(dec)) + ")"84+ num = "(" + FormatIn(-v, "#,##0"+decimals(dec), loc) + ")"7385 default:74- num = Format(v, "#,##0"+decimals(dec)) + " "86+ num = FormatIn(v, "#,##0"+decimals(dec), loc) + " "87+ }88+ sym := loc.Currency89+ if loc.After {90+ body := strings.TrimSuffix(num, " ") + " " + sym + " "91+ gap := width - utf8.RuneCountInString(body)92+ if gap < 0 {93+ return "", false94+ }95+ return strings.Repeat(" ", gap) + body, true7596 }76- gap := width - 1 - len(num)97+ gap := width - utf8.RuneCountInString(sym) - utf8.RuneCountInString(num)7798 switch {7899 case gap < 0:79100 return "", false80101 case gap >= 1:81- // Leave a column of padding before the $, like other cells.82- return " $" + strings.Repeat(" ", gap-1) + num, true102+ // Leave a column of padding before the symbol, like other cells.103+ return " " + sym + strings.Repeat(" ", gap-1) + num, true83104 }84- return "$" + strings.Repeat(" ", gap) + num, true105+ return sym + strings.Repeat(" ", gap) + num, true85106 }86107 87108 func decimals(n int) string {
M internal/numfmt/number.go +14 -9
@@ -4,6 +4,8 @@ import (
44 "math"55 "strconv"66 "strings"7+8+ "github.com/FelineStateMachine/012/internal/locale"79 )810 911 // numLayout is where a number section puts each part of the number: the
@@ -19,6 +21,7 @@ type numLayout struct {
1921 // literals sit among them too (a phone number pattern).2022 grouped bool2123 scale int // commas right after the last integer placeholder divide by 1000 each24+ loc *locale.Locale2225 }2326 2427 func layoutNumber(toks []ptok) numLayout {
@@ -82,7 +85,7 @@ func (l *numLayout) digits(v float64) numDigits {
8285 switch {8386 case l.general && len(l.intPH) == 0:8487 // General shows its own decimals; placeholders after it get zeros.85- return numDigits{whole: General(v), frac: strings.Repeat("0", len(l.fracPH))}88+ return numDigits{whole: Localize(General(v), l.loc), frac: strings.Repeat("0", len(l.fracPH))}8689 case l.exp >= 0:8790 return l.scientific(v)8891 }
@@ -145,9 +148,11 @@ func (l *numLayout) place(d numDigits) []string {
145148 return out146149 }147150 148-// formatNumber renders |v| with a number section; neg adds a minus sign.149-func formatNumber(v float64, neg bool, toks []ptok) string {151+// formatNumber renders |v| with a number section as shown in loc; neg152+// adds a minus sign.153+func formatNumber(v float64, neg bool, toks []ptok, loc *locale.Locale) string {150154 l := layoutNumber(toks)155+ l.loc = loc151156 for range l.percents {152157 v *= 100153158 }
@@ -178,7 +183,7 @@ func (l *numLayout) write(b *strings.Builder, i int, t ptok, d numDigits, placed
178183 l.writeDigit(b, i, d, placed)179184 case ptDot:180185 if i == l.dot {181- b.WriteByte('.')186+ b.WriteByte(l.loc.Decimal)182187 }183188 case ptPercent:184189 b.WriteByte('%')
@@ -209,7 +214,7 @@ func (l *numLayout) writeDigit(b *strings.Builder, i int, d numDigits, placed []
209214 for _, p := range l.intPH {210215 digits.WriteString(placed[p])211216 }212- b.WriteString(group(digits.String()))217+ b.WriteString(group(digits.String(), l.loc.Group))213218 case inInt:214219 if !l.grouped {215220 b.WriteString(placed[i])
@@ -234,9 +239,9 @@ func expSign(sign string, neg bool) string {
234239 return "E"235240 }236241 237-// group inserts thousands separators into a run of digits (and leading238-// spaces from ? placeholders).239-func group(s string) string {242+// group inserts thousands separators sep into a run of digits (and243+// leading spaces from ? placeholders).244+func group(s, sep string) string {240245 lead := len(s) - len(strings.TrimLeft(s, " "))241246 digits := s[lead:]242247 if len(digits) <= 3 {
@@ -250,7 +255,7 @@ func group(s string) string {
250255 }251256 for i := first; i < len(digits); i += 3 {252257 if i > 0 {253- b.WriteByte(',')258+ b.WriteString(sep)254259 }255260 b.WriteString(digits[i : i+3])256261 }
M internal/numfmt/pattern.go +9 -2
M internal/numfmt/round.go +19 -0
M internal/sheet/cellformat.go +7 -7
M internal/sheet/chart.go +3 -3
M internal/sheet/clipfmt.go +2 -2
M internal/sheet/decimal.go +1 -1
M internal/sheet/display.go +18 -7
@@ -4,6 +4,7 @@ import (
44 "strings"55 "unicode/utf8"66 7+ "github.com/FelineStateMachine/012/internal/locale"78 "github.com/FelineStateMachine/012/internal/numfmt"89 )910
@@ -14,6 +15,12 @@ import (
1415 // Automatic first drops decimals and falls back to scientific notation.1516 // Accounting returns AlignFill: exactly width columns, $ at the left.1617 func Display(v Value, f Format, width int) (string, Align) {18+ return DisplayIn(v, f, width, locale.Canonical)19+}20+21+// DisplayIn is Display as shown in loc: its separators, currency and22+// date order (see Format.CodeIn).23+func DisplayIn(v Value, f Format, width int, loc *locale.Locale) (string, Align) {1724 inner := max(width-1, 0)1825 switch v.Kind {1926 case Empty:
@@ -25,16 +32,16 @@ func Display(v Value, f Format, width int) (string, Align) {
2532 }2633 switch f.Kind {2734 case FmtAuto:28- return numfmt.GeneralFit(v.Num, inner), AlignRight35+ return numfmt.GeneralFitIn(v.Num, inner, loc), AlignRight2936 case FmtText:30- return numfmt.General(v.Num), AlignLeft37+ return numfmt.GeneralIn(v.Num, loc), AlignLeft3138 case FmtAccounting:32- if s, ok := numfmt.Accounting(v.Num, f.Decimals, width); ok {39+ if s, ok := numfmt.AccountingIn(v.Num, f.Decimals, width, loc); ok {3340 return s, AlignFill3441 }3542 return strings.Repeat("#", inner), AlignRight3643 }37- s := numfmt.Format(v.Num, f.Code())44+ s := numfmt.FormatIn(v.Num, f.CodeIn(loc), loc)3845 if utf8.RuneCountInString(s) > inner {3946 s = strings.Repeat("#", inner)4047 }
@@ -46,15 +53,19 @@ func FormatPattern(v float64, pat string) string { return numfmt.Format(v, pat)
4653 4754 // FormatText renders v under f with no width limit, e.g. for TEXT() or4855 // copying out of the grid.49-func FormatText(v Value, f Format) string {56+func FormatText(v Value, f Format) string { return FormatTextIn(v, f, locale.Canonical) }57+58+// FormatTextIn is FormatText as shown in loc, e.g. for a CSV file59+// written in loc.60+func FormatTextIn(v Value, f Format, loc *locale.Locale) string {5061 if v.Kind != Number {5162 return text(v)5263 }5364 switch f.Kind {5465 case FmtAuto, FmtText:55- return numfmt.General(v.Num)66+ return numfmt.GeneralIn(v.Num, loc)5667 }57- return numfmt.Format(v.Num, f.Code())68+ return numfmt.FormatIn(v.Num, f.CodeIn(loc), loc)5869 }5970 6071 // FormatValue renders v in width columns with one column of padding, the
M internal/sheet/evaluate.go +8 -5
M internal/sheet/file.go +16 -3
@@ -9,6 +9,8 @@ import (
99 "slices"1010 "strconv"1111 "strings"12+13+ "github.com/FelineStateMachine/012/internal/locale"1214 )1315 1416 // FileExt is the extension of the native worksheet format.
@@ -50,6 +52,10 @@ type fileFormat struct {
5052 // version bump: earlier builds ignore it and compute in binary, as5153 // Sheets would. It is for the whole workbook, so it stays at the top.5254 Arithmetic string `json:"arithmetic,omitempty"`55+ // Locale is the tag of the workbook's locale ("de-DE"), when it56+ // names one. It only changes how entries are typed and shown (cells57+ // are stored in en-US's form), so it needs no version bump either.58+ Locale string `json:"locale,omitempty"`5359 // Macros and where they were made need no version bump either:5460 // earlier builds ignore them, and the sheets read the same.5561 MacroOrigin string `json:"macroOrigin,omitempty"`
@@ -142,7 +148,7 @@ func jsonString(s string) string {
142148 }143149 144150 // headLines are the workbook's fields before the sheets: the named ranges145-// and the arithmetic setting, separated as the lines of the file.151+// and the settings, separated as the lines of the file.146152 func (w *Workbook) headLines() string {147153 var lines []string148154 if names := w.namesLine(); names != "" {
@@ -151,6 +157,9 @@ func (w *Workbook) headLines() string {
151157 if w.decimal {152158 lines = append(lines, `"arithmetic": "decimal"`)153159 }160+ if w.locale != nil {161+ lines = append(lines, `"locale": `+jsonString(w.locale.Tag))162+ }154163 if len(w.macros) > 0 {155164 if w.macroOrigin != "" {156165 lines = append(lines, `"macroOrigin": `+jsonString(w.macroOrigin))
@@ -203,8 +212,8 @@ func (s *Sheet) writeBody(b *bytes.Buffer, indent, names string) error {
203212 return err204213 }205214 addrs := make([]Addr, 0, s.cells.len())206- for a, c := range s.cells.all() {207- if c.saved() != nil {215+ for a := range s.cells.keys() {216+ if c := s.cells.richAt(a); c == nil || c.saved() != nil {208217 addrs = append(addrs, a)209218 }210219 }
@@ -315,6 +324,10 @@ func readBook(r io.Reader, trace any) (*Workbook, error) {
315324 // Anything but "decimal" (say, a mode from a later build) computes in316325 // binary, as the file would in a build without the setting.317326 w.decimal = f.Arithmetic == "decimal"327+ // A locale this build doesn't know follows the default.328+ if l, ok := locale.Lookup(f.Locale); ok {329+ w.locale = l330+ }318331 if err := w.readMacros(f.Macros); err != nil {319332 return nil, err320333 }
M internal/sheet/filter.go +2 -3
M internal/sheet/history.go +14 -12
@@ -54,8 +54,9 @@ type step struct {
5454 rules map[*Sheet]rulesState5555 // sheets is the sheet list before the step, when it changed.5656 sheets *sheetList57- // decimal is the arithmetic setting before the step, when it changed.58- decimal *bool57+ // settings are the workbook's settings before the step, when they58+ // changed.59+ settings *settings5960 // macros is the macro list before the step, when it changed.6061 macros *[]Macro6162 }
@@ -78,7 +79,7 @@ func (st *step) empty() bool {
7879 // widthOnly reports whether the step changed nothing but column widths7980 // and row heights.8081 func (st *step) widthOnly() bool {81- return len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && st.sheets == nil && st.decimal == nil &&82+ return len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && st.sheets == nil && st.settings == nil &&8283 st.macros == nil8384 }8485
@@ -164,7 +165,7 @@ func (s *Sheet) record(a Addr) {
164165 }165166 l := loc{s, a}166167 if _, seen := st.cells[l]; !seen {167- st.keep(l, s.cells.get(a).clone())168+ st.keep(l, s.cells.copyOf(a))168169 }169170 s.wb.hist.dirty = append(s.wb.hist.dirty, l)170171 }
@@ -194,7 +195,7 @@ func (w *Workbook) dropUnchanged(st *step) {
194195 st.dropUnchangedLines()195196 st.dropUnchangedRules()196197 for l, c := range st.cells {197- if c == nil && l.s.cells.get(l.a) == nil {198+ if c == nil && !l.s.cells.has(l.a) {198199 delete(st.cells, l)199200 st.cellBytes -= entryBytes200201 }
@@ -222,8 +223,8 @@ func (w *Workbook) dropUnchanged(st *step) {
222223 if st.sheets != nil && st.sheets.equal(w.sheetList()) {223224 st.sheets = nil224225 }225- if st.decimal != nil && *st.decimal == w.decimal {226- st.decimal = nil226+ if st.settings != nil && *st.settings == w.settings {227+ st.settings = nil227228 }228229 if st.macros != nil && slices.Equal(*st.macros, w.macros) {229230 st.macros = nil
@@ -376,7 +377,7 @@ func (w *Workbook) swap(undo bool) (Change, bool) {
376377 }377378 changed := make([]loc, 0, len(st.cells))378379 for l, c := range st.cells {379- inv.keep(l, l.s.cells.get(l.a).clone())380+ inv.keep(l, l.s.cells.copyOf(l.a))380381 l.s.place(l.a, c.clone())381382 changed = append(changed, l)382383 }
@@ -410,10 +411,11 @@ func (w *Workbook) swap(undo bool) (Change, bool) {
410411 s.rules = r411412 s.looks.reset()412413 }413- if st.decimal != nil {414- cur := w.decimal415- inv.decimal, w.decimal = &cur, *st.decimal416- w.structural = true // every formula computes differently414+ if st.settings != nil {415+ cur := w.settings416+ inv.settings, w.settings = &cur, *st.settings417+ // Every formula computes differently in the other arithmetic.418+ w.structural = w.structural || cur.decimal != w.decimal417419 }418420 if st.macros != nil {419421 cur := w.macros
M internal/sheet/historyrecord.go +6 -6
M internal/sheet/historysize.go +6 -6
@@ -12,15 +12,15 @@ const MaxUndoBytes = 256 << 20
1212 // undoBudget is MaxUndoBytes; tests lower it.1313 var undoBudget int64 = MaxUndoBytes1414 15-// Estimated heap held by the parts of a step. A before-image is a shallow16-// copy of a Cell (216 B) plus its map entry and boxed literal; a formula17-// also keeps its parsed tree and reference lists alive, which grow with18-// its text. The constants match the heap measured by the stress15+// Estimated heap held by the parts of a step. A before-image is a Cell16+// (216 B) plus its map entry, its input and boxed literal (a plain cell's17+// are made for it from the store, see store.go); a formula also keeps its18+// parsed tree and reference lists alive, which grow with its text. The constants match the heap measured by the stress1919 // benchmarks (BenchmarkHistoryFull) within about 10%.2020 const (2121 stepBytes = 512 // the step and its empty maps2222 entryBytes = 64 // a map entry: the key and a pointer23- cellBytes = 240 // a copied Cell and its boxed literal23+ cellBytes = 256 // a Cell, its input's header and its boxed literal2424 formulaBytes = 256 // a parsed formula's tree and references, plus perFormulaByte per byte of text2525 perFormulaByte = 162626 chartBytes = 128
@@ -73,7 +73,7 @@ func (w *Workbook) deletedSize(st *step) int64 {
7373 var n int647474 for _, s := range st.sheets.order {7575 if !slices.Contains(w.sheets, s) {76- n += int64(s.Len()) * (entryBytes + cellBytes)76+ n += s.cells.size()7777 }7878 }7979 return n
M internal/sheet/historysize_test.go +1 -1
M internal/sheet/insertbook.go +1 -1
M internal/sheet/lines.go +10 -13
A internal/sheet/locale.go +185 -0
@@ -0,0 +1,185 @@
1+package sheet2+3+import (4+ "strings"5+ "sync/atomic"6+7+ "github.com/FelineStateMachine/012/internal/formula"8+ "github.com/FelineStateMachine/012/internal/locale"9+ "github.com/FelineStateMachine/012/internal/numfmt"10+ "github.com/FelineStateMachine/012/internal/value"11+)12+13+// A workbook's locale is how its entries are typed and its numbers,14+// dates and formulas shown, as Sheets' File > Settings > Locale. Cells15+// keep their entries in en-US's form whatever the locale (see16+// value.Canonicalize and formula.Delocalize), so values never depend on17+// it: switching the locale redraws the sheet and changes nothing else,18+// and a file reads the same everywhere. Functions compute in en-US's19+// form too (VALUE("1,5") is an error in every locale), so formulas give20+// the same results everywhere.21+22+// settings are the workbook's own settings, saved in its file: Sheets'23+// File > Settings.24+type settings struct {25+ decimal bool // decimal arithmetic, see decimal.go26+ locale *locale.Locale // nil follows the default27+}28+29+// defaultLocale is the locale of workbooks that don't name one: the30+// config's locale, set with SetDefaultLocale.31+var defaultLocale atomic.Pointer[locale.Locale]32+33+// SetDefaultLocale sets the locale of workbooks that don't name one of34+// their own; nil is en-US.35+func SetDefaultLocale(l *locale.Locale) { defaultLocale.Store(l) }36+37+// DefaultLocale is the locale of workbooks that don't name one.38+func DefaultLocale() *locale.Locale { return defaultLocale.Load().Or() }39+40+// Locale is the workbook's locale: its own, or the default.41+func (w *Workbook) Locale() *locale.Locale {42+ if w.locale != nil {43+ return w.locale44+ }45+ return DefaultLocale()46+}47+48+// LocaleTag is the tag of the locale the workbook names, or "" when it49+// follows the default.50+func (w *Workbook) LocaleTag() string {51+ if w.locale == nil {52+ return ""53+ }54+ return w.locale.Tag55+}56+57+// SetLocale sets the workbook's locale by its tag, "" to follow the58+// default, as one undo step. Values don't change, only how they're59+// typed and shown. It reports false for a tag it doesn't know.60+func (w *Workbook) SetLocale(tag string) bool {61+ var l *locale.Locale62+ if tag != "" {63+ var ok bool64+ if l, ok = locale.Lookup(tag); !ok {65+ return false66+ }67+ }68+ if l == w.locale {69+ return true70+ }71+ label := "follow the default locale"72+ if l != nil {73+ label = "set the locale to " + l.Tag74+ }75+ w.change(w.sheets[w.Active()], label, Rect{}, func() {76+ w.recordSettings()77+ w.locale = l78+ })79+ return true80+}81+82+// Locale is the sheet's workbook's.83+func (s *Sheet) Locale() *locale.Locale { return s.wb.Locale() }84+85+// CanonicalEntry is an entry typed in loc as the cell stores it: numbers,86+// dates and times in en-US's form, formulas with its separators, text as87+// typed, behind a ' when it would read as something else stored (1.5,88+// typed as text in fr-FR).89+func CanonicalEntry(typed string, loc *locale.Locale) string {90+ if loc.IsCanonical() || typed == "" || strings.HasPrefix(typed, "'") {91+ return typed92+ }93+ if c, ok := value.Canonicalize(typed, loc); ok {94+ return c95+ }96+ switch strings.ToUpper(typed) {97+ case "TRUE", "FALSE":98+ return typed99+ }100+ if strings.HasPrefix(typed, "=") {101+ return formula.Delocalize(typed, loc)102+ }103+ if strings.HasPrefix(typed, "+") || strings.HasPrefix(typed, "-") {104+ if f := formula.Delocalize(typed, loc); IsFormulaEntry(f) {105+ return f106+ }107+ }108+ if storedAsOther(typed) {109+ return "'" + typed110+ }111+ return typed112+}113+114+// LocalEntry is the inverse of CanonicalEntry: a cell's entry as typed115+// in loc, as the formula bar shows it and editing starts from.116+func LocalEntry(input string, loc *locale.Locale) string {117+ if loc.IsCanonical() || input == "" {118+ return input119+ }120+ if rest, ok := strings.CutPrefix(input, "'"); ok {121+ // The ' CanonicalEntry adds, when typing rest in loc is text.122+ if storedAsOther(rest) && CanonicalEntry(rest, loc) == input {123+ return rest124+ }125+ return input126+ }127+ if _, _, ok := ParseValue(input); ok {128+ return value.Localize(input, loc)129+ }130+ if IsFormulaEntry(input) {131+ return formula.Localize(input, loc)132+ }133+ return input134+}135+136+// CanonicalArg is the argument of a rule or filter condition typed in137+// loc as it's stored: a number or date in en-US's form, a formula (after138+// =) in its syntax, text as typed.139+func CanonicalArg(arg string, loc *locale.Locale) string {140+ if loc.IsCanonical() {141+ return arg142+ }143+ if c, ok := value.Canonicalize(arg, loc); ok {144+ return c145+ }146+ if strings.HasPrefix(arg, "=") {147+ return formula.Delocalize(arg, loc)148+ }149+ return arg150+}151+152+// LocalArg is the inverse of CanonicalArg, for editing an argument again.153+func LocalArg(arg string, loc *locale.Locale) string {154+ if loc.IsCanonical() {155+ return arg156+ }157+ if _, _, ok := ParseValue(arg); ok {158+ return value.Localize(arg, loc)159+ }160+ if strings.HasPrefix(arg, "=") {161+ return formula.Localize(arg, loc)162+ }163+ return arg164+}165+166+// textIn is v as General shows it in loc: 1,5 in de-DE.167+func textIn(v Value, loc *locale.Locale) string {168+ if v.Kind == Number {169+ return numfmt.GeneralIn(v.Num, loc)170+ }171+ return v.String()172+}173+174+// storedAsOther reports whether an entry stored as s isn't text: a175+// number, date, formula or boolean.176+func storedAsOther(s string) bool {177+ if _, _, ok := ParseValue(s); ok {178+ return true179+ }180+ switch strings.ToUpper(s) {181+ case "TRUE", "FALSE":182+ return true183+ }184+ return IsFormulaEntry(s)185+}
A internal/sheet/locale_test.go +133 -0
@@ -0,0 +1,133 @@
1+package sheet2+3+import (4+ "bytes"5+ "strings"6+ "testing"7+8+ "github.com/FelineStateMachine/012/internal/locale"9+)10+11+func mustLocale(t *testing.T, tag string) *locale.Locale {12+ t.Helper()13+ l, ok := locale.Lookup(tag)14+ if !ok {15+ t.Fatalf("no locale %s", tag)16+ }17+ return l18+}19+20+// TestEntriesInLocales types entries in a locale, stores them, and checks21+// the value, the format and what editing the cell shows again.22+func TestEntriesInLocales(t *testing.T) {23+ cases := []struct {24+ tag, typed, stored, shown string25+ kind FormatKind26+ }{27+ {"de-DE", "1.234,5", "1,234.5", "1.234,5", FmtNumber},28+ {"de-DE", "12,50 €", "$12.50", "12,50 €", FmtCurrency},29+ {"de-DE", "26.09.2026", "09/26/2026", "26.09.2026", FmtDate},30+ {"de-DE", "=SUM(1;2)", "=SUM(1,2)", "=SUM(1;2)", FmtAuto},31+ {"de-DE", "=ROUND(A1*1,19; 2)", "=ROUND(A1*1.19, 2)", "=ROUND(A1*1,19; 2)", FmtAuto},32+ {"de-DE", "-A1*0,5", "-A1*0.5", "-A1*0,5", FmtAuto},33+ {"de-DE", "Hallo, Welt", "Hallo, Welt", "Hallo, Welt", FmtAuto},34+ {"fr-FR", "1.5", "'1.5", "1.5", FmtAuto},35+ {"de-DE", "1,234.5", "'1,234.5", "1,234.5", FmtAuto},36+ {"de-DE", "'1,5", "'1,5", "'1,5", FmtAuto},37+ {"de-DE", "TRUE", "TRUE", "TRUE", FmtAuto},38+ {"en-GB", "26/09/2026", "09/26/2026", "26/09/2026", FmtDate},39+ {"en-GB", "9/26/2026", "'9/26/2026", "9/26/2026", FmtAuto},40+ {"en-US", "1,234.5", "1,234.5", "1,234.5", FmtNumber},41+ }42+ for _, c := range cases {43+ l := mustLocale(t, c.tag)44+ stored := CanonicalEntry(c.typed, l)45+ if stored != c.stored {46+ t.Errorf("%s %q stored as %q, want %q", c.tag, c.typed, stored, c.stored)47+ continue48+ }49+ if shown := LocalEntry(stored, l); shown != c.shown {50+ t.Errorf("%s %q shown as %q, want %q", c.tag, stored, shown, c.shown)51+ }52+ s := New()53+ if err := s.Set(at("A1"), stored); err != nil {54+ if !strings.Contains(c.typed, "SUM") {55+ t.Errorf("%s %q: %v", c.tag, c.typed, err)56+ }57+ continue58+ }59+ if k := s.Cell(at("A1")).Format.Kind; k != c.kind {60+ t.Errorf("%s %q: format %v, want %v", c.tag, c.typed, k, c.kind)61+ }62+ }63+}64+65+// TestSetLocale switches a workbook's locale: values stay, the display66+// follows, undo puts it back, and the file keeps it.67+func TestSetLocale(t *testing.T) {68+ s := New()69+ for a, in := range map[string]string{"A1": "1234.5", "A2": "$12.50", "A3": "9/26/2026", "A4": "=A1/4"} {70+ if err := s.Set(at(a), in); err != nil {71+ t.Fatal(err)72+ }73+ }74+ s.SetFormat(NewRect(at("A1"), at("A1")), Preset(FmtNumber))75+ shown := func(a string) string {76+ text, _ := DisplayIn(s.Cell(at(a)).Value, s.DisplayFormat(at(a)), 20, s.Locale())77+ return text78+ }79+ before := s.Cell(at("A4")).Value80+ if !s.Book().SetLocale("de-DE") {81+ t.Fatal("de-DE unknown")82+ }83+ if got := s.Cell(at("A4")).Value; got != before {84+ t.Errorf("A4 changed to %v", got)85+ }86+ for a, want := range map[string]string{"A1": "1.234,50", "A2": "12,50 €", "A3": "26.9.2026", "A4": "308,63"} {87+ if got := shown(a); got != want {88+ t.Errorf("de-DE %s = %q, want %q", a, got, want)89+ }90+ }91+ var buf bytes.Buffer92+ if err := s.Write(&buf); err != nil {93+ t.Fatal(err)94+ }95+ if !strings.Contains(buf.String(), `"locale": "de-DE"`) || !strings.Contains(buf.String(), `"A4": "=A1/4"`) {96+ t.Errorf("file:\n%s", buf.String())97+ }98+ back, err := Read(&buf)99+ if err != nil {100+ t.Fatal(err)101+ }102+ if back.Book().LocaleTag() != "de-DE" || back.Cell(at("A4")).Value != before {103+ t.Errorf("read back %q, %v", back.Book().LocaleTag(), back.Cell(at("A4")).Value)104+ }105+ if _, ok := s.Undo(); !ok {106+ t.Fatal("nothing to undo")107+ }108+ if s.Book().LocaleTag() != "" || shown("A1") != "1,234.50" {109+ t.Errorf("after undo: %q, %q", s.Book().LocaleTag(), shown("A1"))110+ }111+ if s.Book().SetLocale("xx-YY") {112+ t.Error("xx-YY accepted")113+ }114+}115+116+// TestDefaultLocale follows the config's locale unless the file names117+// its own.118+func TestDefaultLocale(t *testing.T) {119+ defer SetDefaultLocale(nil)120+ SetDefaultLocale(mustLocale(t, "fr-FR"))121+ w := NewBook()122+ if w.Locale().Tag != "fr-FR" {123+ t.Errorf("new workbook in %s", w.Locale().Tag)124+ }125+ w.SetLocale("ja-JP")126+ if w.Locale().Tag != "ja-JP" {127+ t.Errorf("workbook in %s", w.Locale().Tag)128+ }129+ SetDefaultLocale(nil)130+ if NewBook().Locale().Tag != "en-US" {131+ t.Error("the default isn't en-US")132+ }133+}
M internal/sheet/looks.go +4 -4
M internal/sheet/move.go +2 -1
M internal/sheet/notes.go +3 -3
M internal/sheet/occupancy.go +28 -3
@@ -15,6 +15,7 @@ import (
1515 type occupancy struct {1616 cols []*colIndex // by column; nil for none1717 colIDs []int // the columns holding cells, ascending18+ n int // the cells marked1819 }1920 2021 // Rows of a column are indexed in blocks of blockRows, one bit each.
@@ -25,9 +26,28 @@ const (
2526 )2627 2728 type rowBlock struct {28- bits [blockWords]uint6429+ bits [blockWords]uint6430+ // rank counts the rows set in the words before each, so a row's31+ // position among the block's is a popcount away (index).32+ rank [blockWords]uint162933 n int3034 stats *blockStats // the filled index's statistics, made on first use; see stats.go35+ vals []slot // the stored index's cells, one per set row in order; see slot.go36+}37+38+// index is the position of row bit of the block among its set rows, and39+// whether it is set.40+func (b *rowBlock) index(bit int) (int, bool) {41+ w, m := bit>>6, uint64(1)<<(bit&63)42+ x := b.bits[w]43+ return int(b.rank[w]) + bits.OnesCount64(x&(m-1)), x&m != 044+}45+46+// count adds d to the rank of the words after word w.47+func (b *rowBlock) count(w, d int) {48+ for j := w + 1; j < blockWords; j++ {49+ b.rank[j] = uint16(int(b.rank[j]) + d)50+ }3151 }3252 3353 // colIndex is the occupancy of one column: its blocks by number (nil
@@ -61,8 +81,8 @@ func (o *occupancy) has(a Addr) bool {
6181 return b != nil && b.bits[bit>>6]&(1<<(bit&63)) != 06282 }6383 64-// mark records a cell at a, which had none.65-func (o *occupancy) mark(a Addr) {84+// mark records a cell at a, which had none, and returns its block.85+func (o *occupancy) mark(a Addr) *rowBlock {6686 ci := o.col(a.Col)6787 if ci == nil {6888 ci = &colIndex{}
@@ -84,8 +104,11 @@ func (o *occupancy) mark(a Addr) {
84104 }85105 bit := a.Row & (blockRows - 1)86106 b.bits[bit>>6] |= 1 << (bit & 63)107+ b.count(bit>>6, 1)87108 b.n++88109 ci.n++110+ o.n++111+ return b89112 }90113 91114 // unmark records that a no longer holds a cell.
@@ -95,8 +118,10 @@ func (o *occupancy) unmark(a Addr) {
95118 b := ci.blocks[id]96119 bit := a.Row & (blockRows - 1)97120 b.bits[bit>>6] &^= 1 << (bit & 63)121+ b.count(bit>>6, -1)98122 b.n--99123 ci.n--124+ o.n--100125 if b.n == 0 {101126 ci.blocks[id] = nil102127 ci.ids = removeSorted(ci.ids, id)
M internal/sheet/ops.go +15 -14
@@ -7,6 +7,7 @@ import (
77 "sync/atomic"88 99 "github.com/FelineStateMachine/012/internal/formula"10+ "github.com/FelineStateMachine/012/internal/locale"1011 )1112 1213 // Errors returned by operations that would lose data or don't fit.
@@ -16,9 +17,9 @@ var (
1617 ErrFillTooBig = errors.New("That would write more cells than max-cells allows (see File > Settings)")1718 )1819 19-// DefaultMaxCells is the max-cells setting's default: about 600 MB of20-// cells at 300 bytes each.21-const DefaultMaxCells = 2_000_00020+// DefaultMaxCells is the max-cells setting's default: about 200 MB of21+// numbers at 20 bytes each (store.go).22+const DefaultMaxCells = 10_000_0002223 2324 // maxCells caps how many cells a paste or fill writes at once, and how2425 // many an import keeps, so an accidental whole-sheet selection or a huge
@@ -65,13 +66,13 @@ func (s *Sheet) remap(label string, focus Rect, cell func(Addr) (Addr, bool), rn
6566 for l, c := range others {6667 l.s.place(l.a, c)6768 }68- for a, c := range s.cells.all() {69- if next[a] != c {70- s.place(a, next[a])69+ for a := range s.cells.keys() {70+ if c := next[a]; !s.cells.holds(a, c) {71+ s.place(a, c)7172 }7273 }7374 for a, c := range next {74- if s.cells.get(a) != c {75+ if !s.cells.holds(a, c) {7576 s.place(a, c)7677 }7778 }
@@ -111,7 +112,7 @@ func (s *Sheet) insert(rows bool, at, n int) error {
111112 if rows {112113 edge = rowRect(first, size-1)113114 }114- for range s.cells.inRange(edge) {115+ for range s.cells.keysIn(edge) {115116 return ErrPushedOff116117 }117118 s.restructure(rows, formula.Span{At: at, N: n, Size: size})
@@ -218,12 +219,12 @@ func (c *Clip) Size() (cols, rows int) {
218219 return c.Src.To.Col - c.Src.From.Col + 1, c.Src.To.Row - c.Src.From.Row + 1219220 }220221 221-// Text returns the clip's values as displayed in General format, row by222+// TextIn returns the clip's values as General shows them in loc, row by222223 // row, for the system clipboard. Blank rows and columns past the last223224 // value are left off, so copying whole columns gives their data; a block224225 // of more than MaxCells cells, blanks between values included, is too225-// big for text, and Text returns nil.226-func (c *Clip) Text() [][]string {226+// big for text, and TextIn returns nil.227+func (c *Clip) TextIn(loc *locale.Locale) [][]string {227228 cols, rows := 0, 0228229 for off, cell := range c.cells {229230 if !cell.Blank() {
@@ -238,7 +239,7 @@ func (c *Clip) Text() [][]string {
238239 out[r] = make([]string, cols)239240 for col := range out[r] {240241 if cell := c.cells[Addr{Col: col, Row: r}]; cell != nil {241- out[r][col] = cell.Value.String()242+ out[r][col] = textIn(cell.Value, loc)242243 }243244 }244245 }
@@ -294,7 +295,7 @@ func (s *Sheet) Paste(c *Clip, dst Rect, values bool) (Rect, error) {
294295 func (s *Sheet) pasteCell(a Addr, c *Cell, dc, dr int, values bool) {295296 switch {296297 case c == nil:297- if s.cells.get(a) != nil {298+ if s.cells.has(a) {298299 s.place(a, nil)299300 }300301 case values:
@@ -415,7 +416,7 @@ func (s *Sheet) seriesStart(r Rect, down bool) (Rect, bool) {
415416 }416417 used := map[int]bool{}417418 for _, a := range s.cellsIn(r) {418- if !s.cells.get(a).Blank() {419+ if s.cells.filledAt(a) {419420 used[line(a)] = true420421 }421422 }
M internal/sheet/pivotlayout.go +1 -1
M internal/sheet/rangememo.go +2 -2
M internal/sheet/recalc.go +5 -4
M internal/sheet/remote.go +1 -1
M internal/sheet/rowlayout.go +4 -4
A internal/sheet/rulemove.go +117 -0
@@ -0,0 +1,117 @@
1+package sheet2+3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/012/internal/formula"7+)8+9+// Conditional formats and validation move with cut and paste on a sheet,10+// as in Sheets: the cut cells take their rules to where they land, the11+// cells they land on lose theirs, and formulas in the rules follow the12+// cells they read.13+14+// moveRules moves the sheet's rules with the cells in src, landing on15+// dst; cell and rng are the move's, for the rules' formulas.16+func (s *Sheet) moveRules(src, dst Rect, cell func(Addr) (Addr, bool), rng func(Rect) (Rect, bool)) {17+ if s.rules.empty() {18+ return19+ }20+ next := s.rules.moved(src, dst, formula.Relocate(s.onThis(s), cell, rng))21+ if !next.equal(s.rules) {22+ s.recordRules()23+ s.rules = next24+ }25+}26+27+// moved is the rules after the cells in src move to dst, with their28+// formulas rewritten by rw. A rule whose cells are partly moved keeps one29+// range list, or, when its formula is relative to its first cell, splits30+// into a rule for the cells that stayed and one for those that moved,31+// each with the formula written for its own first cell.32+func (r rulesState) moved(src, dst Rect, rw formula.Rewriter) rulesState {33+ d := Addr{Col: dst.From.Col - src.From.Col, Row: dst.From.Row - src.From.Row}34+ var out rulesState35+ for _, f := range r.formats {36+ for _, p := range movedParts(f.Ranges, src, dst, d, f.Op == RuleFormula) {37+ g := f38+ g.Ranges = p.ranges39+ if f.Op == RuleFormula {40+ g.Args[0] = reanchor(f.Args[0], p.shift, rw)41+ }42+ out.formats = append(out.formats, g)43+ }44+ }45+ for _, v := range r.validations {46+ for _, p := range movedParts(v.Ranges, src, dst, d, v.Kind == ValidFormula) {47+ w := v.clone()48+ w.Ranges = p.ranges49+ switch v.Kind {50+ case ValidFormula:51+ w.Args[0] = reanchor(v.Args[0], p.shift, rw)52+ case ValidRange:53+ w.Source = strings.TrimPrefix(shiftFormula("="+v.Source, rw), "=")54+ }55+ out.validations = append(out.validations, w)56+ }57+ }58+ return out59+}60+61+// rulePart is the ranges of a rule after a move, and how far its62+// formula's relative references shift: from the rule's first cell to63+// where the part's first cell was before the move.64+type rulePart struct {65+ ranges []Rect66+ shift Addr67+}68+69+// movedParts splits a rule's ranges rs by the move of src to dst (d70+// away): the cells that stay, outside both, and those that move. split71+// keeps the two apart, for a rule with a relative formula.72+func movedParts(rs []Rect, src, dst Rect, d Addr, split bool) []rulePart {73+ var kept, moved []Rect74+ for _, r := range rs {75+ for _, k := range subtract(r, src) {76+ kept = append(kept, subtract(k, dst)...)77+ }78+ if in, ok := overlap(r, src); ok {79+ moved = append(moved, Rect{From: plus(in.From, d), To: plus(in.To, d)})80+ }81+ }82+ from := anchor(rs)83+ keptPart := func() rulePart { return rulePart{kept, minus(anchor(kept), from)} }84+ movedPart := func() rulePart { return rulePart{moved, minus(minus(anchor(moved), d), from)} }85+ switch {86+ case len(moved) == 0 && len(kept) == 0:87+ return nil88+ case len(moved) == 0:89+ return []rulePart{keptPart()}90+ case len(kept) == 0:91+ return []rulePart{movedPart()}92+ case split:93+ return []rulePart{keptPart(), movedPart()}94+ }95+ return []rulePart{{ranges: append(kept, moved...)}}96+}97+98+// reanchor rewrites a rule's formula (with its "=") for a first cell99+// shift away, then for the move (rw).100+func reanchor(src string, shift Addr, rw formula.Rewriter) string {101+ if shift != (Addr{}) {102+ src = shiftFormula(src, formula.Shift(shift.Col, shift.Row))103+ }104+ return shiftFormula(src, rw)105+}106+107+func plus(a, d Addr) Addr { return Addr{Col: a.Col + d.Col, Row: a.Row + d.Row} }108+func minus(a, d Addr) Addr { return Addr{Col: a.Col - d.Col, Row: a.Row - d.Row} }109+110+// overlap is the cells a and b share, and whether they share any.111+func overlap(a, b Rect) (Rect, bool) {112+ r := Rect{113+ From: Addr{Col: max(a.From.Col, b.From.Col), Row: max(a.From.Row, b.From.Row)},114+ To: Addr{Col: min(a.To.Col, b.To.Col), Row: min(a.To.Row, b.To.Row)},115+ }116+ return r, r.From.Col <= r.To.Col && r.From.Row <= r.To.Row117+}
A internal/sheet/rulemove_test.go +75 -0
@@ -0,0 +1,75 @@
1+package sheet2+3+import "testing"4+5+// Rules move with cut and paste: the cut cells take them along, the6+// cells they land on lose theirs, and relative formulas stay right for7+// the cells that stayed and those that moved.8+func TestRulesMoveWithCells(t *testing.T) {9+ s := New()10+ for r := range 10 {11+ s.Set(Addr{Row: r}, "5")12+ }13+ mustAdd(t, s, CondFormat{Ranges: ranges("A1:A10"), Op: RuleFormula, Args: [2]string{"=A1>4"}, Style: green})14+ mustAdd(t, s, CondFormat{Ranges: ranges("A1:A10"), Op: RuleGreater, Args: [2]string{"3"}, Style: green})15+ if err := s.AddValidation(Validation{Ranges: ranges("A1:A10"), Kind: ValidNumber, Op: RuleBetween, Args: [2]string{"1", "9"}, Reject: true}); err != nil {16+ t.Fatal(err)17+ }18+ if err := s.AddValidation(Validation{Ranges: ranges("C1:C3"), Kind: ValidList, Items: []string{"x"}}); err != nil {19+ t.Fatal(err)20+ }21+ if _, err := s.Move(rect("A1:A3"), at("C2")); err != nil {22+ t.Fatal(err)23+ }24+ fs, vs := s.CondFormats(), s.Validations()25+ if len(fs) != 3 {26+ t.Fatalf("formats %+v", fs)27+ }28+ // The formula rule splits: A4:A10 reads its own cells, and the moved29+ // cells read theirs, at their new places.30+ if RangesText(fs[0].Ranges) != "A4:A10" || fs[0].Args[0] != "=A4>4" {31+ t.Errorf("kept part: %s %s", RangesText(fs[0].Ranges), fs[0].Args[0])32+ }33+ if RangesText(fs[1].Ranges) != "C2:C4" || fs[1].Args[0] != "=C2>4" {34+ t.Errorf("moved part: %s %s", RangesText(fs[1].Ranges), fs[1].Args[0])35+ }36+ if RangesText(fs[2].Ranges) != "A4:A10,C2:C4" {37+ t.Errorf("a plain rule keeps one list: %s", RangesText(fs[2].Ranges))38+ }39+ // The list rule loses the cells landed on; the number rule reaches them.40+ if len(vs) != 2 || RangesText(vs[0].Ranges) != "A4:A10,C2:C4" || RangesText(vs[1].Ranges) != "C1" {41+ t.Errorf("validations %s, %s", RangesText(vs[0].Ranges), RangesText(vs[len(vs)-1].Ranges))42+ }43+ if !s.Look(at("C3")).Styled || s.Look(at("A2")).Styled {44+ t.Error("looks don't follow the move")45+ }46+ s.Undo()47+ if fs := s.CondFormats(); len(fs) != 2 || RangesText(fs[0].Ranges) != "A1:A10" || fs[0].Args[0] != "=A1>4" {48+ t.Errorf("undo: %+v", fs)49+ }50+}51+52+// A paste or fill is checked after the fact: InvalidIn lists what fails,53+// and Discard takes the change back without leaving it to redo.54+func TestInvalidInAndDiscard(t *testing.T) {55+ s := New()56+ if err := s.AddValidation(Validation{Ranges: ranges("B1:B9"), Kind: ValidNumber, Op: RuleBetween, Args: [2]string{"1", "10"}, Reject: true}); err != nil {57+ t.Fatal(err)58+ }59+ s.Set(at("A1"), "5")60+ s.Set(at("A2"), "50")61+ s.Set(at("A3"), "x")62+ if _, err := s.Paste(s.Copy(rect("A1:A3")), rect("B1"), false); err != nil {63+ t.Fatal(err)64+ }65+ bad := s.InvalidIn(rect("B1:B3"))66+ if len(bad) != 2 || bad[0].Addr != at("B2") || bad[1].Addr != at("B3") || !bad[0].Reject {67+ t.Fatalf("invalid %+v", bad)68+ }69+ if !s.Discard() || s.Value(at("B2")).Kind != Empty || s.CanRedo() {70+ t.Errorf("discard: B2 %v, redo %v", s.Value(at("B2")), s.CanRedo())71+ }72+ if len(s.InvalidIn(rect("B1:B3"))) != 0 {73+ t.Error("still invalid after discarding")74+ }75+}
M internal/sheet/series.go +1 -1
M internal/sheet/sheet.go +17 -29
@@ -121,30 +121,21 @@ func New() *Sheet {
121121 // formatting. Use Blank to test for contents.122122 func (s *Sheet) Cell(a Addr) *Cell { return s.cells.get(a) }123123 124+// Filled reports whether the cell at a has contents: !Cell(a).Blank(),125+// without making the cell.126+func (s *Sheet) Filled(a Addr) bool { return s.cells.filledAt(a) }127+124128 // Value returns the computed value at a.125-func (s *Sheet) Value(a Addr) Value {126- if c := s.cells.get(a); c != nil {127- return c.Value128- }129- return Value{}130-}129+func (s *Sheet) Value(a Addr) Value { return s.cells.value(a) }131130 132131 // Len returns the number of non-blank cells.133-func (s *Sheet) Len() int {134- n := 0135- for _, c := range s.cells.all() {136- if !c.Blank() {137- n++138- }139- }140- return n141-}132+func (s *Sheet) Len() int { return s.cells.filledLen() }142133 143134 // Addrs returns every non-blank cell in row-major order.144135 func (s *Sheet) Addrs() []Addr {145- out := make([]Addr, 0, s.cells.len())146- for a, c := range s.cells.all() {147- if !c.Blank() {136+ out := make([]Addr, 0, s.cells.filledLen())137+ for a := range s.cells.keys() {138+ if s.cells.filledAt(a) {148139 out = append(out, a)149140 }150141 }
@@ -249,12 +240,8 @@ func (s *Sheet) Set(a Addr, input string) error {
249240 // put stores an entry without recalculating, keeping the cell's250241 // formatting.251242 func (s *Sheet) put(a Addr, input string) error {252- var f Format253- var st Style254- var note string255- if old := s.cells.get(a); old != nil {256- f, st, note = old.Format, old.Style, old.Note257- }243+ f, st, _ := s.cells.look(a)244+ note := s.Note(a)258245 if f.IsZero() && !st.own && s.inherited(a).Format.Kind == FmtText {259246 f = Format{Kind: FmtText} // typed into a plain text column: text260247 }
@@ -393,8 +380,9 @@ func (s *Sheet) place(a Addr, c *Cell) {
393380 func (s *Sheet) EraseRange(r Rect) {394381 s.change("clear "+r.String(), r, func() {395382 for _, a := range s.cellsIn(r) {396- if c := s.cells.get(a); !c.Blank() {397- s.place(a, c.leftover())383+ if s.cells.filledAt(a) {384+ f, st, _ := s.cells.look(a)385+ s.place(a, formattingOnly(f, st).withNote(s.Note(a)))398386 }399387 }400388 })
@@ -403,7 +391,7 @@ func (s *Sheet) EraseRange(r Rect) {
403391 // cellsIn returns the cells in r that have contents or formatting.404392 func (s *Sheet) cellsIn(r Rect) []Addr {405393 var out []Addr406- for a := range s.cells.anyInRange(r) {394+ for a := range s.cells.anyKeysIn(r) {407395 out = append(out, a)408396 }409397 return out
@@ -419,9 +407,9 @@ func (s *Sheet) unlink(a Addr) {
419407 // unindex removes the dependency edges of the cell at a, reporting420408 // whether there is one.421409 func (s *Sheet) unindex(a Addr) bool {422- old := s.cells.get(a)410+ old := s.cells.richAt(a) // plain cells have no edges423411 if old == nil {424- return false412+ return s.cells.has(a)425413 }426414 for _, r := range old.refs {427415 delete(s.dependents[r], a)
A internal/sheet/slot.go +284 -0
@@ -0,0 +1,284 @@
1+package sheet2+3+import (4+ "math"5+ "strconv"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/formula"9+)10+11+// A slot is how cellStore keeps one cell: 16 bytes in its column's12+// block. Most cells are plain, an entry typed or imported that is a13+// number, a boolean or text, with at most a format and a style; their14+// slot holds the value itself, the text in the store's string table, the15+// format and style in its table of looks, and the input only when it16+// isn't the value's own text. Everything else (formulas, notes, what17+// pivots and spills write, text typed with a leading ') is rich: the18+// slot points at a whole Cell in the store's side table.19+type slot struct {20+ num float64 // slotNum's value; slotBool's, 1 or 021+ // ref is slotText's text in strs; slotNum's or slotBool's input in22+ // strs when it isn't the value's text (0 when it is); slotRich's cell23+ // in rich.24+ ref uint3225+ look uint16 // the format and style in looks; 0 for neither26+ kind uint827+ // dec is how a slotNum without ref prints its input: the value with28+ // dec-1 decimals, or its shortest text when 0.29+ dec uint830+}31+32+// Kinds of slot.33+const (34+ slotBlank uint8 = iota // formatting only35+ slotNum36+ slotBool37+ slotText38+ slotRich39+)40+41+// maxDec bounds the decimals a number's input may have to be kept as a42+// count rather than as text.43+const maxDec = 4044+45+// look is a cell's format and style, kept once per store however many46+// cells share it.47+type look struct {48+ f Format49+ st Style50+}51+52+// strTable holds the text of plain cells, each distinct string once,53+// counted by the cells using it; entry 0 is unused.54+type strTable struct {55+ strs []string56+ refs []uint3257+ index map[string]uint3258+ free []uint3259+}60+61+// add counts one more use of s and returns its entry.62+func (t *strTable) add(s string) uint32 {63+ if i, ok := t.index[s]; ok {64+ t.refs[i]++65+ return i66+ }67+ if t.index == nil {68+ t.index = map[string]uint32{}69+ t.strs, t.refs = []string{""}, []uint32{0}70+ }71+ var i uint3272+ if n := len(t.free); n > 0 {73+ i, t.free = t.free[n-1], t.free[:n-1]74+ t.strs[i], t.refs[i] = s, 175+ } else {76+ i = uint32(len(t.strs))77+ t.strs, t.refs = append(t.strs, s), append(t.refs, 1)78+ }79+ t.index[s] = i80+ return i81+}82+83+// release counts one fewer use of entry i, if it is one.84+func (t *strTable) release(i uint32) {85+ if i == 0 {86+ return87+ }88+ if t.refs[i]--; t.refs[i] == 0 {89+ delete(t.index, t.strs[i])90+ t.strs[i] = ""91+ t.free = append(t.free, i)92+ }93+}94+95+// lookID is the entry of l in the table of looks, added if new, and96+// false when the table is full.97+func (st *cellStore) lookID(f Format, sty Style) (uint16, bool) {98+ l := look{f, sty}99+ if l == (look{}) {100+ return 0, true101+ }102+ if i, ok := st.lookIdx[l]; ok {103+ return i, true104+ }105+ if len(st.looks) > math.MaxUint16 {106+ return 0, false107+ }108+ if st.lookIdx == nil {109+ st.lookIdx = map[look]uint16{}110+ st.looks = []look{{}}111+ }112+ i := uint16(len(st.looks))113+ st.looks = append(st.looks, l)114+ st.lookIdx[l] = i115+ return i, true116+}117+118+// lookOf is sl's format and style.119+func (st *cellStore) lookOf(sl slot) look {120+ if sl.look == 0 {121+ return look{}122+ }123+ return st.looks[sl.look]124+}125+126+// plainSlot is c as a plain slot, or false if c is rich. It adds the127+// strings it uses to the table.128+func (st *cellStore) plainSlot(c *Cell) (slot, bool) {129+ if c.Note != "" || c.derived || c.spilled || !c.auto.IsZero() || strings.HasPrefix(c.Input, "=") {130+ return slot{}, false131+ }132+ lk, ok := st.lookID(c.Format, c.Style)133+ if !ok {134+ return slot{}, false135+ }136+ sl := slot{look: lk}137+ switch e := c.expr.(type) {138+ case nil:139+ switch {140+ case c.Input == "":141+ sl.kind = slotBlank142+ case c.Format.Kind != FmtText && strings.HasPrefix(c.Input, "'"):143+ return slot{}, false // its value isn't its input144+ default:145+ sl.kind, sl.ref = slotText, st.strs.add(c.Input)146+ }147+ case formula.Num:148+ if math.IsNaN(e.V) || math.IsInf(e.V, 0) {149+ return slot{}, false // its value is #NUM!150+ }151+ sl.kind, sl.num = slotNum, e.V152+ if d, ok := numDec(e.V, c.Input); ok {153+ sl.dec = d154+ } else {155+ sl.ref = st.strs.add(c.Input)156+ }157+ case formula.Bool:158+ sl.kind = slotBool159+ if e.V {160+ sl.num = 1161+ }162+ if c.Input != boolText(e.V) {163+ sl.ref = st.strs.add(c.Input)164+ }165+ default:166+ return slot{}, false167+ }168+ return sl, true169+}170+171+// numDec is how input prints v, as slot.dec, or false if it isn't v172+// printed plainly.173+func numDec(v float64, input string) (uint8, bool) {174+ var buf [64]byte175+ if string(strconv.AppendFloat(buf[:0], v, 'f', -1, 64)) == input {176+ return 0, true177+ }178+ i := strings.IndexByte(input, '.')179+ if i < 0 || len(input)-i-1 >= maxDec {180+ return 0, false181+ }182+ d := len(input) - i - 1183+ if string(strconv.AppendFloat(buf[:0], v, 'f', d, 64)) == input {184+ return uint8(d + 1), true185+ }186+ return 0, false187+}188+189+func boolText(b bool) string {190+ if b {191+ return "TRUE"192+ }193+ return "FALSE"194+}195+196+// input is the entry of a plain slot.197+func (st *cellStore) input(sl slot) string {198+ switch {199+ case sl.kind == slotBlank:200+ return ""201+ case sl.ref != 0:202+ return st.strs.strs[sl.ref]203+ case sl.kind == slotBool:204+ return boolText(sl.num != 0)205+ }206+ return strconv.FormatFloat(sl.num, 'f', int(sl.dec)-1, 64)207+}208+209+// slotValue is the value of a plain slot.210+func (st *cellStore) slotValue(sl slot) Value {211+ switch sl.kind {212+ case slotNum:213+ return Value{Kind: Number, Num: sl.num}214+ case slotBool:215+ return Value{Kind: Bool, Num: sl.num}216+ case slotText:217+ return Value{Kind: Text, Str: st.strs.strs[sl.ref]}218+ }219+ return Value{}220+}221+222+// view is a plain slot as a Cell of its own: changing it changes nothing223+// stored.224+func (st *cellStore) view(sl slot) *Cell {225+ lk := st.lookOf(sl)226+ c := &Cell{Input: st.input(sl), Value: st.slotValue(sl), Format: lk.f, Style: lk.st}227+ switch sl.kind {228+ case slotNum:229+ c.expr = formula.Num{V: sl.num}230+ case slotBool:231+ c.expr = formula.Bool{V: sl.num != 0}232+ }233+ return c234+}235+236+// releaseSlot counts one fewer use of what sl refers to.237+func (st *cellStore) releaseSlot(sl slot) {238+ switch sl.kind {239+ case slotRich:240+ st.rich[sl.ref] = richCell{}241+ st.richFree = append(st.richFree, sl.ref)242+ case slotNum, slotBool, slotText:243+ st.strs.release(sl.ref)244+ }245+}246+247+// Estimated heap of the store's parts, for the undo history's budget248+// (historysize.go): a slot with its share of the blocks and indexes, as249+// BenchmarkMemory measures it, and a string's entry in the table.250+const (251+ slotBytes = 20252+ strBytes = 64253+)254+255+// size estimates the heap the cells hold: a slot each, and the rich256+// cells and strings on top.257+func (st *cellStore) size() int64 {258+ n := int64(st.len()) * slotBytes259+ for _, rc := range st.rich {260+ n += cellSize(rc.c)261+ }262+ for _, s := range st.strs.strs {263+ n += strBytes + int64(len(s))264+ }265+ return n266+}267+268+// richCell is a rich cell and where it is.269+type richCell struct {270+ c *Cell271+ a Addr272+}273+274+// addRich stores c, at a, in the side table and returns its entry.275+func (st *cellStore) addRich(a Addr, c *Cell) uint32 {276+ if n := len(st.richFree); n > 0 {277+ i := st.richFree[n-1]278+ st.richFree = st.richFree[:n-1]279+ st.rich[i] = richCell{c, a}280+ return i281+ }282+ st.rich = append(st.rich, richCell{c, a})283+ return uint32(len(st.rich) - 1)284+}
A internal/sheet/slot_test.go +144 -0
@@ -0,0 +1,144 @@
1+package sheet2+3+import (4+ "reflect"5+ "strings"6+ "testing"7+8+ "github.com/FelineStateMachine/012/internal/formula"9+)10+11+// literalValue is what recalculation computes for an entry that isn't a12+// formula (evaluate.go).13+func literalValue(c *Cell) Value {14+ switch e := c.expr.(type) {15+ case formula.Num:16+ return num(e.V)17+ case formula.Bool:18+ return boolean(e.V)19+ }20+ switch {21+ case c.Input == "":22+ return Value{}23+ case c.Format.Kind == FmtText:24+ return Value{Kind: Text, Str: c.Input}25+ }26+ return Value{Kind: Text, Str: strings.TrimPrefix(c.Input, "'")}27+}28+29+// roundTripCells are cells of every kind: entries of each sort under30+// formats, styles and notes, with the values recalculation gives them.31+func roundTripCells() []*Cell {32+ inputs := []string{33+ "1", "1.50", "-0", "0.1", "1e5", "007", "1.0000000000000000001", "123456789012345678901234567890",34+ "$1,200", "12%", "9/26/2026", "2026-09-26", "13:45", "TRUE", "true", "FALSE", "hello", "'123", "'",35+ "=A1", "=5", "=TRUE", "+5", "-5", "+A1", " 7 ", "ünïcode", "#N/A", "1e400", "",36+ }37+ formats := []Format{{}, Preset(FmtCurrency), {Kind: FmtText}, {Kind: FmtDate, Pattern: "yyyy-mm-dd"}}38+ styles := []Style{{}, {Bold: true}, {Align: AlignRight, own: true}}39+ var out []*Cell40+ for i := range len(inputs) * len(formats) * len(styles) * 4 {41+ input, f := inputs[i%len(inputs)], formats[i/len(inputs)%len(formats)]42+ sty, rest := styles[i/len(inputs)/len(formats)%len(styles)], i/len(inputs)/len(formats)/len(styles)43+ c, err := newCell(input, f, sty, rest&1 == 1)44+ if rest&2 == 2 {45+ c = c.withNote("a note")46+ }47+ if err != nil || c == nil {48+ continue49+ }50+ c.Value = literalValue(c)51+ if c.expr != nil && !c.literal() {52+ c.Value = Value{} // a formula: only recalculation sets it53+ }54+ out = append(out, c)55+ }56+ return out57+}58+59+// Every cell comes back from the store as it went in, whether it is kept60+// as a plain slot or whole: its entry exactly as typed, its value, format,61+// style, note and expression.62+func TestSlotRoundTrip(t *testing.T) {63+ st := newCellStore()64+ a := Addr{Col: 3, Row: 1500}65+ cells := roundTripCells()66+ for _, c := range cells {67+ want := c.clone()68+ st.set(a, c)69+ checkSame(t, st.get(a), want)70+ checkSame(t, st.copyOf(a), want)71+ if v := st.value(a); v != want.Value {72+ t.Errorf("%q: value %v, want %v", want.Input, v, want.Value)73+ }74+ if got := st.filledAt(a); got != !want.Blank() {75+ t.Errorf("%q: filled %v", want.Input, got)76+ }77+ if !st.holds(a, st.get(a)) {78+ t.Errorf("%q: doesn't hold its own view", want.Input)79+ }80+ }81+ st.delete(a)82+ if st.len() != 0 || len(st.strs.index) != 0 || len(st.rich) != len(st.richFree) {83+ t.Errorf("after %d cells: %d stored, %d strings, %d rich", len(cells), st.len(), len(st.strs.index), len(st.rich)-len(st.richFree))84+ }85+}86+87+// literal reports whether c's expression is a typed number or boolean,88+// which recalculation doesn't evaluate as a formula.89+func (c *Cell) literal() bool {90+ switch c.expr.(type) {91+ case formula.Num, formula.Bool:92+ return !strings.HasPrefix(c.Input, "=")93+ }94+ return false95+}96+97+func checkSame(t *testing.T, got, want *Cell) {98+ t.Helper()99+ if got.Input != want.Input || got.Value != want.Value || got.Format != want.Format || got.Style != want.Style ||100+ got.Note != want.Note || got.auto != want.auto || got.derived != want.derived || got.spilled != want.spilled ||101+ !reflect.DeepEqual(got.expr, want.expr) || got.IsFormula() != want.IsFormula() {102+ t.Errorf("stored %+v\ncame back %+v", want, got)103+ }104+}105+106+// Cells sharing a string keep it while any of them uses it, and a cell107+// turning from plain to rich and back keeps its place.108+func TestSlotStrings(t *testing.T) {109+ st := newCellStore()110+ a1, a2 := Addr{}, Addr{Row: 1}111+ st.set(a1, &Cell{Input: "same"})112+ st.set(a2, &Cell{Input: "same"})113+ if len(st.strs.index) != 1 {114+ t.Fatalf("%d strings for one text", len(st.strs.index))115+ }116+ st.delete(a1)117+ if c := st.get(a2); c.Input != "same" || c.Value.Str != "same" {118+ t.Errorf("the other cell is %+v", c)119+ }120+ rich := &Cell{Input: "same", Note: "n"}121+ st.set(a2, rich)122+ if st.get(a2) != rich || st.richAt(a2) != rich || len(st.strs.index) != 0 {123+ t.Errorf("rich cell not kept whole, %d strings", len(st.strs.index))124+ }125+ num, _ := newCell("12.50", Format{}, Style{}, true)126+ st.set(a2, num)127+ if c := st.get(a2); c.Input != "12.50" || c.Value.Num != 12.5 || st.richAt(a2) != nil {128+ t.Errorf("back to plain: %+v", c)129+ }130+ if len(st.rich) != len(st.richFree) {131+ t.Errorf("%d rich cells left", len(st.rich)-len(st.richFree))132+ }133+}134+135+// A plain number costs its slot: no string, no Cell.136+func TestSlotCompact(t *testing.T) {137+ s := New()138+ for row := range 2048 {139+ s.Load(Addr{Row: row}, []string{"1", "2.5", "3.10", "-4"}[row%4], Format{}, Style{})140+ }141+ if n := len(s.cells.rich) + len(s.cells.strs.strs); n != 0 {142+ t.Errorf("%d rich cells and strings for plain numbers", n)143+ }144+}
M internal/sheet/stats.go +9 -11
M internal/sheet/stats_test.go +4 -4
M internal/sheet/store.go +302 -60
@@ -2,71 +2,285 @@ package sheet
22 33 import (44 "iter"5- "maps"5+ "math"66 "math/bits"77 "slices"8+ "strconv"9+10+ "github.com/FelineStateMachine/012/internal/formula"811 )912 1013 // cellStore holds a sheet's cells: those with contents or formatting, by1114 // address. The rest of the engine reads and writes cells only through12-// these methods, never the map beneath, so the representation can change13-// (to column blocks of compact values, with formulas and formats in side14-// tables; see docs/contributing/limits.md) without touching it.15+// these methods, never the representation beneath.16+//17+// Cells live in columns of blocks of blockRows rows: the stored18+// occupancy index (occupancy.go) has a bitmap per block of the rows19+// holding a cell, and each block keeps their slots (slot.go) in row20+// order, so a cell is found by indexing a column, a block and a popcount,21+// without hashing. A plain number costs its 16-byte slot. Rich cells22+// (formulas, notes, pivots' and spills' results) are whole Cells in a23+// side table the slot points at.24+//25+// get returns a rich cell's own Cell, which recalculation updates in26+// place, as ever, and a plain cell as a Cell made for the caller (a27+// view): the caller may keep it, but changing it changes nothing28+// stored; set is the one way to change a cell. Views cost an allocation29+// or three, so what reads many cells asks for what it needs: peek and30+// value for values, has and filledAt for presence, richAt for formulas,31+// look for formatting, and the key iterators for addresses.1532 //16-// Next to the map, two occupancy indexes (occupancy.go) record which17-// cells are stored and which have contents, so a range is read at the18-// cost of what it holds, and the used range and data edges are found19-// without scanning.33+// A second occupancy index records which cells have contents, so the34+// used range and data edges are found without scanning.2035 //21-// Iteration order of all is unspecified; inRange yields in row-major22-// order. Cells may be set or deleted while iterating, with a map's rules:23-// a deleted cell not yet reached isn't yielded, a new one may or may not24-// be.36+// Iteration yields column by column, except inRange and keysIn, which37+// yield in row-major order. Cells may be set or deleted while iterating,38+// with a map's rules: a deleted cell not yet reached isn't yielded, a new39+// one may or may not be.2540 type cellStore struct {26- m map[Addr]*Cell27- stored occupancy // every cell in m41+ stored occupancy // every cell, with the blocks holding their slots2842 filled occupancy // the cells with contents (not Blank), with their statistics; see stats.go2943 statsUsed bool // a selection has been summed from the statistics index44+45+ strs strTable // plain cells' text46+ looks []look // formats and styles, by slot.look47+ lookIdx map[look]uint16 // looks' entries48+ rich []richCell // rich cells, by slot.ref49+ richFree []uint32 // entries of rich not in use50+}51+52+func newCellStore() cellStore { return cellStore{} }53+54+// find returns the block holding a's slot and its position there, or nil.55+func (st *cellStore) find(a Addr) (*rowBlock, int) {56+ b := st.stored.col(a.Col).block(a.Row >> blockShift)57+ if b == nil {58+ return nil, 059+ }60+ i, ok := b.index(a.Row & (blockRows - 1))61+ if !ok {62+ return nil, 063+ }64+ return b, i65+}66+67+// get returns the cell at a, or nil: a rich cell itself, a plain one as68+// a view.69+func (st *cellStore) get(a Addr) *Cell {70+ b, i := st.find(a)71+ if b == nil {72+ return nil73+ }74+ return st.cellOf(b.vals[i])75+}76+77+func (st *cellStore) cellOf(sl slot) *Cell {78+ if sl.kind == slotRich {79+ return st.rich[sl.ref].c80+ }81+ return st.view(sl)82+}83+84+// peek returns the rich cell at a, or else the plain cell's value, and85+// whether a holds a cell at all. It allocates nothing: recalculation86+// reads every cell through it.87+func (st *cellStore) peek(a Addr) (*Cell, Value, bool) {88+ b, i := st.find(a)89+ if b == nil {90+ return nil, Value{}, false91+ }92+ sl := b.vals[i]93+ if sl.kind == slotRich {94+ return st.rich[sl.ref].c, Value{}, true95+ }96+ return nil, st.slotValue(sl), true97+}98+99+// value returns the value of the cell at a.100+func (st *cellStore) value(a Addr) Value {101+ c, v, _ := st.peek(a)102+ if c != nil {103+ return c.Value104+ }105+ return v30106 }31107 32-func newCellStore() cellStore { return cellStore{m: make(map[Addr]*Cell)} }108+// has reports whether a holds a cell, if only formatting.109+func (st *cellStore) has(a Addr) bool { return st.stored.has(a) }33110 34-// get returns the cell at a, or nil.35-func (st *cellStore) get(a Addr) *Cell { return st.m[a] }111+// filledAt reports whether the cell at a has contents: !get(a).Blank().112+func (st *cellStore) filledAt(a Addr) bool { return st.filled.has(a) }36113 37-// set stores c, which isn't nil, at a.114+// richAt returns the cell at a if it is rich (a formula, a note, a115+// pivot's or a spill's result), or nil.116+func (st *cellStore) richAt(a Addr) *Cell {117+ b, i := st.find(a)118+ if b == nil || b.vals[i].kind != slotRich {119+ return nil120+ }121+ return st.rich[b.vals[i].ref].c122+}123+124+// look returns the format and style of the cell at a, and whether there125+// is one.126+func (st *cellStore) look(a Addr) (Format, Style, bool) {127+ b, i := st.find(a)128+ if b == nil {129+ return Format{}, Style{}, false130+ }131+ if sl := b.vals[i]; sl.kind == slotRich {132+ c := st.rich[sl.ref].c133+ return c.Format, c.Style, true134+ } else {135+ l := st.lookOf(sl)136+ return l.f, l.st, true137+ }138+}139+140+// holds reports whether the cell at a is c: the same Cell if rich, the141+// same contents and formatting if plain; nil for none.142+func (st *cellStore) holds(a Addr, c *Cell) bool {143+ b, i := st.find(a)144+ if b == nil || c == nil {145+ return b == nil && c == nil146+ }147+ sl := b.vals[i]148+ if sl.kind == slotRich {149+ return st.rich[sl.ref].c == c150+ }151+ if l := st.lookOf(sl); c.Note != "" || c.derived || c.spilled || !c.auto.IsZero() || c.Format != l.f || c.Style != l.st {152+ return false153+ }154+ switch sl.kind {155+ case slotText:156+ return c.expr == nil && c.Input == st.strs.strs[sl.ref]157+ case slotNum:158+ e, ok := c.expr.(formula.Num)159+ return ok && math.Float64bits(e.V) == math.Float64bits(sl.num) && st.inputIs(sl, c.Input)160+ case slotBool:161+ e, ok := c.expr.(formula.Bool)162+ return ok && e.V == (sl.num != 0) && st.inputIs(sl, c.Input)163+ }164+ return c.expr == nil && c.Input == ""165+}166+167+// inputIs reports whether input is the entry of the plain number or168+// boolean sl, without making it.169+func (st *cellStore) inputIs(sl slot, input string) bool {170+ switch {171+ case sl.ref != 0:172+ return input == st.strs.strs[sl.ref]173+ case sl.kind == slotBool:174+ return input == boolText(sl.num != 0)175+ }176+ var buf [64]byte177+ return string(strconv.AppendFloat(buf[:0], sl.num, 'f', int(sl.dec)-1, 64)) == input178+}179+180+// copyOf is the cell at a as a Cell the caller owns, or nil.181+func (st *cellStore) copyOf(a Addr) *Cell {182+ b, i := st.find(a)183+ if b == nil {184+ return nil185+ }186+ if sl := b.vals[i]; sl.kind == slotRich {187+ return st.rich[sl.ref].c.clone()188+ } else {189+ return st.view(sl)190+ }191+}192+193+// set stores c, which isn't nil, at a. A rich c is kept as it is, so194+// recalculation can update it in place; a plain one is copied into a195+// slot, and its value is its entry's from then on.38196 func (st *cellStore) set(a Addr, c *Cell) {39- old, ok := st.m[a]40- if !ok {41- st.stored.mark(a)197+ b, i := st.find(a)198+ if b == nil {199+ b = st.stored.mark(a)200+ i, _ = b.index(a.Row & (blockRows - 1))201+ b.vals = slices.Insert(b.vals, i, slot{kind: slotBlank})202+ }203+ old := b.vals[i]204+ sl, ok := st.plainSlot(c) // before releasing old, which may share its strings205+ switch {206+ case ok:207+ st.releaseSlot(old)208+ case old.kind == slotRich:209+ st.rich[old.ref] = richCell{c, a}210+ sl = old211+ default:212+ st.releaseSlot(old)213+ sl = slot{kind: slotRich, ref: st.addRich(a, c)}42214 }43- switch was, is := ok && !old.Blank(), !c.Blank(); {215+ b.vals[i] = sl216+ st.filledAs(a, st.filled.has(a), !c.Blank())217+ st.touch(a)218+}219+220+// filledAs updates the index of filled cells for a cell at a that was221+// and is filled or not.222+func (st *cellStore) filledAs(a Addr, was, is bool) {223+ switch {44224 case is && !was:45225 st.filled.mark(a)46226 case was && !is:47227 st.filled.unmark(a)48228 }49- st.m[a] = c50- st.touch(a)51229 }52230 53231 // delete removes the cell at a, if any.54232 func (st *cellStore) delete(a Addr) {55- if old, ok := st.m[a]; ok {56- st.touch(a)57- st.stored.unmark(a)58- if !old.Blank() {59- st.filled.unmark(a)60- }61- delete(st.m, a)233+ b, i := st.find(a)234+ if b == nil {235+ return62236 }237+ st.touch(a)238+ old := b.vals[i]239+ if st.filled.has(a) {240+ st.filled.unmark(a)241+ }242+ b.vals = slices.Delete(b.vals, i, i+1)243+ st.stored.unmark(a)244+ st.releaseSlot(old)63245 }64246 65247 // len is the number of cells stored, formatting-only ones included.66-func (st *cellStore) len() int { return len(st.m) }248+func (st *cellStore) len() int { return st.stored.n }249+250+// filledLen is the number of cells with contents.251+func (st *cellStore) filledLen() int { return st.filled.n }252+253+// all yields every stored cell, plain ones as views.254+func (st *cellStore) all() iter.Seq2[Addr, *Cell] { return st.cellsOf(st.keys()) }255+256+// keys yields the address of every stored cell, column by column.257+func (st *cellStore) keys() iter.Seq[Addr] {258+ return st.anyKeysIn(Rect{To: Addr{Col: MaxCols - 1, Row: MaxRows - 1}})259+}67260 68-// all yields every stored cell.69-func (st *cellStore) all() iter.Seq2[Addr, *Cell] { return maps.All(st.m) }261+// richCells yields every rich cell: the formulas, notes and results of262+// pivots and spills.263+func (st *cellStore) richCells() iter.Seq2[Addr, *Cell] {264+ return func(yield func(Addr, *Cell) bool) {265+ for i := 0; i < len(st.rich); i++ {266+ if rc := st.rich[i]; rc.c != nil && !yield(rc.a, rc.c) {267+ return268+ }269+ }270+ }271+}272+273+// cellsOf yields the cells at the addresses of keys, skipping any no274+// longer stored.275+func (st *cellStore) cellsOf(keys iter.Seq[Addr]) iter.Seq2[Addr, *Cell] {276+ return func(yield func(Addr, *Cell) bool) {277+ for a := range keys {278+ if c := st.get(a); c != nil && !yield(a, c) {279+ return280+ }281+ }282+ }283+}70284 71285 // bounds returns the smallest range holding every stored cell of r, and72286 // false if r holds none.
@@ -86,8 +300,24 @@ func (st *cellStore) colFill(c, r0, r1 int, dst []Addr) int {
86300 87301 // inRange yields the stored cells in r in row-major order, visiting only88302 // the rows of each column that hold cells.89-func (st *cellStore) inRange(r Rect) iter.Seq2[Addr, *Cell] {90- return func(yield func(Addr, *Cell) bool) {303+func (st *cellStore) inRange(r Rect) iter.Seq2[Addr, *Cell] { return st.cellsOf(st.keysIn(r)) }304+305+// valuesIn yields the values of the stored cells in r in row-major306+// order, without making views of them.307+func (st *cellStore) valuesIn(r Rect) iter.Seq2[Addr, Value] {308+ return func(yield func(Addr, Value) bool) {309+ for a := range st.keysIn(r) {310+ if !yield(a, st.value(a)) {311+ return312+ }313+ }314+ }315+}316+317+// keysIn yields the addresses of the stored cells in r in row-major318+// order.319+func (st *cellStore) keysIn(r Rect) iter.Seq[Addr] {320+ return func(yield func(Addr) bool) {91321 cols := slices.Clone(st.stored.colsIn(r.From.Col, r.To.Col))92322 if len(cols) == 0 || r.From.Row > r.To.Row {93323 return
@@ -96,8 +326,8 @@ func (st *cellStore) inRange(r Rect) iter.Seq2[Addr, *Cell] {
96326 for _, id := range st.stored.blockIDs(cols, b0, b1) {97327 var in []colBlock98328 for _, c := range cols {99- if b := st.stored.cols[c].block(id); b != nil {100- in = append(in, colBlock{c, b})329+ if st.stored.col(c).block(id) != nil {330+ in = append(in, colBlock{col: c})101331 }102332 }103333 if !st.yieldBlock(in, id, r, yield) {
@@ -112,7 +342,7 @@ func (st *cellStore) inRange(r Rect) iter.Seq2[Addr, *Cell] {
112342 func (st *cellStore) rangeFill(r Rect, from Addr, dst []Addr) int {113343 n := 0114344 if from.Col > r.From.Col { // the rest of a row first115- for a := range st.inRange(Rect{From: from, To: Addr{Col: r.To.Col, Row: from.Row}}) {345+ for a := range st.keysIn(Rect{From: from, To: Addr{Col: r.To.Col, Row: from.Row}}) {116346 dst[n] = a117347 if n++; n == len(dst) {118348 return n
@@ -120,7 +350,7 @@ func (st *cellStore) rangeFill(r Rect, from Addr, dst []Addr) int {
120350 }121351 from = Addr{Col: r.From.Col, Row: from.Row + 1}122352 }123- for a := range st.inRange(Rect{From: from, To: r.To}) {353+ for a := range st.keysIn(Rect{From: from, To: r.To}) {124354 dst[n] = a125355 if n++; n == len(dst) {126356 break
@@ -129,19 +359,27 @@ func (st *cellStore) rangeFill(r Rect, from Addr, dst []Addr) int {
129359 return n130360 }131361 132-// anyInRange yields the stored cells in r in no particular order,133-// visiting whichever is cheaper: the index, row by row, or every stored134-// cell (iterating the map costs a quarter of a lookup per cell) when r135-// is more than a quarter as large as the sheet's cells are many.362+// anyInRange yields the stored cells in r in no particular order.136363 func (st *cellStore) anyInRange(r Rect) iter.Seq2[Addr, *Cell] {137- area := (r.To.Col - r.From.Col + 1) * (r.To.Row - r.From.Row + 1)138- if area <= len(st.m)/4 {139- return st.inRange(r)140- }141- return func(yield func(Addr, *Cell) bool) {142- for a, c := range st.m {143- if r.Contains(a) && !yield(a, c) {144- return364+ return st.cellsOf(st.anyKeysIn(r))365+}366+367+// anyKeysIn yields the addresses of the stored cells in r column by368+// column, which walks each block's bitmap once.369+func (st *cellStore) anyKeysIn(r Rect) iter.Seq[Addr] {370+ return func(yield func(Addr) bool) {371+ if r.From.Row > r.To.Row {372+ return373+ }374+ b0, b1 := r.From.Row>>blockShift, r.To.Row>>blockShift375+ for _, c := range slices.Clone(st.stored.colsIn(r.From.Col, r.To.Col)) {376+ if st.stored.col(c) == nil {377+ continue378+ }379+ for _, id := range st.stored.blockIDs([]int{c}, b0, b1) {380+ if !st.yieldBlock([]colBlock{{col: c}}, id, r, yield) {381+ return382+ }145383 }146384 }147385 }
@@ -152,15 +390,20 @@ type colBlock struct {
152390 b *rowBlock153391 }154392 155-// yieldBlock yields the cells of block id in the columns in, row by row,156-// limited to the rows of r.157-func (st *cellStore) yieldBlock(in []colBlock, id int, r Rect, yield func(Addr, *Cell) bool) bool {393+// yieldBlock yields the addresses of the cells of block id in the394+// columns in, row by row, limited to the rows of r. Each word is read395+// from the blocks as they are then, and each address checked before it396+// is yielded, so cells may be set and deleted meanwhile.397+func (st *cellStore) yieldBlock(in []colBlock, id int, r Rect, yield func(Addr) bool) bool {158398 base := id << blockShift159399 lo, hi := max(r.From.Row-base, 0), min(r.To.Row-base, blockRows-1)160400 for w := lo >> 6; w <= hi>>6; w++ {161401 var occ uint64162- for _, cb := range in {163- occ |= cb.b.bits[w]402+ for i := range in {403+ in[i].b = st.stored.col(in[i].col).block(id)404+ if in[i].b != nil {405+ occ |= in[i].b.bits[w]406+ }164407 }165408 occ &= wordMask(w, lo, hi)166409 for occ != 0 {
@@ -168,11 +411,10 @@ func (st *cellStore) yieldBlock(in []colBlock, id int, r Rect, yield func(Addr,
168411 occ &^= 1 << bit169412 row := base + w<<6 + bit170413 for _, cb := range in {171- if cb.b.bits[w]&(1<<bit) == 0 {414+ if cb.b == nil || cb.b.bits[w]&(1<<bit) == 0 {172415 continue173416 }174- a := Addr{Col: cb.col, Row: row}175- if c := st.m[a]; c != nil && !yield(a, c) {417+ if a := (Addr{Col: cb.col, Row: row}); st.stored.has(a) && !yield(a) {176418 return false177419 }178420 }
M internal/sheet/store_test.go +77 -24
@@ -6,53 +6,67 @@ import (
66 "testing"77 )88 9-// The occupancy index agrees with the map: inRange yields exactly the10-// stored cells of a range in row-major order, bounds is their bounding11-// box and nextRow finds the neighbours, through random sets and deletes9+// The store agrees with a map of what was stored: get returns each10+// cell's entry, inRange yields exactly the stored cells of a range in11+// row-major order, bounds is their bounding box and nextRow finds the12+// neighbours, through random sets and deletes of plain and rich cells1213 // that cross block boundaries.1314 func TestCellStoreIndex(t *testing.T) {1415 rng := rand.New(rand.NewPCG(1, 2))1516 st := newCellStore()17+ model := map[Addr]string{}1618 rows := []int{0, 1, 63, 64, 1023, 1024, 1025, 5000, 70000, MaxRows - 1}19+ inputs := []string{"x", "", "5", "2.50", "=A1", "TRUE"} // "" is formatting only1720 for i := range 3000 {1821 a := Addr{Col: rng.IntN(40), Row: rows[rng.IntN(len(rows))] + rng.IntN(3)}1922 a.Row = min(a.Row, MaxRows-1)2023 if rng.IntN(3) == 0 {2124 st.delete(a)25+ delete(model, a)2226 } else {23- input := "x"24- if rng.IntN(4) == 0 {25- input = "" // formatting only27+ input := inputs[rng.IntN(len(inputs))]28+ c, err := newCell(input, Format{}, Style{Bold: input == ""}, true)29+ if err != nil {30+ t.Fatal(err)2631 }27- st.set(a, &Cell{Input: input})32+ st.set(a, c)33+ model[a] = input2834 }2935 if i%100 == 0 {30- checkStore(t, &st, rng)36+ checkStore(t, &st, model, rng)3137 }3238 }3339 for a := range st.all() {3440 st.delete(a)3541 }36- if slices.ContainsFunc(st.stored.cols, func(c *colIndex) bool { return c != nil }) || len(st.stored.colIDs) != 0 || len(st.filled.colIDs) != 0 {42+ if st.len() != 0 || slices.ContainsFunc(st.stored.cols, func(c *colIndex) bool { return c != nil }) || len(st.stored.colIDs) != 0 || len(st.filled.colIDs) != 0 {3743 t.Errorf("empty store keeps %d columns", len(st.stored.colIDs))3844 }3945 }4046 41-func checkStore(t *testing.T, st *cellStore, rng *rand.Rand) {47+func checkStore(t *testing.T, st *cellStore, model map[Addr]string, rng *rand.Rand) {4248 t.Helper()49+ if st.len() != len(model) {50+ t.Fatalf("%d cells stored, want %d", st.len(), len(model))51+ }52+ for a, input := range model {53+ if c := st.get(a); c == nil || c.Input != input {54+ t.Fatalf("get(%v) = %+v, want %q", a, c, input)55+ }56+ }4357 for range 20 {4458 r := NewRect(Addr{Col: rng.IntN(45), Row: rng.IntN(80000)}, Addr{Col: rng.IntN(45), Row: rng.IntN(MaxRows)})45- checkRange(t, st, r)46- checkFilled(t, st, r)47- checkNextRow(t, st, rng.IntN(40), rng.IntN(MaxRows))59+ checkRange(t, st, model, r)60+ checkFilled(t, st, model, r)61+ checkNextRow(t, st, model, rng.IntN(40), rng.IntN(MaxRows))4862 }4963 }5064 51-// checkRange compares inRange and bounds with the map.52-func checkRange(t *testing.T, st *cellStore, r Rect) {65+// checkRange compares inRange and bounds with the model.66+func checkRange(t *testing.T, st *cellStore, model map[Addr]string, r Rect) {5367 t.Helper()5468 var want []Addr55- for a := range st.all() {69+ for a := range model {5670 if r.Contains(a) {5771 want = append(want, a)5872 }
@@ -65,6 +79,14 @@ func checkRange(t *testing.T, st *cellStore, r Rect) {
6579 if !slices.Equal(got, want) {6680 t.Fatalf("inRange(%v): %d cells, want %d", r, len(got), len(want))6781 }82+ got = got[:0]83+ for a := range st.anyKeysIn(r) {84+ got = append(got, a)85+ }86+ sortAddrs(got)87+ if !slices.Equal(got, want) {88+ t.Fatalf("anyKeysIn(%v): %d cells, want %d", r, len(got), len(want))89+ }6890 b, ok := st.bounds(r)6991 if ok != (len(want) > 0) {7092 t.Fatalf("bounds(%v) found %v with %d cells", r, ok, len(want))
@@ -80,15 +102,15 @@ func checkRange(t *testing.T, st *cellStore, r Rect) {
80102 }81103 }82104 83-// checkFilled compares the index of cells with contents with the map.84-func checkFilled(t *testing.T, st *cellStore, r Rect) {105+// checkFilled compares the index of cells with contents with the model.106+func checkFilled(t *testing.T, st *cellStore, model map[Addr]string, r Rect) {85107 t.Helper()86108 var filled []Addr87- for a, c := range st.all() {88- if r.Contains(a) && !c.Blank() {109+ for a, input := range model {110+ if r.Contains(a) && input != "" {89111 filled = append(filled, a)90112 }91- if st.filled.has(a) == c.Blank() {113+ if st.filled.has(a) != (input != "") {92114 t.Fatalf("filled index says %v for %v", st.filled.has(a), a)93115 }94116 }
@@ -98,14 +120,14 @@ func checkFilled(t *testing.T, st *cellStore, r Rect) {
98120 }99121 }100122 101-// checkNextRow compares nextRow both ways with the map.102-func checkNextRow(t *testing.T, st *cellStore, c, row int) {123+// checkNextRow compares nextRow both ways with the model.124+func checkNextRow(t *testing.T, st *cellStore, model map[Addr]string, c, row int) {103125 t.Helper()104126 next, ok := st.stored.nextRow(c, row, 1)105127 prev, okp := st.stored.nextRow(c, row, -1)106128 want1, ok1 := MaxRows, false107129 want2, ok2 := -1, false108- for a := range st.all() {130+ for a := range model {109131 if a.Col != c {110132 continue111133 }
@@ -120,3 +142,34 @@ func checkNextRow(t *testing.T, st *cellStore, c, row int) {
120142 t.Fatalf("nextRow(%d, %d) = %d %v / %d %v, want %d %v / %d %v", c, row, next, ok, prev, okp, want1, ok1, want2, ok2)121143 }122144 }145+146+// Cells set and deleted while iterating follow a map's rules: a cell147+// deleted before it is reached isn't yielded, and every cell stored148+// throughout is yielded once.149+func TestCellStoreChangeWhileIterating(t *testing.T) {150+ st := newCellStore()151+ for row := range 3000 {152+ for col := range 3 {153+ st.set(Addr{Col: col, Row: row}, &Cell{Input: "x"})154+ }155+ }156+ seen := map[Addr]bool{}157+ for a := range st.keysIn(NewRect(Addr{}, Addr{Col: 2, Row: 2999})) {158+ if seen[a] {159+ t.Fatalf("%v yielded twice", a)160+ }161+ seen[a] = true162+ if a.Col == 0 {163+ st.delete(Addr{Col: 2, Row: a.Row}) // not yet reached164+ st.delete(Addr{Col: 1, Row: a.Row + 1}) // nor this165+ }166+ }167+ for a := range seen {168+ if !st.has(a) {169+ t.Fatalf("deleted %v was yielded", a)170+ }171+ }172+ if len(seen) != st.len() {173+ t.Errorf("%d yielded, %d stored", len(seen), st.len())174+ }175+}
M internal/sheet/stress_test.go +63 -5
@@ -9,6 +9,7 @@ import (
99 "bytes"1010 "fmt"1111 "runtime"12+ "sync"1213 "testing"1314 1415 "github.com/FelineStateMachine/012/internal/sheet"
@@ -243,17 +244,34 @@ func saveShapes() []namedBuild {
243244 {"dense-8192x26", func() *sheet.Sheet { return stress.Dense(stress.Rows, 26) }},244245 {"dense-8192x256", func() *sheet.Sheet { return stress.Dense(stress.Rows, stress.Cols) }},245246 {"chain-8192", func() *sheet.Sheet { return stress.Chain(stress.Rows) }},247+ {"dense-1Mx10", func() *sheet.Sheet { return stress.Dense(stress.MaxRows, stress.MaxCols) }},246248 }247249 }248250 249-// BenchmarkMemory reports the live heap per non-blank cell after a load.251+// BenchmarkMemory reports the live heap per non-blank cell after a load:252+// built through Load, or opened from a .012 file written beforehand.250253 func BenchmarkMemory(b *testing.B) {251- for _, sh := range []namedBuild{252- {"numbers", func() *sheet.Sheet { return stress.Dense(stress.Rows, 64) }},253- {"formulas", func() *sheet.Sheet { return stress.Chain(stress.Rows) }},254- {"text-200", func() *sheet.Sheet { return stress.LongText(stress.Rows, 200) }},254+ for _, sh := range []struct {255+ name string256+ prep func()257+ build func() *sheet.Sheet258+ }{259+ {"numbers", nil, func() *sheet.Sheet { return stress.Dense(stress.Rows, 64) }},260+ {"numbers-1Mx10", nil, func() *sheet.Sheet { return stress.Dense(stress.MaxRows, stress.MaxCols) }},261+ {"formulas", nil, func() *sheet.Sheet { return stress.Chain(stress.Rows) }},262+ {"text-200", nil, func() *sheet.Sheet { return stress.LongText(stress.Rows, 200) }},263+ {"open-8192x256", func() { denseFile() }, func() *sheet.Sheet {264+ s, err := sheet.Read(bytes.NewReader(denseFile()))265+ if err != nil {266+ b.Fatal(err)267+ }268+ return s269+ }},255270 } {256271 b.Run(sh.name, func(b *testing.B) {272+ if sh.prep != nil {273+ sh.prep()274+ }257275 for b.Loop() {258276 before := heap()259277 s := sh.build()
@@ -265,6 +283,46 @@ func BenchmarkMemory(b *testing.B) {
265283 }266284 }267285 286+// denseFile is the .012 file of 8192x256 numbers.287+var denseFile = sync.OnceValue(func() []byte {288+ var buf bytes.Buffer289+ if err := stress.Dense(stress.Rows, stress.Cols).Write(&buf); err != nil {290+ panic(err)291+ }292+ return buf.Bytes()293+})294+295+// BenchmarkRead is the cost of reading a cell's value, per cell of 8192296+// x 26 numbers: through Sheet.Value, and as formulas read cells in a297+// recalculation (a SUM of each column, read directly).298+func BenchmarkRead(b *testing.B) {299+ s := stress.Dense(stress.Rows, 26)300+ cells := float64(stress.Rows * 26)301+ b.Run("value", func(b *testing.B) {302+ sum := 0.0303+ for b.Loop() {304+ for row := range stress.Rows {305+ for col := range 26 {306+ sum += s.Value(sheet.Addr{Col: col, Row: row}).Num307+ }308+ }309+ }310+ b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(b.N)/cells, "ns/cell")311+ })312+ b.Run("recalc", func(b *testing.B) {313+ for col := range 26 {314+ name := sheet.ColName(col)315+ if err := s.Set(sheet.Addr{Col: col, Row: stress.Rows}, fmt.Sprintf("=SUM(%s1:%s%d)", name, name, stress.Rows)); err != nil {316+ b.Fatal(err)317+ }318+ }319+ for b.Loop() {320+ s.RecalcAll()321+ }322+ b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(b.N)/cells, "ns/cell")323+ })324+}325+268326 // heap is the live heap after a full collection.269327 func heap() uint64 {270328 runtime.GC()
M internal/sheet/validation.go +1 -1
A internal/sheet/validcheck.go +53 -0
@@ -0,0 +1,53 @@
1+package sheet2+3+import "slices"4+5+// Validation of what pastes and fills write, as Sheets does it: they6+// don't ask cell by cell, so the UI checks the range they wrote after7+// the fact, and takes the whole change back (Discard) when a rule that8+// rejects fails.9+10+// InvalidIn lists the cells in r whose contents fail their validation,11+// in reading order: blanks never do. A cell's rule decides whether its12+// entry is refused (Reject) or kept and marked. The cost is the cells13+// stored where r meets a rule's ranges.14+func (s *Sheet) InvalidIn(r Rect) []*InvalidEntry {15+ var out []*InvalidEntry16+ for _, v := range s.rules.validations {17+ for _, vr := range v.Ranges {18+ in := Rect{19+ From: Addr{Col: max(vr.From.Col, r.From.Col), Row: max(vr.From.Row, r.From.Row)},20+ To: Addr{Col: min(vr.To.Col, r.To.Col), Row: min(vr.To.Row, r.To.Row)},21+ }22+ if in.From.Col > in.To.Col || in.From.Row > in.To.Row {23+ continue24+ }25+ for _, a := range s.cellsIn(in) {26+ if c := s.cells.get(a); !c.Blank() && s.Look(a).Invalid {27+ out = append(out, &InvalidEntry{Addr: a, Help: v.HelpText(), Reject: v.Reject})28+ }29+ }30+ }31+ }32+ slices.SortFunc(out, func(a, b *InvalidEntry) int {33+ if a.Addr.Row != b.Addr.Row {34+ return a.Addr.Row - b.Addr.Row35+ }36+ return a.Addr.Col - b.Addr.Col37+ })38+ return out39+}40+41+// Discard takes back the last change and forgets it, so redo can't bring42+// it back: a paste or fill refused after the fact. It reports whether43+// there was a change to take back.44+func (w *Workbook) Discard() bool {45+ if _, ok := w.swap(true); !ok {46+ return false47+ }48+ w.hist.redo = w.hist.redo[:len(w.hist.redo)-1]49+ return true50+}51+52+// Discard takes back the workbook's last change; see Workbook.Discard.53+func (s *Sheet) Discard() bool { return s.wb.Discard() }
M internal/sheet/workbook.go +6 -6
M internal/stress/gen.go +8 -0
M internal/ui/assist.go +13 -9
@@ -10,7 +10,6 @@ import (
1010 1111 "github.com/FelineStateMachine/012/internal/sheet"1212 "github.com/FelineStateMachine/012/internal/ui/formula"13- "github.com/FelineStateMachine/012/internal/ui/lineedit"1413 "github.com/FelineStateMachine/012/internal/ui/overlay"1514 "github.com/FelineStateMachine/012/internal/ui/theme"1615 )
@@ -88,7 +87,7 @@ func (a *assist) shown(m *Model) ([]suggestion, int) {
8887 if (m.mode != modeEnter && m.mode != modeEdit) || !m.line.IsFormula() || !a.active || m.overlay != nil {8988 return nil, 09089 }91- c := formula.ScanCaret(m.line.Buf, m.line.Pos)90+ c := formula.ScanCaret(m.storedFormula(m.line.Buf), m.line.Pos)9291 if c.Word == "" {9392 return nil, 09493 }
@@ -237,16 +236,16 @@ func (a *assist) status(m *Model) (desc, keys string, ok bool) {
237236 238237 // inFunction reports whether the caret is inside a known function's239238 // parentheses.240-func inFunction(l *lineedit.Line) bool {241- _, ok := sheet.LookupFunc(formula.ScanCaret(l.Buf, l.Pos).Fn)239+func (m *Model) inFunction() bool {240+ _, ok := sheet.LookupFunc(formula.ScanCaret(m.storedFormula(m.line.Buf), m.line.Pos).Fn)242241 return ok243242 }244243 245244 // signatureLine puts the signature of the function around the caret on246245 // the context line, with what the function does and the keys that apply247246 // (hints) as room allows. It reports false outside a function.248-func signatureLine(th *theme.Theme, width int, buf []rune, pos int, hints string) (left, right string, ok bool) {249- sig, desc := signature(th, buf, pos)247+func signatureLine(th *theme.Theme, width int, buf []rune, pos int, sep byte, hints string) (left, right string, ok bool) {248+ sig, desc := signature(th, buf, pos, sep)250249 if sig == "" {251250 return "", "", false252251 }
@@ -261,8 +260,10 @@ func signatureLine(th *theme.Theme, width int, buf []rune, pos int, hints string
261260 262261 // signature renders the signature of the function around the caret, e.g.263262 // SUM(value1, [value2, ...]), with the current argument marked, and what264-// the function does. It is "" outside a function.265-func signature(th *theme.Theme, buf []rune, pos int) (sig, desc string) {263+// the function does. It is "" outside a function. buf is the formula in264+// the syntax it's parsed in (see Model.storedFormula); sep separates the265+// arguments shown, ; in a locale with a decimal comma.266+func signature(th *theme.Theme, buf []rune, pos int, sep byte) (sig, desc string) {266267 c := formula.ScanCaret(buf, pos)267268 f, ok := sheet.LookupFunc(c.Fn)268269 if c.Fn == "" || !ok {
@@ -274,7 +275,10 @@ func signature(th *theme.Theme, buf []rune, pos int) (sig, desc string) {
274275 b.WriteString(th.Key.Render(f.Name) + "(")275276 for i, p := range parts {276277 if i > 0 {277- b.WriteString(", ")278+ b.WriteString(string(sep) + " ")279+ }280+ if sep != ',' {281+ p = strings.ReplaceAll(p, ",", string(sep)) // [value2; ...]278282 }279283 if i == cur {280284 b.WriteString(th.Argument.Render(p))
M internal/ui/assist_test.go +1 -1
M internal/ui/clipboard.go +10 -2
M internal/ui/columns.go +1 -3
M internal/ui/edit.go +8 -2
M internal/ui/entry.go +4 -4
@@ -59,7 +59,7 @@ func (m *Model) startEntry(md mode, text string) {
5959 func (m *Model) startEdit() tea.Cmd {6060 m.startEntry(modeEdit, "")6161 if c := m.sheet.Cell(m.cur); c != nil && m.mode == modeEdit {62- m.line.Set(c.Input)62+ m.line.Set(m.shownEntry(c.Input))6363 }6464 return nil6565 }
@@ -79,7 +79,7 @@ func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {
7979 m.toggleAbsolute()8080 return nil8181 }82- if m.line.IsFormula() && (m.pointAfterSheet(key) || m.line.CanPoint() && m.startPoint(key)) {82+ if m.line.IsFormula() && (m.pointAfterSheet(key) || m.canPoint() && m.startPoint(key)) {8383 return nil8484 }8585 if m.line.IsFormula() && (key == "left" || key == "right") {
@@ -102,7 +102,7 @@ func (m *Model) editKey(k tea.KeyPressMsg) tea.Cmd {
102102 if m.entry.assist.key(m, key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {103103 return nil104104 }105- if m.line.IsFormula() && strings.HasPrefix(key, "shift+") && (m.pointAfterSheet(key) || m.line.CanPoint() && m.startPoint(key)) {105+ if m.line.IsFormula() && strings.HasPrefix(key, "shift+") && (m.pointAfterSheet(key) || m.canPoint() && m.startPoint(key)) {106106 return nil107107 }108108 switch key {
@@ -228,7 +228,7 @@ func (m *Model) resumeEntry(ref string) {
228228 // commit stores the edit line in the active cell. An invalid formula stays229229 // in EDIT mode with the caret at the problem and the reason on line 3.230230 func (m *Model) commit() bool {231- input := m.line.Text()231+ input := m.storedEntry(m.line.Text())232232 warn, ok := m.checkEntry(input) // data validation, rules.go233233 if !ok {234234 return false
M internal/ui/fill.go +3 -0
M internal/ui/filter.go +2 -2
M internal/ui/links.go +1 -1
A internal/ui/locale.go +133 -0
@@ -0,0 +1,133 @@
1+package ui2+3+import (4+ "strings"5+6+ tea "charm.land/bubbletea/v2"7+8+ "github.com/FelineStateMachine/012/internal/formula"9+ "github.com/FelineStateMachine/012/internal/locale"10+ "github.com/FelineStateMachine/012/internal/numfmt"11+ "github.com/FelineStateMachine/012/internal/sheet"12+ "github.com/FelineStateMachine/012/internal/ui/picker"13+)14+15+// File > Settings > Locale: how the workbook's entries are typed and its16+// numbers, dates and formulas shown (see sheet/locale.go). Cells keep17+// their entries in en-US's form, so the UI translates at its edges: what18+// the formula bar and editing show (shownEntry), and what's typed or19+// pasted before it's stored (storedEntry).20+21+func init() {22+ register(&command{id: "settings.locale", title: "Locale",23+ desc: "Type and show numbers, dates, currency and formulas as a country does: 1.234,56 and =ROUND(A1; 2) in Germany",24+ run: func(m *Model) tea.Cmd { m.openLocalePicker(); return nil }})25+}26+27+// locale is the workbook's locale.28+func (m *Model) locale() *locale.Locale { return m.sheet.Locale() }29+30+// shownEntry is a cell's entry as typed in the workbook's locale.31+func (m *Model) shownEntry(input string) string { return sheet.LocalEntry(input, m.locale()) }32+33+// storedEntry is text typed in the workbook's locale as the cell stores34+// it.35+func (m *Model) storedEntry(typed string) string { return sheet.CanonicalEntry(typed, m.locale()) }36+37+// storedFormula is a formula being typed, in the syntax it's parsed in:38+// what the caret's function and argument are read from. It has the same39+// runes at the same places.40+func (m *Model) storedFormula(buf []rune) []rune {41+ if formula.SameSyntax(m.locale()) {42+ return buf43+ }44+ return []rune(formula.Delocalize(string(buf), m.locale()))45+}46+47+// canPoint reports whether the caret follows an operator a reference may48+// come after; not a decimal comma.49+func (m *Model) canPoint() bool {50+ if !m.line.CanPoint() {51+ return false52+ }53+ return m.line.Buf[m.line.Pos-1] != ',' || m.locale().Decimal != ','54+}55+56+// openLocalePicker lists the locales, the workbook's own highlighted;57+// the first follows the config's locale.58+func (m *Model) openLocalePicker() {59+ cur := m.book().LocaleTag()60+ def := sheet.DefaultLocale()61+ items := []picker.Item{m.localeItem("Default", "", def)}62+ items[0].Desc = "Follow the locale setting, " + def.Name + ". " + localeDesc(def)63+ sel := 064+ for i, l := range locale.All() {65+ if l.Tag == cur {66+ sel = i + 167+ }68+ items = append(items, m.localeItem(l.Name, l.Tag, l))69+ }70+ p := m.newPicker("Locale", "Type a language, country or tag", 80, items)71+ p.Action = "set"72+ p.Answers = true73+ p.Sel = sel74+ p.Narrow = narrowTags75+ m.openOverlay(p)76+}77+78+// narrowTags keeps the locales whose tag starts with the search, when79+// any does: "de" lists German ones rather than Denmark first.80+func narrowTags(query string) (string, func(*picker.Item) bool) {81+ q := strings.ToLower(strings.TrimSpace(query))82+ if len(q) < 2 {83+ return query, nil84+ }85+ tagged := func(it *picker.Item) bool { return strings.HasPrefix(strings.ToLower(it.Detail), q) }86+ for _, tag := range locale.Tags() {87+ if strings.HasPrefix(strings.ToLower(tag), q) {88+ return "", tagged89+ }90+ }91+ return query, nil92+}93+94+// localeItem is the picker's row for l, which picking sets as tag.95+func (m *Model) localeItem(title, tag string, l *locale.Locale) picker.Item {96+ return picker.Item{97+ Title: title, Name: len(title), Detail: l.Tag + " " + localeSample(l), Desc: localeDesc(l),98+ Pick: func() tea.Cmd {99+ m.closeOverlay()100+ m.setLocale(tag)101+ return nil102+ },103+ }104+}105+106+// localeSample shows a number, a date and a formula in l.107+func localeSample(l *locale.Locale) string {108+ return numfmt.FormatIn(1234.56, "#,##0.00", l) + " " + numfmt.FormatIn(sampleDate, l.Date, l) + " " + sampleFormula(l)109+}110+111+// localeDesc says how l writes currency, dates and formulas.112+func localeDesc(l *locale.Locale) string {113+ return "Numbers " + numfmt.FormatIn(1234.56, sheet.Preset(sheet.FmtCurrency).CodeIn(l), l) +114+ ", dates " + numfmt.FormatIn(sampleDate, l.Date, l) + ", formulas " + sampleFormula(l)115+}116+117+// sampleFormula is a formula with a decimal and two arguments, in l.118+func sampleFormula(l *locale.Locale) string { return formula.Localize("=ROUND(1.5, 1)", l) }119+120+// sampleDate is the date locales are shown with.121+var sampleDate = numfmt.DateSerial(2026, 9, 26)122+123+// setLocale sets the workbook's locale, "" to follow the default.124+func (m *Model) setLocale(tag string) {125+ m.book().SetLocale(tag)126+ m.changed = m.sheet.StateID() != m.saved127+ l := m.locale()128+ name := l.Name129+ if tag == "" {130+ name = "the default, " + l.Name131+ }132+ m.note = "Locale " + name + ". " + localeDesc(l)133+}
A internal/ui/locale_test.go +73 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/menubar.go +1 -1

Not shown: this page already shows 6000 lines of diff.

A internal/ui/monochrome_test.go +332 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/mouse.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/panel.go +10 -9

Not shown: this page already shows 6000 lines of diff.

M internal/ui/prefs.go +4 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/rows.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/rowtext/rowtext.go +29 -23

Not shown: this page already shows 6000 lines of diff.

M internal/ui/rules.go +49 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/rules/condfmt.go +7 -5

Not shown: this page already shows 6000 lines of diff.

M internal/ui/rules/form.go +13 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/rules/validation.go +7 -5

Not shown: this page already shows 6000 lines of diff.

M internal/ui/tabs.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/theme/contrast.go +1 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/theme/highcontrast.go +92 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/theme/monochrome.go +32 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/theme/palette.go +9 -6

Not shown: this page already shows 6000 lines of diff.

M internal/ui/theme/scheme.go +42 -15

Not shown: this page already shows 6000 lines of diff.

M internal/ui/theme/scheme_test.go +66 -33

Not shown: this page already shows 6000 lines of diff.

A internal/ui/theme/standout_test.go +39 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/theme/theme.go +39 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/themepicker/themepicker.go +2 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/transfer.go +3 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/validpaste_test.go +88 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/view.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/vim.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/vim_test.go +1 -1

Not shown: this page already shows 6000 lines of diff.

A internal/value/locale.go +311 -0

Not shown: this page already shows 6000 lines of diff.

A internal/value/locale_test.go +215 -0

Not shown: this page already shows 6000 lines of diff.

M internal/value/parse.go +5 -2

Not shown: this page already shows 6000 lines of diff.

A scripts/dist.sh +63 -0

Not shown: this page already shows 6000 lines of diff.

M scripts/doccheck/tree.go +29 -16

Not shown: this page already shows 6000 lines of diff.

M scripts/stress/load.sql +11 -1

Not shown: this page already shows 6000 lines of diff.

A scripts/stress/release.sql +71 -0

Not shown: this page already shows 6000 lines of diff.

M scripts/stress/report.sh +49 -10

Not shown: this page already shows 6000 lines of diff.

M scripts/stress/report.sql +4 -4

Not shown: this page already shows 6000 lines of diff.

M scripts/stress/run.sh +3 -1

Not shown: this page already shows 6000 lines of diff.