- commit
- 4ebe5b5a607788fba2c4b509015c844ede60ec4d
- parent
- a50df9d4373ba547d50bc8cd337d0bea2f845e63
- parent
- 27bca8f2d8d879433fa3f84d24911264cb7f36a3
- author
- Dami <dami.etoile@gmail.com>
@@ -7,7 +7,7 @@ GHOSTTY_SRC := $(DEPS)/ghostty-src
77 GHOSTTY_OUT := $(DEPS)/ghostty88 GHOSTTY_STAMP := $(GHOSTTY_OUT)/.built-$(GHOSTTY_COMMIT)99 10-10+1111 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 @@ -53,19 +59,21 @@ screens: $(GHOSTTY_STAMP)
5359 # Stress: fetch real datasets into .deps/stress, run the benchmarks built5460 # with -tags stress, print a summary and record the run in5561 # .deps/stress/results/runs.jsonl. BENCH, BENCHTIME and PKGS narrow it;56-# see docs/limits.md. Plain `go test ./...` never runs these.62+# see docs/contributing/limits.md. Plain `go test ./...` never runs these.5763 stress:5864 scripts/stress/run.sh5965 6066 stress-data:6167 scripts/stress-data.sh6268 63-# Compare the latest run with the previous one and a baseline (DuckDB).69+# Compare the latest run with the previous one, a baseline and the last70+# tagged release's run (DuckDB); exits 1 on a regression against the71+# release past THRESHOLD (0.10) plus the benchmark's noise.6472 stress-report:6573 scripts/stress/report.sh6674 6775 # The observability stack (deploy/observability): an OpenTelemetry68-# Collector, ClickHouse and Grafana in Docker. See docs/observability.md.76+# Collector, ClickHouse and Grafana in Docker. See docs/contributing/observability.md.6977 obs-up:7078 scripts/obs.sh up7179 @@ -105,7 +113,7 @@ demos: build
105113 mkdir -p demos/out/stills106114 bin/fakejev -addr $(FAKEJEV_ADDR) & pid=$$!; trap "kill $$pid" EXIT; \107115 export DEMOS_TTYD="$$(command -v ttyd)" PATH="$(CURDIR)/demos/lib:$$PATH"; \108- export XDG_CONFIG_HOME="$$(mktemp -d)" O12_JEV_CREDENTIAL_STORE=false O12_THEME=; \116+ export XDG_CONFIG_HOME="$$(mktemp -d)" O12_JEV_CREDENTIAL_STORE=false O12_THEME= O12_LOCALE=en-US; \109117 cd demos && for d in $(DEMOS); do echo "vhs $$d.tape"; vhs -q $$d.tape || exit 1; done110118 mkdir -p demos/out/media111119 for d in $(DEMOS); do ffmpeg -v error -y -i demos/out/$$d.gif -filter_complex \@@ -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,7 +27,7 @@ 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 @@ -39,6 +39,7 @@ rises in measured steps; see [Bounds of support](docs/contributing/limits.md#wha
3939 |---|---|---|4040 | A streaming or binary `.012` format next to the readable JSON one | Open and save scale with the data | M |4141 | 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 |4243 | Linked, paged read-only ranges over Parquet and SQLite that feed pivots and formulas by streaming | Sources too big for any grid | L |4344 4445 ### 4. Macros, keys and the terminal@@ -50,15 +51,6 @@ rises in measured steps; see [Bounds of support](docs/contributing/limits.md#wha
5051 | Hold-to-preview and Shift+Enter on terminals with the kitty keyboard protocol (`View.KeyboardEnhancements`) | S |5152 | Sixel chart images, redrawn on resize, for terminals without kitty graphics | M |5253 53-### 5. Distribution and upkeep54-55-| Item | Size |56-|---|---|57-| Prebuilt binaries attached to version tags, built locally (the GitHub repo is hosting only); tags carry release notes in their message | S |58-| `make stress-report` thresholds that flag regressions over a set percentage against the last release | S |59-| Grafana: a trace panel for the nested spans | S |60-| Accessibility: a high-contrast theme, and an audit that every state reads without color (a [UX rule](docs/contributing/ux.md#visual-rules)) | S |61-6254 ## Later: sharing a live sheet (shelved)6355 6456 Explored, not scheduled. `012 serve` gives each SSH session its own@@ -85,8 +77,9 @@ by a gateway dialing the iroh ticket.
8577 - 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)8678 - 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)8779 - 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)8881 - 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)89-- Compact column storage: 20 B a number, a ten-million-cell `max-cells` budget, 3 ns cell reads: [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)@@ -99,6 +99,8 @@ func listThemes(c *config.Config, e env) {
9999 switch {100100 case t.Name == theme.Terminal:101101 kind = "the terminal's own colors"102+ case t.Name == theme.HighContrast:103+ kind = "high contrast, dark or light by the terminal"102104 case t.User:103105 kind += ", " + config.ThemesDir()104106 }@@ -53,13 +53,17 @@ func usage() error {
5353 return errors.New("usage: 012 " + config.FlagUsage() + " [file]: a " + sheet.FileExt +5454 " sheet, or a .csv, .tsv, .xlsx, .sqlite, .parquet or .wk1 file to import\n" +5555 " 012 serve [flags] [dir]: serve sheets in dir over SSH (see docs/terminal/ssh.md)\n" +56- " 012 config [path|edit|default|themes|set-key|delete-key]")56+ " 012 config [path|edit|default|themes|set-key|delete-key]\n" +57+ " 012 version")5758 }5859 5960 func run(args []string, e env) error {6061 if len(args) > 0 && args[0] == "config" {6162 return runConfig(args[1:], e)6263 }64+ if len(args) > 0 && (args[0] == "version" || args[0] == "--version") {65+ return runVersion(e)66+ }6367 if len(args) > 0 && args[0] == "serve" {6468 // A file called serve opens as ./serve.6569 return runServe(args[1:], e)@@ -174,3 +174,15 @@ func TestRunWarnsOnConfig(t *testing.T) {
174174 t.Error("two files")175175 }176176 }177+178+func TestVersion(t *testing.T) {179+ e, out, _ := testEnv(t, nil)180+ version = "v9.8.7"181+ defer func() { version = "" }()182+ for _, arg := range []string{"version", "--version"} {183+ out.Reset()184+ if err := run([]string{arg}, e); err != nil || !strings.HasPrefix(out.String(), "012 v9.8.7") {185+ t.Errorf("%s: %v %q", arg, err, out)186+ }187+ }188+}@@ -0,0 +1,43 @@
1+package main2+3+import (4+ "fmt"5+ "runtime"6+ "runtime/debug"7+)8+9+// version is the release this binary was built as, set by make dist10+// with -ldflags "-X main.version=v1.2.3". Empty in other builds.11+var version string12+13+// buildVersion is what 012 version prints: the stamped release, else the14+// module version go install records (v1.2.3, or (devel) in a checkout),15+// with the VCS revision when the build has one.16+func buildVersion() string {17+ v, rev := version, ""18+ if info, ok := debug.ReadBuildInfo(); ok {19+ if v == "" {20+ v = info.Main.Version21+ }22+ for _, s := range info.Settings {23+ if s.Key == "vcs.revision" {24+ rev = s.Value25+ }26+ }27+ }28+ if v == "" {29+ v = "(devel)"30+ }31+ if len(rev) > 12 {32+ rev = rev[:12]33+ }34+ if rev != "" {35+ v += " (" + rev + ")"36+ }37+ return v38+}39+40+func runVersion(e env) error {41+ _, err := fmt.Fprintf(e.stdout, "012 %s %s %s/%s\n", buildVersion(), runtime.Version(), runtime.GOOS, runtime.GOARCH)42+ return err43+}@@ -0,0 +1,32 @@
1+# Locale: a small budget typed in English (United States), then File >2+# Settings > Locale, here from the palette, shows it as Germany writes3+# it: decimal commas, day-first dates, euros after the number and ; in4+# formulas. The values don't change; the file stores the same thing.5+Output out/locale.gif6+Source lib/setup.tape7+8+Hide9+Type "../bin/012" Enter10+Sleep 1.5s11+Show12+13+Sleep 600ms14+Type "Item" Tab Type "Amount" Tab Type "Due" Enter15+Type "Rent" Tab Type "$950.00" Tab Type "9/26/2026" Enter16+Type "Power" Tab Type "$84.20" Tab Type "10/3/2026" Enter17+Type "Total" Tab Type "=SUM(B2:B3)" Enter18+Type "VAT" Tab Type "=ROUND(B4*0.19, 2)" Enter19+Sleep 1.5s20+Ctrl+K21+Type@120ms "locale"22+Sleep 1s23+Enter24+Sleep 1s25+Type@120ms "germany"26+Sleep 1.5s27+Enter28+Sleep 1s29+Up Right30+Sleep 2.5s31+Screenshot out/stills/locale-de.png32+Sleep 500ms@@ -1,5 +1,5 @@
11 # Local observability stack for 012: an OpenTelemetry Collector feeding2-# ClickHouse, and Grafana on top. See docs/observability.md.2+# ClickHouse, and Grafana on top. See docs/contributing/observability.md.33 #44 # make obs-up start it and print the environment for 01255 # make obs-status containers, endpoints and row counts@@ -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+}@@ -16,5 +16,6 @@ in [CLAUDE.md](../../CLAUDE.md), then:
1616 | [Testing](testing.md) | Unit, end-to-end through libghostty, golden screens, the oracle, docs checks, demos |1717 | [Bounds of support](limits.md) | Measured bounds and known bottlenecks |1818 | [Observability](observability.md) | Event logs, OTLP export, DuckDB queries, the local telemetry stack |19+| [Releasing](releasing.md) | The release checklist and `make dist` |1920 2021 Plans are in the [roadmap](../../ROADMAP.md).@@ -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,@@ -100,12 +115,16 @@ style.
100115 cost of what it holds; the stored index's blocks hold the cells101116 themselves, a 16-byte slot each in row order (`slot.go`). A plain cell102117 (a number, boolean or text as typed, with a format and style) lives in103- its slot, with its text in a table of strings and its formatting in a104- table of looks; formulas, notes, and what pivots and spills write are105- whole `Cell`s in a side table, which recalculation updates in place.106- `get` hands out a plain cell as a `Cell` made for the caller, a copy107- whose changes reach nothing; `set` is the one way to change a cell. Formats of whole columns and rows, and of the108- whole sheet, live on the lines (`lines.go`); a cell falls back on them.118+ 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.109128 Copy, paste and move carry the formatting cells show (`clipfmt.go`):110129 whole lines as line formats, blocks as the cells' own. A line's format111130 changing recalculates the formulas reading any cell of it, found through@@ -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.@@ -29,7 +29,7 @@ it lags, and past a second it stalls.
2929 | 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 |3030 | 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.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 |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 |3333 | 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 |3434 | 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 |@@ -67,17 +67,14 @@ Heap per non-blank cell after loading (`BenchmarkMemory`,
6767 `BenchmarkImport`): numbers 20.5 B, opened from a file too; formulas 7506868 B (the parsed tree and reference lists); imported CSV, SQLite and6969 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). The store71-(`internal/sheet/store.go`, `slot.go`) keeps each column in blocks of72-1024 rows, a 16-byte slot per cell with contents or formatting: a plain73-cell (a number, boolean or text as typed, with a format and style) holds74-its value there, its text in a table of strings kept once each, its75-format and style in a table of looks, and its input only when that76-isn't the value's own text ("1.50" is kept as 1.5 printed with two77-decimals). Formulas, notes, and the results pivots and spills write are78-whole `Cell`s (216 B) in a side table. Reading a cell's value is an79-index into its column's block: 2.8 ns (`BenchmarkRead`), 8.6 ns a cell80-read by a SUM in a full recalculation.70+(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.8178 8279 ## The grid8380 @@ -499,32 +496,33 @@ computation.
499496 500497 ## What would raise the bounds501498 502-In order of value for effort. `max-cells` stands at ten million, as503-Google Sheets' limit does; the sheet itself would hold several times499+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 times504502 that in a few GB, and these are what stand in the way.505503 506-1. **Streaming, compact file format** (M, 1 week). The `.012` file is507- JSON decoded whole: ten million cells take 8 s and 2.7 GB at the508- peak to open, 5.7 GB allocated, for a sheet that then holds 205 MB.509- A streaming decoder over the same format would roughly halve open510- time and cut allocation tenfold; a columnar or gzip-compressed511- variant would cut the size about fourfold. The format is512- `internal/sheet/file.go`, apart from the cell store.513-2. **Smaller undo steps** (S to M). A step keeps a whole `Cell` for each514- cell it changed, about 300 B where the store keeps a number in 20,515- and formatting-only changes as whole cells too. Keeping plain cells'516- before-images as slots, and formatting changes as diffs, would let a517- step over a full sheet cost what the sheet does.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 streaming507+ decoder over the same format would roughly halve open time and cut508+ allocation tenfold; a columnar or gzip-compressed variant would cut509+ 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.518516 3. **Compact spilled and pivot cells.** What arrays and pivots write is519- kept as whole `Cell`s (about 300 B each, 516 k of them in the arrays520- stress shape); their values and inferred formats would fit in slots.517+ kept as whole `Cell`s (516 k of them in the arrays stress shape);518+ their values and inferred formats would fit in slots.521519 522520 ## Measuring523521 524522 ```sh525523 make stress # fetch datasets, run everything, record the run (5 minutes)526524 BENCH='Edit|Frame' make stress # a subset; BENCHTIME=2s for steadier numbers527-make stress-report # latest against previous and baseline, with trends525+make stress-report # latest against previous, baseline and last release, with trends528526 make stress-e2e # key press to screen through libghostty529527 ```530528 @@ -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.@@ -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.@@ -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@@ -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 | |@@ -63,6 +63,7 @@ save), so they raise no version:
6363 | `protected` | a sheet | [Protected ranges](../sheets/notes-protection.md#protected-sheets-and-ranges): `{"range":"B2:C9","description":"Totals"}`, or `{"sheet":true}` |6464 | `conditionalFormats`, `validations` | a sheet | [Rules](#conditional-formats-and-data-validation), one per line |6565 | `arithmetic` | the workbook | `decimal` for [decimal arithmetic](../formulas/decimal.md) |66+| `locale` | the workbook | The [locale](../sheets/locale.md) it's typed and shown in (`"de-DE"`), when File > Settings > Locale chose one; without it, the file follows the reader's `locale` setting. Cells are stored the same way in every locale: `input` is always as typed in en-US (`1,234.5`, `9/26/2026`, `=ROUND(A1,2)`) |6667 | `macros`, `macroOrigin` | the workbook | Macros as Starlark scripts, and the computer they were made or trusted on: see [Macro scripting API](../reference/macro-api.md#in-the-file). Opening a file never runs them |6768 6869 ## Column and row formats@@ -79,7 +80,7 @@ formatting together; `A:XFD` is the whole sheet's format:
7980 8081 In version 4 the sheets are a list, each with its name and the fields a8182 version 3 file has at the top, and named ranges say their sheet. The82-workbook's settings (named ranges, decimal arithmetic, the sheet shown83+workbook's settings (named ranges, decimal arithmetic, the locale, the sheet shown8384 when saved as `active`) stay at the top:8485 8586 ```json@@ -20,6 +20,11 @@ entry is the way Sheets does:
2020 | `=A1*2`, `+A1*2` | a formula |2121 | `'42` | text, even though it looks like a number |2222 23+That's in English (United States); in another [locale](../sheets/locale.md)24+numbers, currency and dates are typed its way (`1.234,5`, `12,50 €`,25+`26.09.2026` in German), and formulas with a decimal comma separate26+their arguments with `;`: `=ROUND(A1*1,19; 2)`.27+2328 A formula that doesn't parse isn't accepted: 012 stays in EDIT mode with the2429 caret on the problem and says what's wrong on the context line, e.g.2530 `Expected , or ) in SUM`.Binary file.
@@ -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,6 +72,7 @@ start.
7172 | [`chart-images`](#chart-images) | `true` | `O12_CHART_IMAGES` |7273 | [`notifications`](#notifications) | `true` | `O12_NOTIFICATIONS` |7374 | [`keymap`](#keymap) | `default` | `O12_KEYMAP` |75+| [`locale`](#locale) | `en-US` | `O12_LOCALE` |7476 | [`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` |@@ -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,6 +137,18 @@ 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 140154 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.@@ -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@@ -22,3 +22,4 @@ working with its data.
2222 | [Notes and protection](notes-protection.md) | Notes on cells; ranges that warn before an edit |2323 | [Charts](charts.md) | Charts that float over the grid |2424 | [Macros](macros.md) | Recording and running what you do |25+| [Locale](locale.md) | Decimal commas, date order, currency and `;` in formulas, as a country writes them |@@ -47,8 +47,9 @@ while typing enters the same entry into every selected cell.
4747 4848 ## Undo4949 50-Ctrl+Z undoes and Ctrl+Y or Ctrl+Shift+Z redoes, 100 steps deep, across51-every sheet: undo shows the sheet a step changed. Every change is covered:50+Ctrl+Z undoes and Ctrl+Y or Ctrl+Shift+Z redoes, 100 steps deep (fewer51+when steps are very large, the oldest dropped first: see52+[Bounds of support](../contributing/limits.md#undo)), across every sheet: undo shows the sheet a step changed. Every change is covered:5253 typing, clearing, paste, fill, sort, insert and delete, formats, widths,5354 names, charts, freeze, filters, pivot tables, and adding, deleting,5455 renaming, moving and duplicating sheets. A multi-cell change is one step,@@ -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+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.@@ -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.@@ -29,5 +29,3 @@ colors that follow light and dark.
2929 | Curly, colored underlines | error cells | kitty, Ghostty, WezTerm, iTerm2, Alacritty |3030 | Background color query and mode 2031 | light and dark themes, following system changes | most terminals; 2031 in Ghostty and kitty |3131 | Synchronized output (2026) | flicker-free redraws | kitty, Ghostty, WezTerm, iTerm2, Alacritty |32-33-Everything degrades quietly where it isn't supported.@@ -190,8 +190,8 @@ check protects the local app from other programs writing the file.
190190 - An idle session is closed without saving over its file; unsaved191191 changes go to a recovery file instead.192192 - A dropped connection loses unsaved changes.193-- Each session holds about 1.3 MiB of heap on a new sheet, plus 20 to194- 60 B per cell of data of the sheets it opens (a formula takes about195- 750); two sessions opening one file hold two copies. With 50 sessions typing at once on loopback, frames196- still arrive within one frame interval (p95 16.6 ms), the same as with197- 10: see [Bounds of support](../contributing/limits.md#serving-over-ssh).193+- Each session holds its screen's buffers, about a megabyte, plus the194+ sheets it opens; two sessions opening one file hold two copies. Fifty195+ sessions typing at once get their frames as fast as ten do: see196+ [Bounds of support](../contributing/limits.md#serving-over-ssh) for197+ the measurements.@@ -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@@ -20,7 +20,7 @@ func TestConfigWarnings(t *testing.T) {
2020 2121 // openTheme opens File > Settings > Theme.2222 func openTheme(s *session) {23- s.keys("<alt+f>", "<up>", "<up>", "<right>", "<down>", "<down>")23+ s.keys("<alt+f>", "<up>", "<up>", "<right>", "<down>", "<down>", "<down>")2424 s.waitFor("Pick a color theme")2525 s.keys("<enter>")2626 s.waitFor("─ Theme ─")@@ -94,7 +94,7 @@ func TestAPIKeyPrompt(t *testing.T) {
9494 cfg := t.TempDir()9595 s := startWith(t, options{configDir: cfg, env: []string{"TYPESAFE_BASE_URL=" + srv.URL}})9696 s.keys(`=JEV.TEST("The box was crushed", "Is this a complaint?")`, "<enter>")97- s.keys("<alt+f>", "<up>", "<up>", "<right>", "<down>", "<down>", "<down>")97+ s.keys("<alt+f>", "<up>", "<up>", "<right>", "<down>", "<down>", "<down>", "<down>")9898 s.waitFor("Store the TypeSafe API key")9999 s.keys("<enter>", "test-key")100100 s.waitFor("TypeSafe API key: ••••••••")@@ -479,7 +479,7 @@ var charKeys = func() map[rune]physKey {
479479 }()480480 481481 // configEnv gives a session its own config directory, with o.config as482-// its config file, and turns off the telemetry and theme variables a482+// its config file, and turns off the telemetry, theme and locale variables a483483 // developer's shell may set.484484 func configEnv(t *testing.T, o options) []string {485485 t.Helper()@@ -495,5 +495,5 @@ func configEnv(t *testing.T, o options) []string {
495495 t.Fatal(err)496496 }497497 }498- return []string{"XDG_CONFIG_HOME=" + dir, "O12_THEME=", "TYPESAFE_DEFAULT_MODEL="}498+ return []string{"XDG_CONFIG_HOME=" + dir, "O12_THEME=", "TYPESAFE_DEFAULT_MODEL=", "O12_LOCALE=", "LC_ALL=", "LC_NUMERIC=", "LANG=C.UTF-8"}499499 }@@ -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+}@@ -33,7 +33,7 @@ var fileScreens = []screen{
3333 }},3434 // Nothing listens on port 1: the key is stored, and its check fails.3535 {name: "api-key-check-failed", opts: options{env: []string{"TYPESAFE_BASE_URL=http://127.0.0.1:1"}}, setup: func(s *session) {36- s.keys("<alt+f>", "<up>", "<up>", "<right>", "<down>", "<down>", "<down>")36+ s.keys("<alt+f>", "<up>", "<up>", "<right>", "<down>", "<down>", "<down>", "<down>")3737 s.waitFor("Store the TypeSafe API key")3838 s.keys("<enter>", "test-key", "<enter>")3939 s.waitFor("Key saved, but the check failed")@@ -262,7 +262,7 @@ var screens = []screen{
262262 s.waitFor("│ › gruvbox")263263 }},264264 {name: "api-key-prompt", setup: func(s *session) {265- s.keys("<alt+f>", "<up>", "<up>", "<right>", "<down>", "<down>", "<down>")265+ s.keys("<alt+f>", "<up>", "<up>", "<right>", "<down>", "<down>", "<down>", "<down>")266266 s.waitFor("Store the TypeSafe API key")267267 s.keys("<enter>", "ts-demo-0123456789")268268 s.waitFor("TypeSafe API key: ••••••••••••••••••")@@ -0,0 +1,33 @@
1+package e2e2+3+// Golden screens for locales: File > Settings > Locale's picker narrowed4+// to a search, and a budget shown in German (Germany), with a formula in5+// its syntax in the formula bar and a function's hint while typing one.6+7+var localeScreens = []screen{8+ {name: "locale-picker", setup: func(s *session) {9+ s.keys("<ctrl+k>", "locale", "<enter>")10+ s.waitFor("Type a language, country or tag")11+ s.keys("de")12+ s.waitFor("German (Switzerland)")13+ }},14+ {name: "locale-de", setup: func(s *session) {15+ germanBudget(s)16+ s.keys("<up>", "<right>")17+ s.waitForBar("B5", "=ROUND(B4*0,19; 2)")18+ }},19+ {name: "locale-de-signature", setup: func(s *session) {20+ germanBudget(s)21+ s.keys("Rate", "<tab>", "=ROUND(B5/B4; ")22+ s.waitFor("ROUND(value; [places])")23+ }},24+}25+26+func init() {27+ for _, sc := range localeScreens {28+ screens = append(screens, sc)29+ sc.name += "-light"30+ sc.opts.light = true31+ screens = append(screens, sc)32+ }33+}@@ -0,0 +1,25 @@
1+package e2e2+3+// Golden screens for the high-contrast theme (WCAG AAA text), on a dark4+// and a light terminal, with a selection and an error, and an open menu.5+6+// highContrast is the budget with an error and a selection.7+func highContrast(s *session) {8+ budget(s)9+ s.keys("<up>", "<up>", "<up>", "<up>", "<shift+down>", "<shift+down>")10+ s.waitFor("Sum 2158.4")11+}12+13+const highContrastConfig = "theme = high-contrast\n"14+15+func init() {16+ screens = append(screens,17+ screen{name: "theme-high-contrast", opts: options{config: highContrastConfig}, setup: highContrast},18+ screen{name: "theme-high-contrast-light", opts: options{light: true, config: highContrastConfig}, setup: highContrast},19+ screen{name: "theme-high-contrast-menu", opts: options{config: highContrastConfig}, setup: func(s *session) {20+ budget(s)21+ s.keys("<alt+f>", "<down>", "<down>", "<down>")22+ s.waitFor("Save the sheet")23+ }},24+ )25+}@@ -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@@ -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@@ -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@@ -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@@ -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@@ -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@@ -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>@@ -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)">32</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">142</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@@ -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)">23</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">142</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>@@ -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)">23</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">142</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>@@ -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)">142</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>@@ -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)">142</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">142</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>@@ -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>@@ -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@@ -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@@ -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@@ -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@@ -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@@ -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>@@ -9,7 +9,7 @@ import (
99 // vimOn turns on File > Settings > Vim keys through the menus.1010 func vimOn(s *session) {1111 s.t.Helper()12- s.keys("<alt+f>", "<up>", "<up>", "<right>", "<down>")12+ s.keys("<alt+f>", "<up>", "<up>", "<right>", "<down>", "<down>")1313 s.waitFor("Move with hjkl")1414 s.keys("<enter>")1515 s.waitFor("NORMAL")@@ -136,6 +136,7 @@ func Load(path string, getenv func(string) string, flags map[string]string) *Con
136136 break137137 }138138 }139+ c.fallBack(o, getenv)139140 }140141 }141142 for name, v := range flags {@@ -146,6 +147,20 @@ func Load(path string, getenv func(string) string, flags map[string]string) *Con
146147 return c147148 }148149 150+// fallBack sets an option nothing else set from the first of its151+// Fallback variables that Adopt makes a value of.152+func (c *Config) fallBack(o *Option, getenv func(string) string) {153+ if len(c.vals[o.Name]) > 0 {154+ return155+ }156+ for _, env := range o.Fallback {157+ if v := o.Adopt(strings.TrimSpace(getenv(env))); v != "" {158+ c.set(o, v, Source{Kind: FromEnv, Name: env})159+ return160+ }161+ }162+}163+149164 // readFile reads one file and then the files it includes. seen holds the150165 // files being read, to stop include cycles.151166 func (c *Config) readFile(path string, optional bool, seen map[string]bool) {@@ -331,3 +331,30 @@ func TestConfigDoc(t *testing.T) {
331331 t.Errorf("%s is stale: run go test ./internal/config -update-docs", path)332332 }333333 }334+335+// TestLocaleFallsBackOnPOSIX reads the locale from LC_ALL, LC_NUMERIC or336+// LANG only when nothing else sets it, and passes over POSIX locales it337+// doesn't have without a warning.338+func TestLocaleFallsBackOnPOSIX(t *testing.T) {339+ dir := t.TempDir()340+ set := write(t, filepath.Join(dir, "set"), "locale = fr-FR\n")341+ unset := filepath.Join(dir, "none")342+ cases := []struct {343+ path string344+ env map[string]string345+ want string346+ }{347+ {unset, map[string]string{"LANG": "de_DE.UTF-8"}, "de-DE"},348+ {unset, map[string]string{"LANG": "de_DE.UTF-8", "LC_ALL": "pt_BR.UTF-8"}, "pt-BR"},349+ {unset, map[string]string{"LANG": "C.UTF-8"}, "en-US"},350+ {unset, map[string]string{"LANG": "xx_YY.UTF-8", "LC_NUMERIC": "sv_SE"}, "sv-SE"},351+ {set, map[string]string{"LANG": "de_DE.UTF-8"}, "fr-FR"},352+ {unset, map[string]string{"LANG": "de_DE.UTF-8", "O12_LOCALE": "ja-JP"}, "ja-JP"},353+ }354+ for _, c := range cases {355+ cfg := Load(c.path, func(k string) string { return c.env[k] }, nil)356+ if got := cfg.String("locale"); got != c.want || len(cfg.Warnings) > 0 {357+ t.Errorf("%v: locale %q, want %q; warnings %s", c.env, got, c.want, warnings(cfg))358+ }359+ }360+}@@ -30,6 +30,9 @@ func Reference() string {
3030 if len(o.Env) > 0 {3131 fmt.Fprintf(&b, "| Environment | `%s` |\n", strings.Join(o.Env, "`, `"))3232 }33+ if len(o.Fallback) > 0 {34+ fmt.Fprintf(&b, "| When unset | `%s` |\n", strings.Join(o.Fallback, "`, `"))35+ }3336 if o.Flag != "" {3437 fmt.Fprintf(&b, "| Flag | `%s` |\n", o.Flag)3538 }@@ -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,6 +102,14 @@ 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 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+95113 {Name: "max-cells", Kind: Int, Group: GroupData, Default: "10000000", Env: []string{"O12_MAX_CELLS"}, Live: true,96114 Desc: "The most cells an import keeps, and a paste or fill writes at once. Numbers and text " +97115 "take 20 to 60 bytes a cell and formulas about 750, so the default of ten million cells of " +@@ -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.@@ -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 }@@ -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 }@@ -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+}@@ -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+}@@ -18,6 +18,7 @@ import (
1818 "strings"1919 "sync/atomic"2020 21+ "github.com/FelineStateMachine/012/internal/locale"2122 "github.com/FelineStateMachine/012/internal/sheet"2223 "github.com/FelineStateMachine/012/internal/telemetry"2324 )@@ -79,6 +80,9 @@ type Options struct {
7980 // MaxCells is the most cells to keep, whole rows at a time; 0 is the8081 // max-cells setting (sheet.MaxCells). WK1 files keep their own limits.8182 MaxCells int83+ // Locale is what CSV and TSV fields are read in (see numberLocale);84+ // nil is en-US.85+ Locale *locale.Locale8286 }8387 8488 // Result is an imported sheet and what the import had to leave out or@@ -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).@@ -87,7 +90,7 @@ func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {
8790 } else {8891 r.To = r.From8992 }90- snap := &Snapshot{Range: r, Cells: map[sheet.Addr]SnapCell{}, Widths: s.Widths(), Name: name,93+ snap := &Snapshot{Range: r, Cells: map[sheet.Addr]SnapCell{}, Widths: s.Widths(), Name: name, Locale: s.Locale(),9194 ColFormats: snapLines(s, false), RowFormats: snapLines(s, true), Filter: s.Filter(),9295 CondFormats: s.CondFormats(), Validations: s.Validations()}9396 snap.FrozenRows, snap.FrozenCols = s.Frozen()@@ -166,7 +169,8 @@ func excelRange(ws string, r sheet.Rect) string {
166169 }167170 168171 // textRows yields the snapshot's displayed text row by row across its169-// range, for text formats. The slice is reused from row to row.172+// range as shown in its locale, for text formats. The slice is reused173+// from row to row.170174 func (s *Snapshot) textRows() iter.Seq[[]string] {171175 return func(yield func([]string) bool) {172176 r := s.Range@@ -175,7 +179,7 @@ func (s *Snapshot) textRows() iter.Seq[[]string] {
175179 for col := r.From.Col; col <= r.To.Col; col++ {176180 line[col-r.From.Col] = ""177181 if c, ok := s.Cells[sheet.Addr{Col: col, Row: row}]; ok {178- line[col-r.From.Col] = c.Text()182+ line[col-r.From.Col] = sheet.FormatTextIn(c.Value, c.Format, s.Locale.Or())179183 }180184 }181185 if !yield(line) {@@ -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+}@@ -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+}@@ -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+}@@ -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+}@@ -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 {@@ -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 }@@ -9,6 +9,8 @@ import (
99 "math"1010 "strings"1111 "unicode/utf8"12+13+ "github.com/FelineStateMachine/012/internal/locale"1214 )1315 1416 // Number format patterns: "#,##0.00", "0.0%", "0.00E+00",@@ -44,7 +46,12 @@ type ptok struct {
4446 }4547 4648 // Format renders v with a number format pattern, as TEXT() does.47-func Format(v float64, pat string) string {49+func Format(v float64, pat string) string { return FormatIn(v, pat, locale.Canonical) }50+51+// FormatIn renders v with a number format pattern as shown in loc: the52+// pattern is written as in en-US ("#,##0.00") and the number shows loc's53+// decimal and thousands separators ("1.234,50" in de-DE).54+func FormatIn(v float64, pat string, loc *locale.Locale) string {4855 secs := splitSections(pat)4956 sec, neg := secs[0], v < 05057 switch {@@ -57,7 +64,7 @@ func Format(v float64, pat string) string {
5764 if isDatePattern(toks) {5865 return formatDate(v, toks)5966 }60- return formatNumber(math.Abs(v), neg, toks)67+ return formatNumber(math.Abs(v), neg, toks, loc)6168 }6269 6370 // splitSections splits a pattern on ; outside quotes and escapes.@@ -4,6 +4,8 @@ import (
44 "math"55 "strconv"66 "strings"7+8+ "github.com/FelineStateMachine/012/internal/locale"79 )810 911 // Rounding is how Fixed and Round treat the digits they drop.@@ -115,6 +117,23 @@ func General(v float64) string {
115117 return strconv.FormatFloat(r, 'f', -1, 64)116118 }117119 120+// Localize writes a number in General's form (digits, a point, an121+// exponent, no grouping) with loc's decimal separator.122+func Localize(s string, loc *locale.Locale) string {123+ if loc.Decimal == '.' {124+ return s125+ }126+ return strings.Replace(s, ".", string(loc.Decimal), 1)127+}128+129+// GeneralIn is General as shown in loc: 1,5 in de-DE.130+func GeneralIn(v float64, loc *locale.Locale) string { return Localize(General(v), loc) }131+132+// GeneralFitIn is GeneralFit as shown in loc.133+func GeneralFitIn(v float64, width int, loc *locale.Locale) string {134+ return Localize(GeneralFit(v, width), loc)135+}136+118137 // GeneralFit renders v in General format within width columns: as many119138 // digits as fit, rounding decimals first, then scientific notation, then120139 // #s.@@ -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@@ -49,6 +49,10 @@ type fileFormat struct {
4949 // version bump: earlier builds ignore it and compute in binary, as5050 // Sheets would. It is for the whole workbook, so it stays at the top.5151 Arithmetic string `json:"arithmetic,omitempty"`52+ // Locale is the tag of the workbook's locale ("de-DE"), when it53+ // names one. It only changes how entries are typed and shown (cells54+ // are stored in en-US's form), so it needs no version bump either.55+ Locale string `json:"locale,omitempty"`5256 // Macros and where they were made need no version bump either:5357 // earlier builds ignore them, and the sheets read the same.5458 MacroOrigin string `json:"macroOrigin,omitempty"`@@ -139,7 +143,7 @@ func jsonString(s string) string {
139143 }140144 141145 // headLines are the workbook's fields before the sheets: the named ranges142-// and the arithmetic setting, separated as the lines of the file.146+// and the settings, separated as the lines of the file.143147 func (w *Workbook) headLines() string {144148 var lines []string145149 if names := w.namesLine(); names != "" {@@ -148,6 +152,9 @@ func (w *Workbook) headLines() string {
148152 if w.decimal {149153 lines = append(lines, `"arithmetic": "decimal"`)150154 }155+ if w.locale != nil {156+ lines = append(lines, `"locale": `+jsonString(w.locale.Tag))157+ }151158 if len(w.macros) > 0 {152159 if w.macroOrigin != "" {153160 lines = append(lines, `"macroOrigin": `+jsonString(w.macroOrigin))@@ -7,6 +7,8 @@ import (
77 "fmt"88 "io"99 "strings"10+11+ "github.com/FelineStateMachine/012/internal/locale"1012 )1113 1214 // The reader streams the file (see scanner): cells go straight into@@ -98,6 +100,10 @@ func readBook(r io.Reader, trace any) (*Workbook, error) {
98100 // Anything but "decimal" (say, a mode from a later build) computes in99101 // binary, as the file would in a build without the setting.100102 w.decimal = f.Arithmetic == "decimal"103+ // A locale this build doesn't know follows the default.104+ if l, ok := locale.Lookup(f.Locale); ok {105+ w.locale = l106+ }101107 if err := w.readMacros(f.Macros); err != nil {102108 return nil, err103109 }@@ -7,6 +7,8 @@ import (
77 "fmt"88 "strings"99 "testing"10+11+ "github.com/FelineStateMachine/012/internal/locale"1012 )1113 1214 // readWhole reads a file the way encoding/json reads it whole, into@@ -55,6 +57,9 @@ func readWhole(data []byte) (w *Workbook, ambiguous bool, err error) {
5557 w.active = clampInt(f.Active, 0, len(w.sheets)-1)5658 w.settleHidden()5759 w.decimal = f.Arithmetic == "decimal"60+ if l, ok := locale.Lookup(f.Locale); ok {61+ w.locale = l62+ }5863 if err := w.readMacros(f.Macros); err != nil {5964 return nil, false, err6065 }@@ -60,8 +60,9 @@ type step struct {
6060 rules map[*Sheet]rulesState6161 // sheets is the sheet list before the step, when it changed.6262 sheets *sheetList63- // decimal is the arithmetic setting before the step, when it changed.64- decimal *bool63+ // settings are the workbook's settings before the step, when they64+ // changed.65+ settings *settings6566 // macros is the macro list before the step, when it changed.6667 macros *[]Macro6768 }@@ -83,7 +84,7 @@ func (st *step) empty() bool {
8384 8485 // widthOnly reports whether the step changed nothing but column widths.8586 func (st *step) widthOnly() bool {86- 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 &&87+ 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 &&8788 st.macros == nil8889 }8990 @@ -217,12 +218,12 @@ func (w *Workbook) recordSheets() {
217218 }218219 }219220 220-// recordDecimal saves the arithmetic setting before its first change in221-// the open step.222-func (w *Workbook) recordDecimal() {223- if st := w.hist.open; st != nil && st.decimal == nil {224- d := w.decimal225- st.decimal = &d221+// recordSettings saves the workbook's settings before their first change222+// in the open step.223+func (w *Workbook) recordSettings() {224+ if st := w.hist.open; st != nil && st.settings == nil {225+ cur := w.settings226+ st.settings = &cur226227 }227228 }228229 @@ -280,8 +281,8 @@ func (w *Workbook) dropUnchanged(st *step) {
280281 if st.sheets != nil && st.sheets.equal(w.sheetList()) {281282 st.sheets = nil282283 }283- if st.decimal != nil && *st.decimal == w.decimal {284- st.decimal = nil284+ if st.settings != nil && *st.settings == w.settings {285+ st.settings = nil285286 }286287 if st.macros != nil && slices.Equal(*st.macros, w.macros) {287288 st.macros = nil@@ -460,10 +461,11 @@ func (w *Workbook) swap(undo bool) (Change, bool) {
460461 s.rules = r461462 s.looks.reset()462463 }463- if st.decimal != nil {464- cur := w.decimal465- inv.decimal, w.decimal = &cur, *st.decimal466- w.structural = true // every formula computes differently464+ if st.settings != nil {465+ cur := w.settings466+ inv.settings, w.settings = &cur, *st.settings467+ // Every formula computes differently in the other arithmetic.468+ w.structural = w.structural || cur.decimal != w.decimal467469 }468470 if st.macros != nil {469471 cur := w.macros@@ -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+}@@ -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+}@@ -38,6 +38,7 @@ func (s *Sheet) Move(src Rect, to Addr) (Rect, error) {
3838 s.remap(label, dst, cell, rng)3939 s.remapNames(rng) // a name for exactly the moved cells follows them4040 s.moveFormats(s, &f, src, dst)41+ s.moveRules(src, dst, cell, rng) // rulemove.go4142 })4243 return dst, nil4344 }@@ -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.@@ -215,12 +216,12 @@ func (c *Clip) Size() (cols, rows int) {
215216 return c.Src.To.Col - c.Src.From.Col + 1, c.Src.To.Row - c.Src.From.Row + 1216217 }217218 218-// Text returns the clip's values as displayed in General format, row by219+// TextIn returns the clip's values as General shows them in loc, row by219220 // row, for the system clipboard. Blank rows and columns past the last220221 // value are left off, so copying whole columns gives their data; a block221222 // of more than MaxCells cells, blanks between values included, is too222-// big for text, and Text returns nil.223-func (c *Clip) Text() [][]string {223+// big for text, and TextIn returns nil.224+func (c *Clip) TextIn(loc *locale.Locale) [][]string {224225 cols, rows := 0, 0225226 for off, cell := range c.cells {226227 if !cell.Blank() {@@ -235,7 +236,7 @@ func (c *Clip) Text() [][]string {
235236 out[r] = make([]string, cols)236237 for col := range out[r] {237238 if cell := c.cells[Addr{Col: col, Row: r}]; cell != nil {238- out[r][col] = cell.Value.String()239+ out[r][col] = textIn(cell.Value, loc)239240 }240241 }241242 }@@ -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+}@@ -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+}@@ -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() }@@ -49,10 +49,10 @@ type Workbook struct {
4949 evaluating loc5050 // depth counts the cells and operators being evaluated, nested; see5151 // evaluate.go.52- depth int53- hist history // undo and redo, see history.go54- active int // the sheet last shown, saved in the file55- decimal bool // decimal arithmetic, see decimal.go52+ depth int53+ hist history // undo and redo, see history.go54+ active int // the sheet last shown, saved in the file55+ settings5656 5757 macros []Macro // see macros.go5858 macroOrigin string@@ -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))@@ -38,7 +38,7 @@ func TestAutocompleteFunctions(t *testing.T) {
3838 t.Errorf("signature %q", ctx)3939 }4040 press(t, m, "1,")41- sig, _ := signature(&m.th, m.line.Buf, m.line.Pos)41+ sig, _ := signature(&m.th, m.line.Buf, m.line.Pos, ',')4242 if !strings.Contains(sig, m.th.Argument.Render("[value2, ...]")) {4343 t.Errorf("second argument not marked: %q", sig)4444 }@@ -44,7 +44,7 @@ func init() {
4444 func (m *Model) copy(cut bool) tea.Cmd {4545 clip := m.sheet.Copy(m.selection())4646 m.copied = clipboard{clip: clip, sheet: m.sheet, cut: cut, marked: true}47- text := clip.Text()47+ text := clip.TextIn(m.locale())4848 if text == nil {4949 m.note = "Copied; too many cells for the system clipboard, but Ctrl+V pastes them here"5050 return nil@@ -79,6 +79,11 @@ func (m *Model) paste(values bool) tea.Cmd {
7979 m.fail(err.Error())8080 return nil8181 }82+ // Cells moved on their sheet take their rules along (sheet.Move);83+ // anything else meets the rules where it lands.84+ if (!c.cut || values || c.sheet != m.sheet) && !m.checkWritten(r, "Paste") {85+ return nil86+ }8287 m.selectRect(r)8388 switch {8489 case c.cut && !values:@@ -167,13 +172,16 @@ func (m *Model) pasteText(content string) bool {
167172 v = row[j]168173 }169174 a := sheet.Addr{Col: r.From.Col + j, Row: r.From.Row + i}170- if m.sheet.Set(a, v) != nil {175+ if m.sheet.Set(a, m.storedEntry(v)) != nil {171176 m.sheet.Set(a, "'"+v) // a broken formula stays as text172177 }173178 }174179 }175180 return nil176181 })182+ if !m.checkWritten(r, "Paste") {183+ return true184+ }177185 m.selectRect(r)178186 m.note = "Pasted " + countCells(r) + " at " + r.String()179187 return true@@ -134,9 +134,12 @@ func (g *grid) selectRect(r sheet.Rect) {
134134 }135135 136136 func (m *Model) fill(fn func(sheet.Rect) (sheet.Rect, error)) tea.Cmd {137- if _, err := fn(m.selection()); err != nil {137+ r, err := fn(m.selection())138+ if err != nil {138139 m.fail(err.Error())140+ return nil139141 }142+ m.checkWritten(r, "Fill")140143 return nil141144 }142145 @@ -151,11 +154,14 @@ func (m *Model) structural(err error) tea.Cmd {
151154 // fillEntry stores the entry being typed in every selected cell, adjusting152155 // references as if it were copied from the active cell (Ctrl+Enter).153156 func (m *Model) fillEntry() bool {154- input := m.line.Text()157+ input := m.storedEntry(m.line.Text())155158 if err := m.entrySheet().FillEntry(m.selection(), m.cur, input); err != nil {156159 m.entryError(err, input)157160 return false158161 }162+ if m.checkEntryFill(m.entrySheet().InvalidIn(m.selection()), input) {163+ return false164+ }159165 m.cancelEntry()160166 m.recordEntry(input, true)161167 return true@@ -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@@ -93,6 +93,9 @@ func (m *Model) finishFill() {
9393 m.fail(err.Error())9494 return9595 }96+ if !m.checkWritten(got, "Fill") {97+ return98+ }9699 m.changed = true97100 m.selectRect(got)98101 m.record(fillAction(m.rec, src, dst))@@ -141,7 +141,7 @@ func (m *Model) openValuesPicker(title string, x int, values []sheet.FilterValue
141141 for _, v := range p.values {142142 p.checked[v.Text] = v.Shown143143 }144- p.fields[1] = p.cond.Arg144+ p.fields[1] = sheet.LocalArg(p.cond.Arg, m.locale())145145 m.openOverlay(p)146146 m.line.Clear()147147 p.search()@@ -239,7 +239,7 @@ func (p *filterPicker) apply(m *Model) {
239239 cr.Hidden = append(cr.Hidden, v.Text)240240 }241241 }242- cr.Cond = sheet.Condition{Op: p.cond.Op, Arg: strings.TrimSpace(p.fields[1])}242+ cr.Cond = sheet.Condition{Op: p.cond.Op, Arg: sheet.CanonicalArg(strings.TrimSpace(p.fields[1]), m.locale())}243243 if !cr.Cond.Op.TakesArg() {244244 cr.Cond.Arg = ""245245 } else if cr.Cond.Arg == "" {@@ -43,7 +43,7 @@ func spanStyle(th *theme.Theme, base lipgloss.Style, sp rowtext.Span) lipgloss.S
4343 case sp.Invalid:4444 s = s.Inherit(th.Invalid)4545 }46- return s46+ return theme.Drawable(s)4747 }4848 4949 // spillLine says where the active cell's value comes from when an array@@ -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+}@@ -0,0 +1,73 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// File > Settings > Locale picks German (Germany): the sheet shows11+// decimal commas without changing a value, entries and formulas are12+// typed the German way and stored the en-US way, the formula bar and13+// editing show them German again, and undo goes back to en-US.14+func TestLocaleSetting(t *testing.T) {15+ m := newModel()16+ press(t, m, "1234.5", "<enter>", "=A1/4", "<enter>")17+ press(t, m, "<alt+f>", "<up>", "<up>", "<right>", "<down>", "<enter>")18+ if !strings.Contains(screen(m), "German (Germany)") {19+ t.Fatalf("no locale picker:\n%s", screen(m))20+ }21+ press(t, m, "Germany", "<enter>")22+ if tag := m.book().LocaleTag(); tag != "de-DE" || !strings.Contains(line(m, contextLine), "1.234,56") {23+ t.Fatalf("locale %q, context %q", tag, line(m, contextLine))24+ }25+ if v := m.sheet.Value(addr("A2")).Num; v != 308.625 || !strings.Contains(screen(m), "308,625") {26+ t.Errorf("A2 = %v:\n%s", v, screen(m))27+ }28+ press(t, m, "1,5", "<enter>")29+ if input(m, "A3") != "1.5" || m.sheet.Value(addr("A3")).Num != 1.5 {30+ t.Errorf("A3 %q = %v", input(m, "A3"), m.sheet.Value(addr("A3")))31+ }32+ press(t, m, "=ROUND(A1;")33+ if ctx := line(m, contextLine); !strings.HasPrefix(ctx, "ROUND(value; [places])") {34+ t.Errorf("signature %q", ctx)35+ }36+ press(t, m, "<backspace>", "*1,5; 1)", "<enter>")37+ if input(m, "A4") != "=ROUND(A1*1.5, 1)" || m.sheet.Value(addr("A4")).Num != 1851.8 {38+ t.Errorf("A4 %q = %v", input(m, "A4"), m.sheet.Value(addr("A4")))39+ }40+ m.cur = addr("A4")41+ if bar := line(m, formulaLine); !strings.Contains(bar, "=ROUND(A1*1,5; 1)") {42+ t.Errorf("formula bar %q", bar)43+ }44+ press(t, m, "<f2>")45+ if m.line.Text() != "=ROUND(A1*1,5; 1)" {46+ t.Errorf("editing %q", m.line.Text())47+ }48+ press(t, m, "<esc>", "<ctrl+z>", "<ctrl+z>", "<ctrl+z>")49+ if tag := m.book().LocaleTag(); tag != "" || !strings.Contains(screen(m), "308.625") {50+ t.Errorf("after undo: locale %q\n%s", tag, screen(m))51+ }52+}53+54+// Pasted text is read in the workbook's locale, and copied values are55+// written in it.56+func TestLocalePasteAndCopy(t *testing.T) {57+ m := newModel()58+ m.book().SetLocale("de-DE")59+ m.handlePaste("1,5\t26.09.2026\n2.000\tText")60+ if v := m.sheet.Value(addr("A1")).Num; v != 1.5 {61+ t.Errorf("A1 = %v", v)62+ }63+ if f := m.sheet.Cell(addr("B1")).Format; f.Kind != sheet.FmtDate {64+ t.Errorf("B1 format %v", f)65+ }66+ if v := m.sheet.Value(addr("A2")).Num; v != 2000 {67+ t.Errorf("A2 = %v", v)68+ }69+ clip := m.sheet.Copy(sheet.NewRect(addr("A1"), addr("A2")))70+ if got := formatTSV(clip.TextIn(m.locale())); got != "1,5\n2000" {71+ t.Errorf("copied %q", got)72+ }73+}@@ -35,7 +35,7 @@ var menuBar = []menuDef{
3535 {cmd: "file.new"}, {cmd: "file.open"}, {cmd: "file.import"}, sep,3636 {cmd: "file.save"}, {cmd: "file.saveas"}, {title: "Download", items: downloadItems()}, sep,3737 {title: "Settings", items: []menuItem{38- {cmd: "settings.decimal"}, {cmd: "settings.vim"}, sep,38+ {cmd: "settings.decimal"}, {cmd: "settings.locale"}, {cmd: "settings.vim"}, sep,3939 {cmd: "settings.theme"}, {cmd: "settings.jev_key"}, sep,4040 {cmd: "settings.config_edit"}, {cmd: "settings.config_reload"},4141 }}, sep,@@ -0,0 +1,302 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+ "github.com/FelineStateMachine/012/internal/ui/theme"9+)10+11+// Every state reads without color (docs/contributing/ux.md#visual-rules):12+// drawn with a monochrome theme, where every role has the same color,13+// each state still differs from a plain cell in its text or attributes.14+15+// attrs are the attributes a terminal cell is drawn with, colors aside.16+type attrs struct {17+ bold, faint, italic, reverse, strike bool18+ underline string // "" none, or the SGR 4 sub-parameter: 1 single, 3 curly, 4 dotted, 5 dashed19+}20+21+// monoCell is one terminal cell: its text and attributes.22+type monoCell struct {23+ text string24+ attrs25+}26+27+// monoCells splits a rendered line into cells, reading SGR attributes28+// and skipping colors and other escape sequences.29+func monoCells(line string) []monoCell {30+ var out []monoCell31+ var a attrs32+ for i := 0; i < len(line); {33+ switch {34+ case strings.HasPrefix(line[i:], "\x1b["):35+ j := i + 236+ for j < len(line) && (line[j] < 0x40 || line[j] > 0x7e) {37+ j++38+ }39+ if j < len(line) && line[j] == 'm' {40+ sgr(line[i+2:j], &a)41+ }42+ i = j + 143+ case strings.HasPrefix(line[i:], "\x1b]"): // OSC, e.g. a hyperlink, up to ST or BEL44+ end := strings.Index(line[i:], "\x1b\\")45+ bel := strings.IndexByte(line[i:], '\a')46+ switch {47+ case bel >= 0 && (end < 0 || bel < end):48+ i += bel + 149+ case end >= 0:50+ i += end + 251+ default:52+ i = len(line)53+ }54+ default:55+ r := []rune(line[i:])[0]56+ out = append(out, monoCell{text: string(r), attrs: a})57+ i += len(string(r))58+ }59+ }60+ return out61+}62+63+// sgr applies one SGR sequence's parameters to a.64+func sgr(params string, a *attrs) {65+ fields := strings.Split(params, ";")66+ for i := 0; i < len(fields); i++ {67+ p, sub, _ := strings.Cut(fields[i], ":")68+ switch p {69+ case "", "0":70+ *a = attrs{}71+ case "1":72+ a.bold = true73+ case "2":74+ a.faint = true75+ case "3":76+ a.italic = true77+ case "4":78+ a.underline = sub79+ if sub == "" {80+ a.underline = "1"81+ } else if sub == "0" {82+ a.underline = ""83+ }84+ case "7":85+ a.reverse = true86+ case "9":87+ a.strike = true88+ case "22":89+ a.bold, a.faint = false, false90+ case "23":91+ a.italic = false92+ case "24":93+ a.underline = ""94+ case "27":95+ a.reverse = false96+ case "29":97+ a.strike = false98+ case "38", "48", "58":99+ if sub == "" && i+1 < len(fields) {100+ switch fields[i+1] {101+ case "5":102+ i += 2103+ case "2":104+ i += 4105+ }106+ }107+ }108+ }109+}110+111+// monoLine is screen line y drawn in monochrome, as cells.112+func monoLine(m *Model, y int) []monoCell {113+ m.th = theme.Monochrome(m.th)114+ return monoCells(strings.Split(m.View().Content, "\n")[y])115+}116+117+// cellAt is the terminal cells of sheet cell a (in the first screen of118+// an unscrolled sheet).119+func cellAt(m *Model, a sheet.Addr) []monoCell {120+ x := m.hdrW() + a.Col*sheet.DefaultWidth121+ cells := monoLine(m, gridTop+a.Row)122+ return cells[x : x+sheet.DefaultWidth]123+}124+125+func cellText(cs []monoCell) string {126+ var b strings.Builder127+ for _, c := range cs {128+ b.WriteString(c.text)129+ }130+ return b.String()131+}132+133+// every reports whether f holds for every cell of cs; with text, only for134+// those that aren't blank.135+func every(cs []monoCell, text bool, f func(monoCell) bool) bool {136+ for _, c := range cs {137+ if text && strings.TrimSpace(c.text) == "" {138+ continue139+ }140+ if !f(c) {141+ return false142+ }143+ }144+ return true145+}146+147+func isReverse(c monoCell) bool { return c.reverse }148+func plain(c monoCell) bool { return c.attrs == attrs{} }149+150+func TestMonochromePointer(t *testing.T) {151+ m := newModel()152+ m.sheet.Set(addr("B1"), "text")153+ if p := cellAt(m, addr("A1")); !every(p, false, isReverse) {154+ t.Errorf("the pointer isn't in reverse video: %+v", p)155+ }156+ if c := cellAt(m, addr("B1")); !every(c, false, plain) {157+ t.Errorf("a plain cell has attributes: %+v", c)158+ }159+}160+161+func TestMonochromeSelection(t *testing.T) {162+ m := newModel()163+ press(t, m, "<shift+right>", "<shift+down>")164+ for _, a := range []string{"A1", "B1", "A2", "B2"} {165+ if c := cellAt(m, addr(a)); !every(c, false, isReverse) {166+ t.Errorf("%s in the selection isn't in reverse video", a)167+ }168+ }169+ if c := cellAt(m, addr("C3")); !every(c, false, plain) {170+ t.Error("a cell outside the selection has attributes")171+ }172+ // Column headers: the active cell's (the selection's anchor, A1)173+ // bold and reversed, another selected one reversed, the rest plain.174+ hdr := monoLine(m, headerLine)175+ x := m.hdrW()176+ col := func(c int) []monoCell { return hdr[x+c*sheet.DefaultWidth : x+(c+1)*sheet.DefaultWidth] }177+ if !every(col(0), true, func(c monoCell) bool { return c.reverse && c.bold }) {178+ t.Errorf("the active column's header: %+v", col(0))179+ }180+ if !every(col(1), true, func(c monoCell) bool { return c.reverse && !c.bold }) {181+ t.Errorf("a selected column's header: %+v", col(1))182+ }183+ if every(col(2), true, isReverse) {184+ t.Error("an unselected column's header is reversed")185+ }186+ if !strings.Contains(cellText(monoLine(m, formulaLine)), "A1:B2") {187+ t.Error("the name box doesn't name the selection")188+ }189+}190+191+func TestMonochromeError(t *testing.T) {192+ m := newModel()193+ m.sheet.Set(addr("B1"), "=1/0")194+ c := cellAt(m, addr("B1"))195+ if !strings.Contains(cellText(c), "#DIV/0!") || !every(c, true, func(c monoCell) bool { return c.underline == "3" }) {196+ t.Errorf("an error cell: %q %+v", cellText(c), c)197+ }198+}199+200+func TestMonochromeInvalid(t *testing.T) {201+ m := newModel()202+ mustValidate(t, m, `{"ranges":"B1:B9","criteria":"number","condition":"between","values":["1","10"]}`)203+ m.sheet.Set(addr("B1"), "42")204+ c := cellAt(m, addr("B1"))205+ if !every(c, true, func(c monoCell) bool { return c.underline == "4" }) {206+ t.Errorf("an invalid cell isn't dotted: %+v", c)207+ }208+ m.cur = addr("B1")209+ if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "Invalid:") {210+ t.Errorf("no warning in words: %q", l)211+ }212+}213+214+func TestMonochromeCircular(t *testing.T) {215+ m := newModel()216+ m.sheet.Set(addr("A1"), "=A1+1")217+ if l := cellText(monoLine(m, m.height-1)); !strings.Contains(l, "Circular reference") {218+ t.Errorf("status line: %q", l)219+ }220+}221+222+func TestMonochromeSpill(t *testing.T) {223+ m := newModel()224+ m.sheet.Set(addr("B1"), "=SEQUENCE(3)")225+ m.cur = addr("D1")226+ c := cellAt(m, addr("B2"))227+ if strings.TrimSpace(cellText(c)) != "2" || !every(c, true, func(c monoCell) bool { return c.italic }) {228+ t.Errorf("a spilled value isn't italic: %+v", c)229+ }230+ m.cur = addr("B2")231+ if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "Spilled from B1") {232+ t.Errorf("context line: %q", l)233+ }234+}235+236+func TestMonochromeNote(t *testing.T) {237+ m := newModel()238+ m.sheet.Set(addr("B1"), "x")239+ m.sheet.SetNote(addr("B1"), "Checked")240+ if c := cellAt(m, addr("B1")); c[len(c)-1].text != "▝" {241+ t.Errorf("no note mark: %q", cellText(c))242+ }243+}244+245+func TestMonochromeFoundTraced(t *testing.T) {246+ m := traceModel(t)247+ m.cur = addr("B1")248+ press(t, m, "<alt+,>")249+ if c := cellAt(m, addr("A2")); !every(c, false, isReverse) {250+ t.Errorf("a traced cell isn't reversed: %+v", c)251+ }252+ m = newModel()253+ m.sheet.Set(addr("B2"), "Rent")254+ press(t, m, "<ctrl+f>", "Rent")255+ if c := cellAt(m, addr("B2")); !every(c, false, isReverse) {256+ t.Errorf("a match isn't reversed: %+v", c)257+ }258+}259+260+func TestMonochromeCopied(t *testing.T) {261+ m := newModel()262+ m.sheet.Set(addr("A1"), "1")263+ press(t, m, "<ctrl+c>", "<right>")264+ c := cellAt(m, addr("A1"))265+ if !every(c, false, func(c monoCell) bool { return c.underline != "" }) || !every(c, true, func(c monoCell) bool { return c.underline == "5" }) {266+ t.Errorf("the copied range isn't underlined, dashed under its text: %+v", c)267+ }268+}269+270+// States told in words: the mode indicator, recording, protected ranges271+// and pivot tables.272+func TestMonochromeStatesInWords(t *testing.T) {273+ m := newModel()274+ if l := cellText(monoLine(m, menuLine)); !strings.Contains(l, "READY") {275+ t.Errorf("indicator: %q", l)276+ }277+ press(t, m, "1")278+ if l := cellText(monoLine(m, menuLine)); !strings.Contains(l, "ENTER") {279+ t.Errorf("indicator while typing: %q", l)280+ }281+ press(t, m, "<esc>")282+ run(m, m.runCommand("macro.record"))283+ if l := cellText(monoLine(m, menuLine)); !strings.Contains(l, "REC") {284+ t.Errorf("recording: %q", l)285+ }286+287+ m = newModel()288+ press(t, m, "<shift+down>")289+ m.runCommand("data.protect_range")290+ press(t, m, "<enter>", "<esc>", "7")291+ if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "is protected") {292+ t.Errorf("protected: %q", l)293+ }294+295+ m = wideModel()296+ salesTable(t, m)297+ m.runCommand("data.pivot")298+ press(t, m, "<space>", "reg", "<enter>", "<enter>", "<ctrl+home>", "x")299+ if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "Pivot table results can't be edited") {300+ t.Errorf("pivot: %q", l)301+ }302+}@@ -181,7 +181,7 @@ func (m *Model) leftPress(h hit, mouse tea.Mouse, double bool) tea.Cmd {
181181 case h.kind == hitEditLine:182182 m.line.SetCaret(h.x)183183 return nil184- case h.kind == hitCell && m.line.IsFormula() && m.line.CanPoint():184+ case h.kind == hitCell && m.line.IsFormula() && m.canPoint():185185 m.entry.prefix = m.line.Head()186186 m.entry.suffix = m.line.Tail()187187 m.point = pointer{at: h.addr, anchor: h.addr}@@ -12,6 +12,7 @@ import (
1212 "charm.land/lipgloss/v2"1313 "github.com/charmbracelet/x/ansi"1414 15+ "github.com/FelineStateMachine/012/internal/numfmt"1516 "github.com/FelineStateMachine/012/internal/sheet"1617 "github.com/FelineStateMachine/012/internal/telemetry"1718 "github.com/FelineStateMachine/012/internal/ui/overlay"@@ -188,10 +189,10 @@ func (m *Model) formulaBar() string {
188189 if anchor, ok := m.sheet.SpillAnchor(m.cur); ok && m.sheet.HasSpills() {189190 // A spilled cell shows the formula it spills from, dimmed, as190191 // Sheets does.191- return box + m.th.Muted.Render(m.sheet.Cell(anchor).Input)192+ return box + m.th.Muted.Render(m.shownEntry(m.sheet.Cell(anchor).Input))192193 }193194 if c := m.sheet.Cell(m.cur); c != nil {194- return box + c.Input195+ return box + m.shownEntry(c.Input)195196 }196197 return box197198 }@@ -244,11 +245,11 @@ func (m *Model) contextLineText() string {
244245 case m.mode == modePoint:245246 prefix := []rune(m.entry.prefix)246247 var ok bool247- if left, right, ok = signatureLine(&m.th, m.width, prefix, len(prefix), m.th.KeyHints("Shift+arrows", "range", "Esc", "back")); !ok {248+ if left, right, ok = signatureLine(&m.th, m.width, m.storedFormula(prefix), len(prefix), m.locale().ArgSep(), m.th.KeyHints("Shift+arrows", "range", "Esc", "back")); !ok {248249 left = m.th.KeyHints("Arrows", "pick a cell", "Shift+arrows", "pick a range", "Enter", "accept", "Esc", "back")249250 }250- case (m.mode == modeEnter || m.mode == modeEdit) && m.line.IsFormula() && inFunction(&m.line):251- left, right, _ = signatureLine(&m.th, m.width, m.line.Buf, m.line.Pos, m.th.KeyHints("Enter", "accept", "Esc", "cancel"))251+ case (m.mode == modeEnter || m.mode == modeEdit) && m.line.IsFormula() && m.inFunction():252+ left, right, _ = signatureLine(&m.th, m.width, m.storedFormula(m.line.Buf), m.line.Pos, m.locale().ArgSep(), m.th.KeyHints("Enter", "accept", "Esc", "cancel"))252253 case m.mode == modeEnter && m.line.IsFormula():253254 left = m.th.KeyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "pick cells after an operator", "Esc", "cancel")254255 case m.mode == modeEnter:@@ -401,8 +402,8 @@ func (m *Model) statusRights() []string {
401402 rng := m.th.Key.Render(r.String())402403 sum, avg := "", ""403404 if st.Nums > 0 {404- sum = m.th.Muted.Render("Sum ") + fmtStat(st.Sum)405- avg = m.th.Muted.Render("Avg ") + fmtStat(st.Sum/float64(st.Nums))405+ sum = m.th.Muted.Render("Sum ") + m.fmtStat(st.Sum)406+ avg = m.th.Muted.Render("Avg ") + m.fmtStat(st.Sum/float64(st.Nums))406407 }407408 count := m.th.Muted.Render("Count ") + strconv.Itoa(st.Count)408409 // As many stats as fit: Avg goes first, then Sum, then Count.@@ -423,6 +424,6 @@ func (m *Model) statusRights() []string {
423424 424425 // fmtStat formats a status line statistic with at most two decimals, as425426 // Sheets does.426-func fmtStat(v float64) string {427- return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64)427+func (m *Model) fmtStat(v float64) string {428+ return numfmt.Localize(strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64), m.locale())428429 }@@ -13,6 +13,7 @@ import (
1313 "github.com/FelineStateMachine/012/internal/config"1414 "github.com/FelineStateMachine/012/internal/jev"1515 "github.com/FelineStateMachine/012/internal/keyring"16+ "github.com/FelineStateMachine/012/internal/locale"1617 "github.com/FelineStateMachine/012/internal/sheet"1718 "github.com/FelineStateMachine/012/internal/ui/theme"1819 "github.com/FelineStateMachine/012/internal/ui/themepicker"@@ -62,6 +63,9 @@ func (m *Model) applyConfig() string {
6263 m.term.noNotify = !c.Bool("notifications")6364 m.SetVimKeys(c.String("keymap") == "vim")6465 sheet.SetMaxCells(c.Int("max-cells"))66+ if l, ok := locale.Lookup(c.String("locale")); ok {67+ sheet.SetDefaultLocale(l)68+ }6569 return m.applyTheme()6670 }6771 @@ -50,7 +50,7 @@ func (m *Model) copyRows(r sheet.Rect) tea.Cmd {
5050 clip := m.sheet.Copy(r)5151 m.copied = clipboard{clip: clip, sheet: m.sheet}5252 m.vim.rows = clip53- return tea.SetClipboard(formatTSV(clip.Text()))53+ return tea.SetClipboard(formatTSV(clip.TextIn(m.locale())))5454 }5555 5656 // rowsCopied reports whether the clipboard holds whole rows.@@ -10,6 +10,7 @@ import (
1010 1111 "github.com/charmbracelet/x/ansi"1212 13+ "github.com/FelineStateMachine/012/internal/locale"1314 "github.com/FelineStateMachine/012/internal/sheet"1415 )1516 @@ -41,7 +42,7 @@ type Span struct {
4142 // It can come from cells outside the viewport, so the scan starts at the4243 // nearest filled cell on each side.4344 func Layout(s *sheet.Sheet, row, lo, ncols, minCol, maxCol int) []Span {44- l := rowLayout{s: s, row: row, lo: lo, hi: lo + ncols - 1, out: make([]Span, ncols)}45+ l := rowLayout{s: s, loc: s.Locale(), row: row, lo: lo, hi: lo + ncols - 1, out: make([]Span, ncols)}4546 for i := range l.out {4647 l.out[i] = Span{Trail: s.ColWidth(lo + i)}4748 }@@ -63,6 +64,7 @@ func Layout(s *sheet.Sheet, row, lo, ncols, minCol, maxCol int) []Span {
6364 // rowLayout is Layout's work in progress on one row.6465 type rowLayout struct {6566 s *sheet.Sheet67+ loc *locale.Locale // what numbers are shown in6668 row int6769 lo, hi int // the columns laid out6870 first, last int // the columns whose text may reach them@@ -141,13 +143,13 @@ func (l *rowLayout) place(c int, v sheet.Value) {
141143 // the text, its alignment and the padding on its aligned side.142144 func (l *rowLayout) display(c int, v sheet.Value, w int) (string, sheet.Align, int) {143145 f := l.s.DisplayFormat(sheet.Addr{Col: c, Row: l.row})144- text, align := sheet.Display(v, f, w)146+ text, align := sheet.DisplayIn(v, f, w, l.loc)145147 pad := 1146148 // A number one character too wide (12/31/2026 in a default column)147149 // may use the padding when nothing is to its right, rather than148150 // turning into #s.149151 if v.Kind == sheet.Number && strings.Trim(text, "#") == "" && !l.filled(c+1) {150- if wider, _ := sheet.Display(v, f, w+1); strings.Trim(wider, "#") != "" {152+ if wider, _ := sheet.DisplayIn(v, f, w+1, l.loc); strings.Trim(wider, "#") != "" {151153 text, pad = wider, 0152154 }153155 }@@ -2,6 +2,7 @@ package ui
22 33 import (44 "errors"5+ "fmt"56 "image/color"67 78 tea "charm.land/bubbletea/v2"@@ -258,6 +259,53 @@ func (m *Model) checkEntry(input string) (warn string, ok bool) {
258259 return bad.Error(), true259260 }260261 262+// checkWritten checks what a paste or fill (what) wrote into r against263+// the cells' validation, as Sheets does: when a rule that rejects fails,264+// the whole change is taken back and ERROR mode says why; when only265+// rules that warn fail, it stays, the cells are marked, and the context266+// line says how many. It reports whether the change stayed.267+func (m *Model) checkWritten(r sheet.Rect, what string) bool {268+ if !m.sheet.HasRules() {269+ return true270+ }271+ bad := m.sheet.InvalidIn(r)272+ for _, b := range bad {273+ if b.Reject {274+ m.sheet.Discard()275+ m.fail(what + " undone: " + b.Error())276+ return false277+ }278+ }279+ if len(bad) > 0 {280+ m.warn = invalidNote(bad)281+ }282+ return true283+}284+285+// checkEntryFill handles the cells an entry filled (Ctrl+Enter) that fail286+// their validation: a rule that rejects takes the fill back and keeps287+// the entry open with the rule's help, as for one cell; rules that warn288+// let it in, saying so. It reports whether the entry stays open.289+func (m *Model) checkEntryFill(bad []*sheet.InvalidEntry, input string) bool {290+ for _, b := range bad {291+ if b.Reject {292+ m.entrySheet().Discard()293+ m.entryError(b, input)294+ return true295+ }296+ }297+ if len(bad) > 0 {298+ m.warn = invalidNote(bad)299+ }300+ return false301+}302+303+// invalidNote says how many cells a paste or fill left invalid, and why304+// the first is.305+func invalidNote(bad []*sheet.InvalidEntry) string {306+ return fmt.Sprintf("%s invalid, first %s: %s", cellCount(len(bad)), bad[0].Addr, bad[0].Help)307+}308+261309 // The rules panel's host.262310 263311 func (h host) Sheet() *sheet.Sheet { return h.m.sheet }@@ -3,6 +3,7 @@ package rules
33 import (44 "strings"55 6+ "github.com/FelineStateMachine/012/internal/locale"67 "github.com/FelineStateMachine/012/internal/sheet"78 "github.com/FelineStateMachine/012/internal/ui/theme"89 )@@ -81,6 +82,7 @@ type cfForm struct {
8182 under bool8283 strike bool8384 pts [3]cfPoint85+ loc *locale.Locale // what args are typed in8486 }8587 8688 // cfPoint is a color scale point being edited: kind is an index in its@@ -101,7 +103,7 @@ var pointKinds = [3][]sheet.PointKind{
101103 }102104 103105 func (cfKind) form(h Host, i int, sel sheet.Rect) form {104- f := &cfForm{i: i, ranges: sel.String(), fill: int(sheet.ColorGreen),106+ f := &cfForm{i: i, ranges: sel.String(), fill: int(sheet.ColorGreen), loc: h.Sheet().Locale(),105107 op: indexOf(sheet.CondFormatOps(), sheet.RuleNotEmpty),106108 pts: [3]cfPoint{{color: int(sheet.ColorRed) - 1}, {value: "50", color: int(sheet.ColorYellow) - 1}, {color: int(sheet.ColorGreen) - 1}}}107109 if i < 0 {@@ -116,11 +118,11 @@ func (cfKind) form(h Host, i int, sel sheet.Rect) form {
116118 if k == len(r.Scale)-1 {117119 slot = 2118120 }119- f.pts[slot] = cfPoint{kind: max(indexOf(pointKinds[slot], p.Kind), 0), value: p.Value, color: int(p.Color) - 1}121+ f.pts[slot] = cfPoint{kind: max(indexOf(pointKinds[slot], p.Kind), 0), value: sheet.LocalArg(p.Value, f.loc), color: int(p.Color) - 1}120122 }121123 return f122124 }123- f.op, f.args = max(indexOf(sheet.CondFormatOps(), r.Op), 0), r.Args125+ f.op, f.args = max(indexOf(sheet.CondFormatOps(), r.Op), 0), localArgs(r.Args, f.loc)124126 st := r.Style125127 f.text, f.fill = int(st.Text), int(st.Fill)126128 f.bold, f.italic, f.under, f.strike = st.Bold, st.Italic, st.Underline, st.Strikethrough@@ -155,12 +157,12 @@ func (f *cfForm) rule() (sheet.CondFormat, error) {
155157 if k == 1 && p.kind == 0 {156158 continue // no midpoint157159 }158- r.Scale = append(r.Scale, sheet.ScalePoint{Kind: pointKinds[k][p.kind], Value: strings.TrimSpace(p.value), Color: sheet.Color(p.color + 1)})160+ r.Scale = append(r.Scale, sheet.ScalePoint{Kind: pointKinds[k][p.kind], Value: sheet.CanonicalArg(strings.TrimSpace(p.value), f.loc), Color: sheet.Color(p.color + 1)})159161 }160162 return r, nil161163 }162164 r.Op = sheet.CondFormatOps()[f.op]163- r.Args = [2]string{strings.TrimSpace(f.args[0]), strings.TrimSpace(f.args[1])}165+ r.Args = canonicalArgs(f.args, f.loc)164166 r.Style = sheet.RuleStyle{Text: sheet.Color(f.text), Fill: sheet.Color(f.fill), Bold: f.bold,165167 Italic: f.italic, Underline: f.under, Strikethrough: f.strike}166168 return r, nil@@ -6,6 +6,8 @@ import (
66 "charm.land/lipgloss/v2"77 "github.com/charmbracelet/x/ansi"88 9+ "github.com/FelineStateMachine/012/internal/locale"10+ "github.com/FelineStateMachine/012/internal/sheet"911 "github.com/FelineStateMachine/012/internal/ui/theme"1012 )1113 @@ -109,3 +111,14 @@ func (r row) cycle(d int) {
109111 110112 // textX is where a text row's value starts, from the box's inner left.111113 func textX() int { return labelW + 1 }114+115+// canonicalArgs are a rule's two arguments typed in loc, trimmed, as the116+// rule stores them (see sheet.CanonicalArg).117+func canonicalArgs(args [2]string, loc *locale.Locale) [2]string {118+ return [2]string{sheet.CanonicalArg(strings.TrimSpace(args[0]), loc), sheet.CanonicalArg(strings.TrimSpace(args[1]), loc)}119+}120+121+// localArgs are a rule's two arguments as typed in loc.122+func localArgs(args [2]string, loc *locale.Locale) [2]string {123+ return [2]string{sheet.LocalArg(args[0], loc), sheet.LocalArg(args[1], loc)}124+}@@ -4,6 +4,7 @@ import (
44 "errors"55 "strings"66 7+ "github.com/FelineStateMachine/012/internal/locale"78 "github.com/FelineStateMachine/012/internal/sheet"89 "github.com/FelineStateMachine/012/internal/ui/theme"910 )@@ -55,10 +56,11 @@ type dvForm struct {
5556 formula string5657 reject int // 0 warns, 1 rejects5758 help string59+ loc *locale.Locale // what args are typed in5860 }5961 6062 func (dvKind) form(h Host, i int, sel sheet.Rect) form {61- f := &dvForm{i: i, ranges: sel.String(), kind: int(sheet.ValidList)}63+ f := &dvForm{i: i, ranges: sel.String(), kind: int(sheet.ValidList), loc: h.Sheet().Locale()}6264 if i < 0 {6365 return f6466 }@@ -70,9 +72,9 @@ func (dvKind) form(h Host, i int, sel sheet.Rect) form {
7072 }7173 switch {7274 case v.Kind == sheet.ValidFormula:73- f.formula = v.Args[0]75+ f.formula = sheet.LocalArg(v.Args[0], f.loc)7476 case v.Kind.Compares():75- f.op, f.args = max(indexOf(f.ops(), v.Op), 0), v.Args77+ f.op, f.args = max(indexOf(f.ops(), v.Op), 0), localArgs(v.Args, f.loc)7678 }7779 return f7880 }@@ -124,11 +126,11 @@ func (f *dvForm) rule() (sheet.Validation, error) {
124126 case k == sheet.ValidRange:125127 v.Source = strings.TrimSpace(f.source)126128 case k == sheet.ValidFormula:127- v.Args[0] = strings.TrimSpace(f.formula)129+ v.Args[0] = sheet.CanonicalArg(strings.TrimSpace(f.formula), f.loc)128130 case k.Compares():129131 ops := f.ops()130132 v.Op = ops[min(f.op, len(ops)-1)]131- v.Args = [2]string{strings.TrimSpace(f.args[0]), strings.TrimSpace(f.args[1])}133+ v.Args = canonicalArgs(f.args, f.loc)132134 }133135 return v, nil134136 }@@ -169,7 +169,7 @@ func (m *Model) pointInto(s *sheet.Sheet) {
169169 return170170 }171171 if m.mode != modePoint {172- if !m.line.IsFormula() || !m.line.CanPoint() {172+ if !m.line.IsFormula() || !m.canPoint() {173173 if m.commit() {174174 m.showSheet(s)175175 }@@ -12,6 +12,7 @@ import (
1212 // Contrast minimums.1313 const (1414 minText = 4.5 // text: WCAG AA15+ minTextAAA = 7.0 // text: WCAG AAA, the high-contrast schemes' minimum1516 minSecondary = 3.0 // hints, muted text, borders, chart axes1617 minDisabled = 2.0 // unavailable items stay visible but recede1718 minDistinct = 1.2 // a background role against the screen, so bars and highlights show@@ -0,0 +1,92 @@
1+package theme2+3+import "strings"4+5+// HighContrast is the theme name for the high-contrast schemes: the dark6+// one on a dark terminal, the light one on a light terminal, switching7+// when the terminal does. HighContrastDark and HighContrastLight name8+// one of them.9+const (10+ HighContrast = "high-contrast"11+ HighContrastDark = "High Contrast Dark"12+ HighContrastLight = "High Contrast Light"13+)14+15+// The high-contrast schemes: white on black and black on white, with16+// ANSI colors picked so the roles defined on them (black text on the17+// pointer's cyan, the found cells' yellow, the traced cells' green) meet18+// WCAG AAA before correction, and a selection in saturated blue with19+// white text. FromPalette then holds every role to AAA (aaa below).20+const highContrastDark = `# High Contrast Dark: WCAG AAA (7:1) text.21+background = #00000022+foreground = #ffffff23+selection-background = #0033cc24+palette = 0=#00000025+palette = 1=#ff808026+palette = 2=#40e04027+palette = 3=#ffe04028+palette = 4=#80a8ff29+palette = 5=#ff80ff30+palette = 6=#40e0ff31+palette = 7=#e0e0e032+palette = 8=#3a3a3a33+palette = 9=#ffa0a034+palette = 10=#80ff8035+palette = 11=#ffff8036+palette = 12=#a0c0ff37+palette = 13=#ffa0ff38+palette = 14=#a0f0ff39+palette = 15=#ffffff40+`41+42+const highContrastLight = `# High Contrast Light: WCAG AAA (7:1) text.43+background = #ffffff44+foreground = #00000045+selection-background = #0033cc46+palette = 0=#00000047+palette = 1=#a0000048+palette = 2=#b0f0b049+palette = 3=#ffe07050+palette = 4=#0033cc51+palette = 5=#80008052+palette = 6=#a0e8ff53+palette = 7=#d0d0d054+palette = 8=#6a6a6a55+palette = 9=#a0000056+palette = 10=#005a0057+palette = 11=#6a4a0058+palette = 12=#0033cc59+palette = 13=#80008060+palette = 14=#005a6a61+palette = 15=#ffffff62+`63+64+// highContrastPalettes are the two schemes, dark first.65+func highContrastPalettes() []Palette {66+ var out []Palette67+ for _, s := range []struct{ name, text string }{{HighContrastDark, highContrastDark}, {HighContrastLight, highContrastLight}} {68+ p, _, err := parseText(s.text)69+ if err != nil {70+ panic("theme: " + s.name + ": " + err.Error())71+ }72+ p.Name, p.HighContrast = s.name, true73+ out = append(out, p)74+ }75+ return out76+}77+78+// isHighContrast reports whether name is HighContrast, loosely matched79+// as Lookup matches names.80+func isHighContrast(name string) bool {81+ return squash(strings.TrimSpace(name)) == squash(HighContrast)82+}83+84+// highContrastFor is the high-contrast scheme for a dark or light85+// terminal.86+func highContrastFor(dark bool) Palette {87+ ps := highContrastPalettes()88+ if dark {89+ return ps[0]90+ }91+ return ps[1]92+}@@ -0,0 +1,32 @@
1+package theme2+3+import (4+ "image/color"5+6+ "charm.land/lipgloss/v2"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// Monochrome returns t with every color of every role replaced by one12+// gray, as a terminal without color, or a reader who can't tell the13+// colors apart, sees it: what's left to tell states apart is text,14+// glyphs and attributes (bold, italic, underline styles, reverse video).15+// Tests draw with it to check that every state reads without color (see16+// docs/contributing/ux.md#visual-rules).17+func Monochrome(t Theme) Theme {18+ one := color.RGBA{R: 0x80, G: 0x80, B: 0x80, A: 0xff}19+ eachRole(&t, func(_ string, s *lipgloss.Style) {20+ if !isNoColor(s.GetForeground()) {21+ *s = s.Foreground(one)22+ }23+ if !isNoColor(s.GetBackground()) {24+ *s = s.Background(one)25+ }26+ if !isNoColor(s.GetUnderlineColor()) {27+ *s = s.UnderlineColor(one)28+ }29+ })30+ t.scales, t.shades = map[scaleKey]Shade{}, map[sheet.RuleStyle]Shade{}31+ return t32+}@@ -27,6 +27,9 @@ type Palette struct {
2727 Selection color.Color // may be nil2828 Dark bool2929 File string // the file it came from; empty for built-ins30+ // HighContrast holds every role to WCAG AAA (7:1 for text) instead31+ // of AA: the built-in high-contrast schemes.32+ HighContrast bool3033 }3134 3235 // Terminal is the theme name that keeps the terminal's own palette.@@ -74,7 +77,7 @@ var builtins = sync.OnceValue(func() []Palette {
7477 panic("theme: classic: " + err.Error())7578 }7679 c.Name = ClassicName77- return append([]Palette{c}, ps...)80+ return append(append([]Palette{c}, highContrastPalettes()...), ps...)7881 })7982 8083 // Builtins returns the built-in schemes.@@ -87,12 +90,12 @@ type Entry struct {
8790 User bool // a file in the themes directory8891 }8992 90-// List returns the themes available: terminal first, then the files in91-// dir (the user's themes directory, may be ""), then the built-ins, each92-// group sorted by name case-insensitively. A file with a built-in's name93-// hides the built-in.93+// List returns the themes available: terminal and high-contrast first,94+// then the files in dir (the user's themes directory, may be ""), then95+// the built-ins, each group sorted by name case-insensitively. A file96+// with a built-in's name hides the built-in.9497 func List(dir string) []Entry {95- out := []Entry{{Name: Terminal, Dark: true}}98+ out := []Entry{{Name: Terminal, Dark: true}, {Name: HighContrast, Dark: true}}9699 seen := map[string]bool{}97100 var user []Entry98101 for _, p := range userPalettes(dir) {@@ -27,9 +27,12 @@ const barBlend = 0.12
2727 // background is moved toward the scheme's text color just far enough2828 // to meet it, or toward black or white.2929 func FromPalette(p Palette) Theme {30- t := New(p.Dark)30+ t := roles(p.Dark)3131 t.Name, t.Palette = p.Name, &p32- text := readable(p.Foreground, p.Background, nil, minText)32+ if p.HighContrast {33+ t.levels = aaa34+ }35+ text := readable(p.Foreground, p.Background, nil, t.levels.text)3336 t.Screen = lipgloss.NewStyle().Background(p.Background).Foreground(text)3437 eachRole(&t, func(_ string, s *lipgloss.Style) { *s = mapStyle(*s, &p) })3538 if p.Selection != nil && contrast(p.Selection, p.Background) >= minDistinct {@@ -42,8 +45,9 @@ func FromPalette(p Palette) Theme {
4245 }4346 bar := lipgloss.NewStyle().Background(blend(p.Background, text, barBlend)).Foreground(text)4447 t.MenuBarRow, t.StatusBarRow = bar, bar45- eachRole(&t, func(name string, s *lipgloss.Style) { *s = fixContrast(name, *s, text, &p) })48+ eachRole(&t, func(name string, s *lipgloss.Style) { *s = fixContrast(name, *s, text, &p, t.levels) })4649 t.ColumnHeaderRow = lipgloss.NewStyle().Background(t.Header.GetBackground()).Foreground(t.Header.GetForeground())50+ t.standouts()4751 return t4852 }4953 @@ -92,21 +96,38 @@ func slot(c color.Color, p *Palette) (color.Color, bool) {
9296 return nil, false9397 }9498 99+// levels are the contrast minimums a theme's roles are held to.100+type levels struct {101+ text float64 // cell text, bars, menus, chips: every other role102+ secondary float64 // hints, muted text, marks and chart labels103+ lines float64 // borders, rules, chart axes and swatches, progress104+ disabled float64 // unavailable items105+}106+107+var (108+ // aa is WCAG AA, every scheme's floor.109+ aa = levels{text: minText, secondary: minSecondary, lines: minSecondary, disabled: minDisabled}110+ // aaa is WCAG AAA, the high-contrast schemes': 7:1 for all text,111+ // hints included, and 4.5:1 for lines and unavailable items.112+ aaa = levels{text: minTextAAA, secondary: minTextAAA, lines: minText, disabled: minText}113+)114+95115 // minContrast is the contrast minimum for a role's text.96-func minContrast(role string) float64 {116+func minContrast(role string, lv levels) float64 {97117 switch role {98118 case "Disabled":99- return minDisabled100- case "Hint", "Muted", "Border", "FrozenLine", "ChartFrame", "ChartAxis", "ChartLabel",101- "Progress", "ProgressTodo", "Copied", "Series", "Match", "Dropdown", "NoteMark":102- return minSecondary119+ return lv.disabled120+ case "Hint", "Muted", "ChartLabel", "Match", "Dropdown", "NoteMark":121+ return lv.secondary122+ case "Border", "FrozenLine", "ChartFrame", "ChartAxis", "Progress", "ProgressTodo", "Copied", "Series":123+ return lv.lines103124 }104- return minText125+ return lv.text105126 }106127 107128 // fixContrast keeps a role's background apart from the screen and its108129 // text readable on it.109-func fixContrast(role string, s lipgloss.Style, text color.Color, p *Palette) lipgloss.Style {130+func fixContrast(role string, s lipgloss.Style, text color.Color, p *Palette, lv levels) lipgloss.Style {110131 if role == "Screen" {111132 return s112133 }@@ -119,11 +140,11 @@ func fixContrast(role string, s lipgloss.Style, text color.Color, p *Palette) li
119140 }120141 }121142 if c := s.GetForeground(); !isNoColor(c) && role != "SeriesBg" {122- s = s.Foreground(readable(c, bg, text, minContrast(role)))143+ s = s.Foreground(readable(c, bg, text, minContrast(role, lv)))123144 } else if !isNoColor(s.GetBackground()) && role != "SeriesBg" {124145 // Text on a background role with no color of its own is drawn in125146 // the screen's text color.126- s = s.Foreground(readable(text, bg, nil, minContrast(role)))147+ s = s.Foreground(readable(text, bg, nil, minContrast(role, lv)))127148 }128149 return s129150 }@@ -133,13 +154,19 @@ func isNoColor(c color.Color) bool {
133154 return c == nil || none134155 }135156 136-// Resolve returns the theme called name: the terminal theme's variant137-// for a dark or light terminal, or a scheme found by Lookup in dir. An138-// unknown scheme gives the terminal theme and the error.157+// Resolve returns the theme called name: the terminal theme's or the158+// high-contrast scheme's variant for a dark or light terminal, or a159+// scheme found by Lookup in dir. An unknown scheme gives the terminal160+// theme and the error.139161 func Resolve(name string, dark bool, dir string) (Theme, error) {140162 if name == "" || name == Terminal {141163 return New(dark), nil142164 }165+ if isHighContrast(name) {166+ t := FromPalette(highContrastFor(dark))167+ t.Name = HighContrast168+ return t, nil169+ }143170 p, err := Lookup(name, dir)144171 if err != nil {145172 return New(dark), err@@ -48,43 +48,75 @@ func requireContrast(t *testing.T, theme, what string, fg, bg color.Color, min f
4848 4949 // Every built-in scheme, run through the role derivation, keeps text5050 // readable: on cells, on the bars, in the selection and the headers, and51-// every other role at its minimum.51+// every other role at its minimum. The high-contrast schemes are held to52+// WCAG AAA.5253 func TestEveryThemeReadable(t *testing.T) {5354 for _, p := range Builtins() {54- th := FromPalette(p)55- screenBg, text := th.Screen.GetBackground(), th.Screen.GetForeground()56- requireContrast(t, p.Name, "cell text", text, screenBg, minText)57- for _, bar := range []struct {58- name string59- s lipgloss.Style60- }{{"menu bar", th.MenuBarRow}, {"status bar", th.StatusBarRow}, {"column header row", th.ColumnHeaderRow}} {61- requireContrast(t, p.Name, bar.name+" text", bar.s.GetForeground(), bar.s.GetBackground(), minText)62- if c := contrast(bar.s.GetBackground(), screenBg); c < minDistinct-0.01 {63- t.Errorf("%s: %s doesn't stand out from the screen (%.2f)", p.Name, bar.name, c)64- }55+ requireReadable(t, p.Name, FromPalette(p))56+ }57+}58+59+// requireReadable checks a scheme's roles against its minimums.60+func requireReadable(t *testing.T, name string, th Theme) {61+ t.Helper()62+ lv := th.levels63+ screenBg, text := th.Screen.GetBackground(), th.Screen.GetForeground()64+ requireContrast(t, name, "cell text", text, screenBg, lv.text)65+ for _, bar := range []struct {66+ name string67+ s lipgloss.Style68+ }{{"menu bar", th.MenuBarRow}, {"status bar", th.StatusBarRow}, {"column header row", th.ColumnHeaderRow}} {69+ requireContrast(t, name, bar.name+" text", bar.s.GetForeground(), bar.s.GetBackground(), lv.text)70+ if c := contrast(bar.s.GetBackground(), screenBg); c < minDistinct-0.01 {71+ t.Errorf("%s: %s doesn't stand out from the screen (%.2f)", name, bar.name, c)6572 }66- for _, r := range []struct {67- name string68- s lipgloss.Style69- }{70- {"selection", th.Selection}, {"header", th.Header}, {"row header", th.RowHeader},71- {"active header", th.HeaderActive}, {"selected header", th.HeaderSel}, {"pointer", th.Pointer},72- {"key chip", th.KeyChip}, {"menu selection", th.MenuSelected}, {"indicator", th.Indicator},73- } {74- requireContrast(t, p.Name, r.name, r.s.GetForeground(), r.s.GetBackground(), minText)73+ }74+ for _, r := range []struct {75+ name string76+ s lipgloss.Style77+ }{78+ {"selection", th.Selection}, {"header", th.Header}, {"row header", th.RowHeader},79+ {"active header", th.HeaderActive}, {"selected header", th.HeaderSel}, {"pointer", th.Pointer},80+ {"key chip", th.KeyChip}, {"menu selection", th.MenuSelected}, {"indicator", th.Indicator},81+ } {82+ requireContrast(t, name, r.name, r.s.GetForeground(), r.s.GetBackground(), lv.text)83+ }84+ // Muted text and hints on the bars, where the status line shows them.85+ requireContrast(t, name, "muted text", th.Muted.GetForeground(), screenBg, lv.secondary)86+ eachRole(&th, func(role string, s *lipgloss.Style) {87+ fg, bg := s.GetForeground(), s.GetBackground()88+ if role == "SeriesBg" || isNoColor(fg) {89+ return7590 }76- // Muted text and hints on the bars, where the status line shows them.77- requireContrast(t, p.Name, "muted text", th.Muted.GetForeground(), screenBg, minSecondary)78- eachRole(&th, func(role string, s *lipgloss.Style) {79- fg, bg := s.GetForeground(), s.GetBackground()80- if role == "SeriesBg" || isNoColor(fg) {81- return82- }83- if isNoColor(bg) {84- bg = screenBg91+ if isNoColor(bg) {92+ bg = screenBg93+ }94+ requireContrast(t, name, role, fg, bg, minContrast(role, lv))95+ })96+}97+98+// The high-contrast theme follows the terminal's background, and its99+// schemes are AAA: 7:1 for every text role, hints included.100+func TestHighContrast(t *testing.T) {101+ for _, dark := range []bool{true, false} {102+ th, err := Resolve("high-contrast", dark, "")103+ if err != nil || th.Name != HighContrast || th.Palette == nil || th.Palette.Dark != dark || th.levels != aaa {104+ t.Fatalf("dark %v: %q %v", dark, th.Name, err)105+ }106+ requireReadable(t, th.Palette.Name, th)107+ for _, role := range []string{"Hint", "Muted", "Warning", "Error", "ErrorCell", "Link", "Spilled", "NoteMark"} {108+ if minContrast(role, th.levels) < 7 {109+ t.Errorf("%s held to %.1f", role, minContrast(role, th.levels))85110 }86- requireContrast(t, p.Name, role, fg, bg, minContrast(role))87- })111+ }112+ }113+ for _, name := range []string{"High Contrast Dark", "high-contrast-light", "HIGH-CONTRAST"} {114+ if _, err := Resolve(name, true, ""); err != nil {115+ t.Error(err)116+ }117+ }118+ if l := List(""); l[1].Name != HighContrast {119+ t.Errorf("list: %v", l[:3])88120 }89121 }90122 @@ -103,7 +135,8 @@ func TestSchemeUsesPaletteColors(t *testing.T) {
103135 }104136 }105137 })106- if bg := th.Selection.GetBackground(); fmt.Sprint(bg) != fmt.Sprint(p.Selection) {138+ // The selection is in reverse video: its background is its foreground.139+ if bg := th.Selection.GetForeground(); fmt.Sprint(bg) != fmt.Sprint(p.Selection) || !th.Selection.GetReverse() {107140 t.Errorf("selection %v, scheme's %v", bg, p.Selection)108141 }109142 // The terminal theme keeps ANSI colors and no bands.@@ -0,0 +1,39 @@
1+package theme2+3+import (4+ "strings"5+ "testing"6+7+ "charm.land/lipgloss/v2"8+)9+10+// The standout roles are drawn in reverse video with their colors11+// swapped, so they look as defined in color; Drawable undoes that where12+// lipgloss would draw spaces without reverse video.13+func TestStandouts(t *testing.T) {14+ th := New(true)15+ p := th.Pointer16+ if !p.GetReverse() || p.GetForeground() != lipgloss.Cyan || p.GetBackground() != lipgloss.Black {17+ t.Errorf("pointer: reverse %v fg %v bg %v", p.GetReverse(), p.GetForeground(), p.GetBackground())18+ }19+ if !strings.Contains(p.Render("x"), "7") {20+ t.Errorf("no SGR 7: %q", p.Render("x"))21+ }22+ if Drawable(p).Render("x") != p.Render("x") {23+ t.Error("Drawable changed a plain standout")24+ }25+ d := Drawable(p.Underline(true))26+ if d.GetReverse() || d.GetForeground() != lipgloss.Black || d.GetBackground() != lipgloss.Cyan {27+ t.Errorf("underlined: reverse %v fg %v bg %v", d.GetReverse(), d.GetForeground(), d.GetBackground())28+ }29+ for _, s := range []lipgloss.Style{th.Selection, th.HeaderSel, th.HeaderActive, th.Found, th.Traced, th.MenuSelected} {30+ if !s.GetReverse() {31+ t.Errorf("%v isn't reversed", s)32+ }33+ }34+ // Monochrome keeps attributes and makes every color one.35+ m := Monochrome(th)36+ if !m.Pointer.GetReverse() || m.Pointer.GetForeground() != m.Error.GetForeground() || !m.Spilled.GetItalic() {37+ t.Error("monochrome lost an attribute or kept a color")38+ }39+}@@ -131,6 +131,10 @@ type Theme struct {
131131 Series [chart.Colors]lipgloss.Style132132 SeriesBg [chart.Colors]lipgloss.Style133133 SeriesANSI [chart.Colors]int134+135+ // levels are the contrast minimums the roles meet: WCAG AA, or AAA136+ // for a high-contrast scheme.137+ levels levels134138 }135139 136140 // ImageID is the style of an image's Unicode placeholders: the@@ -161,6 +165,39 @@ func (t *Theme) Text(base lipgloss.Style, st sheet.Style) lipgloss.Style {
161165 162166 // New returns the terminal theme's dark or light variant.163167 func New(dark bool) Theme {168+ t := roles(dark)169+ t.standouts()170+ return t171+}172+173+// standouts draws the roles that mark where the user is (the pointer,174+// the selection, their headers, search matches, traced cells and the175+// highlighted menu item) in reverse video: the colors swapped and SGR 7176+// on, so they look the same in color and still stand out on a terminal177+// without it. Call it last: the roles' colors read reversed afterwards.178+func (t *Theme) standouts() {179+ for _, s := range []*lipgloss.Style{&t.Pointer, &t.Selection, &t.HeaderActive, &t.HeaderSel,180+ &t.Found, &t.Traced, &t.MenuSelected, &t.MenuAccelSelected} {181+ fg, bg := s.GetForeground(), s.GetBackground()182+ *s = s.Foreground(bg).Background(fg).Reverse(true)183+ }184+}185+186+// Drawable returns s as it can be drawn: a standout role with an187+// underline or strikethrough layered on it is drawn in its own colors188+// without reverse video, because lipgloss draws the spaces of underlined189+// or struck-out text without it, which would show their colors swapped.190+// The underline or strikethrough is then that text's cue without color.191+func Drawable(s lipgloss.Style) lipgloss.Style {192+ if !s.GetReverse() || !s.GetUnderline() && !s.GetStrikethrough() {193+ return s194+ }195+ fg, bg := s.GetForeground(), s.GetBackground()196+ return s.Foreground(bg).Background(fg).Reverse(false)197+}198+199+// roles are the terminal theme's roles, before standouts.200+func roles(dark bool) Theme {164201 // Contrast was checked against the reference palettes in e2e: text on165202 // colored backgrounds stays at or above roughly 4.5:1.166203 headerBg, headerFg := lipgloss.BrightBlack, lipgloss.BrightWhite@@ -205,7 +242,7 @@ func New(dark bool) Theme {
205242 ErrorCell: lipgloss.NewStyle().Foreground(lipgloss.Red),206243 ErrorMark: lipgloss.NewStyle().UnderlineStyle(lipgloss.UnderlineCurly).UnderlineColor(lipgloss.Red),207244 Link: lipgloss.NewStyle().Foreground(link).Underline(true),208- Spilled: lipgloss.NewStyle().Foreground(bar),245+ Spilled: lipgloss.NewStyle().Foreground(bar).Italic(true),209246 Found: lipgloss.NewStyle().Background(lipgloss.Yellow).Foreground(lipgloss.Black),210247 Traced: lipgloss.NewStyle().Background(lipgloss.Green).Foreground(lipgloss.Black),211248 Argument: lipgloss.NewStyle().Bold(true).Underline(true),@@ -243,6 +280,7 @@ func New(dark bool) Theme {
243280 Dropdown: lipgloss.NewStyle().Foreground(muted),244281 scales: map[scaleKey]Shade{},245282 shades: map[sheet.RuleStyle]Shade{},283+ levels: aa,246284 }247285 ruleRoles(&t, dark)248286 for i, c := range series {@@ -55,6 +55,8 @@ func New(h Host) *Picker {
5555 switch {5656 case e.Name == theme.Terminal:5757 kind, desc = "your terminal's colors", "The terminal's own 16-color palette, following its light or dark background"58+ case e.Name == theme.HighContrast:59+ kind, desc = "high contrast", "White on black or black on white, following the terminal, with WCAG AAA contrast"5860 case e.User:5961 kind += ", your file"6062 }@@ -168,6 +168,9 @@ func (m *Model) startImport(name string, opt fileio.Options, place transfer.Plac
168168 if !ok {169169 return nil170170 }171+ if opt.Locale == nil {172+ opt.Locale = m.locale()173+ }171174 return m.xfer.Start(name, path, opt, place, m.spans.Parent())172175 }173176 @@ -0,0 +1,88 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// Pastes and fills meet the validation of the cells they write, as in13+// Sheets: a rule that rejects takes the whole change back, one that warns14+// keeps it and says so.15+func TestValidationOnPastesAndFills(t *testing.T) {16+ setup := func(t *testing.T) *Model {17+ m := newModel()18+ mustValidate(t, m, `{"ranges":"B1:B9","criteria":"number","condition":"between","values":["1","10"],"reject":true}`)19+ mustValidate(t, m, `{"ranges":"C1:C9","criteria":"number","condition":"between","values":["1","10"]}`)20+ m.sheet.Set(addr("A1"), "5")21+ m.sheet.Set(addr("A2"), "50")22+ return m23+ }24+25+ t.Run("paste rejected", func(t *testing.T) {26+ m := setup(t)27+ press(t, m, "<shift+down>", "<ctrl+c>", "<right>", "<ctrl+v>")28+ if m.mode != modeError || input(m, "B1") != "" || input(m, "B2") != "" || m.sheet.CanRedo() {29+ t.Fatalf("mode %v B1 %q B2 %q", m.mode, input(m, "B1"), input(m, "B2"))30+ }31+ if m.errMsg != "Paste undone: Invalid entry in B2: Input must be a number between 1 and 10" {32+ t.Errorf("reason %q", m.errMsg)33+ }34+ })35+36+ t.Run("paste warned", func(t *testing.T) {37+ m := setup(t)38+ press(t, m, "<shift+down>", "<ctrl+c>", "<right>", "<right>", "<ctrl+v>")39+ if input(m, "C2") != "50" || !m.sheet.Look(addr("C2")).Invalid {40+ t.Fatalf("C2 %q", input(m, "C2"))41+ }42+ if l := line(m, contextLine); !strings.Contains(l, "1 cell invalid, first C2: Input must be a number between 1 and 10") {43+ t.Errorf("context line %q", l)44+ }45+ })46+47+ t.Run("text paste rejected", func(t *testing.T) {48+ m := setup(t)49+ m.cur = addr("B1")50+ send(m, tea.PasteMsg{Content: "3\n30"})51+ if m.mode != modeError || input(m, "B1") != "" {52+ t.Errorf("mode %v B1 %q", m.mode, input(m, "B1"))53+ }54+ })55+56+ t.Run("fill down rejected", func(t *testing.T) {57+ m := setup(t)58+ m.sheet.Set(addr("B1"), "7")59+ m.sheet.Set(addr("B1"), "=A1*2") // 10, valid; filled down it's 10060+ press(t, m, "<right>", "<shift+down>", "<ctrl+d>")61+ if m.mode != modeError || input(m, "B2") != "" {62+ t.Errorf("mode %v B2 %q", m.mode, input(m, "B2"))63+ }64+ })65+66+ t.Run("ctrl+enter rejected keeps the entry", func(t *testing.T) {67+ m := setup(t)68+ press(t, m, "<right>", "<shift+down>", "=A1*2", "<ctrl+enter>")69+ if m.mode != modeEdit || input(m, "B1") != "" || !strings.Contains(line(m, contextLine), "Invalid entry in B2") {70+ t.Errorf("mode %v B1 %q %q", m.mode, input(m, "B1"), line(m, contextLine))71+ }72+ })73+}74+75+// Cut and paste takes the cells' rules along.76+func TestRulesMoveWithCutAndPaste(t *testing.T) {77+ m := newModel()78+ mustValidate(t, m, `{"ranges":"A1:A2","criteria":"list","items":["Yes","No"]}`)79+ m.sheet.Set(addr("A1"), "Yes")80+ press(t, m, "<shift+down>", "<ctrl+x>", "<right>", "<right>", "<ctrl+v>")81+ vs := m.sheet.Validations()82+ if len(vs) != 1 || sheet.RangesText(vs[0].Ranges) != "C1:C2" || !m.sheet.Look(addr("C1")).Dropdown {83+ t.Fatalf("validations %+v", vs)84+ }85+ if m.mode == modeError {86+ t.Errorf("a move was refused: %s", screen(m))87+ }88+}@@ -191,7 +191,7 @@ func (m *Model) cellsText(row, first int, spans []rowtext.Span, focus sheet.Addr
191191 }192192 // The copy marker is layered on the cell's own colors.193193 if m.copied.marks(m.sheet, a) {194- base, colored, shaded = base.Inherit(m.th.Copied), true, false194+ base, colored, shaded = theme.Drawable(base.Inherit(m.th.Copied)), true, false195195 }196196 var text string197197 if shaded && plainSpan(sp) {@@ -41,7 +41,7 @@ func TestVimSetting(t *testing.T) {
4141 if s := screen(m); !strings.Contains(s, "Vim keys") {4242 t.Fatalf("settings submenu:\n%s", s)4343 }44- press(t, m, "<down>", "<enter>")44+ press(t, m, "<down>", "<down>", "<enter>")4545 if !m.prefs.vim || indicator(m) != "NORMAL" || !strings.Contains(line(m, contextLine), "Vim keys on") {4646 t.Fatalf("vim %v, indicator %q, context %q", m.prefs.vim, indicator(m), line(m, contextLine))4747 }@@ -0,0 +1,311 @@
1+package value2+3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/012/internal/locale"7+)8+9+// Entries are stored as typed in en-US, the canonical locale, so a sheet10+// reads the same in every locale. What is typed in another locale is11+// rewritten to that form as it's entered (Canonicalize), and shown in12+// the locale's form when edited again (Localize): 1.234,5 in de-DE is13+// stored as 1,234.5, 26.09.2026 as 09/26/2026 and 12,50 € as $12.50.14+// Both keep what was typed as far as they can (grouping, leading zeros,15+// ISO dates, month names, times), and the value is the same either way.16+17+// ParseValueIn is ParseValue for an entry typed in loc.18+func ParseValueIn(s string, loc *locale.Locale) (float64, Format, bool) {19+ if loc.IsCanonical() {20+ return ParseValue(s)21+ }22+ c, ok := Canonicalize(s, loc)23+ if !ok {24+ return 0, Format{}, false25+ }26+ return ParseValue(c)27+}28+29+// Canonicalize rewrites a number, date or time typed in loc as it would30+// be typed in en-US, reporting false when s isn't one in loc.31+func Canonicalize(s string, loc *locale.Locale) (string, bool) {32+ if loc.IsCanonical() {33+ _, _, ok := ParseValue(s)34+ return s, ok35+ }36+ t := strings.TrimSpace(s)37+ if t == "" {38+ return s, false39+ }40+ if c, ok := canonicalNumber(t, loc); ok {41+ if _, _, ok := parseNumberFormat(c); ok {42+ return c, true43+ }44+ }45+ if c := canonicalDateTime(t, loc); c != "" {46+ if _, _, ok := ParseDateTime(c); ok {47+ return c, true48+ }49+ }50+ return s, false51+}52+53+// Localize is the inverse of Canonicalize: a number, date or time entry54+// as stored, written as typed in loc. Anything else is returned as is.55+func Localize(s string, loc *locale.Locale) string {56+ if loc.IsCanonical() {57+ return s58+ }59+ t := strings.TrimSpace(s)60+ if _, _, ok := parseNumberFormat(t); ok {61+ return localNumber(t, loc)62+ }63+ if _, _, ok := ParseDateTime(t); ok {64+ return localDateTime(t, loc)65+ }66+ return s67+}68+69+// spaces are the spaces that may stand between a number and its70+// currency symbol or percent sign.71+const spaces = " "72+73+// canonicalNumber rewrites a number typed in loc with en-US's74+// separators, $ for loc's currency symbol before it and % after it. It75+// reports false for characters that are no part of a number in loc,76+// such as the other locale's separators.77+func canonicalNumber(t string, loc *locale.Locale) (string, bool) {78+ sign := ""79+ if t[0] == '-' || t[0] == '+' {80+ sign, t = t[:1], t[1:]81+ }82+ cur := false83+ if rest, ok := strings.CutPrefix(t, loc.Currency); ok {84+ t, cur = strings.TrimLeft(rest, spaces), true85+ } else if rest, ok := strings.CutSuffix(t, loc.Currency); ok {86+ t, cur = strings.TrimRight(rest, spaces), true87+ }88+ pct := false89+ if rest, ok := strings.CutSuffix(t, "%"); ok {90+ t, pct = strings.TrimRight(rest, spaces), true91+ }92+ if t == "" {93+ return "", false94+ }95+ var b strings.Builder96+ b.Grow(len(t) + 3)97+ b.WriteString(sign)98+ if cur {99+ b.WriteByte('$')100+ }101+ for _, r := range t {102+ switch {103+ case r == rune(loc.Decimal):104+ b.WriteByte('.')105+ case loc.IsGroup(r):106+ b.WriteByte(',')107+ case r >= '0' && r <= '9', r == 'e', r == 'E', r == '+', r == '-':108+ b.WriteRune(r)109+ default:110+ return "", false111+ }112+ }113+ if pct {114+ b.WriteByte('%')115+ }116+ return b.String(), true117+}118+119+// localNumber writes a number entry as stored (en-US) as typed in loc.120+func localNumber(t string, loc *locale.Locale) string {121+ sign := ""122+ if t[0] == '-' || t[0] == '+' {123+ sign, t = t[:1], t[1:]124+ }125+ t, cur := strings.CutPrefix(t, "$")126+ var b strings.Builder127+ for i := 0; i < len(t); i++ {128+ switch c := t[i]; c {129+ case '.':130+ b.WriteByte(loc.Decimal)131+ case ',':132+ b.WriteString(loc.Group)133+ default:134+ b.WriteByte(c)135+ }136+ }137+ num := b.String()138+ if !cur {139+ return sign + num140+ }141+ gap := ""142+ if loc.Space {143+ gap = " "144+ }145+ if loc.After {146+ return sign + num + gap + loc.Currency147+ }148+ return sign + loc.Currency + gap + num149+}150+151+// canonicalDateTime rewrites a date typed with numbers in loc's order152+// (26.09.2026, 26/9) in en-US's (9/26/2026), keeping any time after it.153+// Other dates and times are returned as they are; "" is a date of154+// numbers that can't be one in loc.155+func canonicalDateTime(t string, loc *locale.Locale) string {156+ head, tail, spaced := strings.Cut(t, " ")157+ date, shaped := canonicalDate(head, loc)158+ if !shaped {159+ return t160+ }161+ if date == "" || !spaced {162+ return date163+ }164+ if loc.Decimal == ',' {165+ tail = strings.Replace(tail, ",", ".", 1) // 14:30:15,5166+ }167+ return date + " " + tail168+}169+170+// canonicalDate rewrites a date of numbers typed in loc in en-US's171+// order. shaped is false when s isn't numbers and separators; the date172+// is "" when they can't make a date in loc.173+func canonicalDate(s string, loc *locale.Locale) (date string, shaped bool) {174+ sep := dateSep(s, loc)175+ if sep == 0 {176+ return "", false177+ }178+ if sep == '.' {179+ s = strings.TrimSuffix(s, ".") // 26.09.2026.180+ }181+ p := strings.Split(s, string(sep))182+ if len(p) < 2 || len(p) > 3 || strings.Contains(s, string(sep)+string(sep)) || p[0] == "" || p[len(p)-1] == "" {183+ return "", true184+ }185+ if len(p) == 3 && len(p[0]) == 4 { // the year first, in every locale186+ switch {187+ case loc.Order == locale.YMD:188+ return p[1] + "/" + p[2] + "/" + p[0], true189+ case sep == '.':190+ return p[0] + "/" + p[1] + "/" + p[2], true191+ }192+ return s, true193+ }194+ var d, m, y string195+ switch {196+ case loc.Order == locale.DMY:197+ d, m = p[0], p[1]198+ case loc.Order == locale.YMD && len(p) == 3:199+ y, m, d = p[0], p[1], p[2]200+ default:201+ m, d = p[0], p[1]202+ }203+ if len(p) == 3 && y == "" {204+ y = p[2]205+ }206+ if y == "" {207+ return m + "/" + d, true208+ }209+ return m + "/" + d + "/" + y, true210+}211+212+// dateSep is the separator of a date of numbers typed in loc: / and -213+// everywhere, . where loc writes dates with it. It is 0 when s is214+// anything else, or mixes separators.215+func dateSep(s string, loc *locale.Locale) byte {216+ var sep byte217+ for i := 0; i < len(s); i++ {218+ c := s[i]219+ switch {220+ case isDigit(c):221+ continue222+ case c != '/' && c != '-' && (c != '.' || loc.DateSep != '.'):223+ return 0224+ case sep != 0 && c != sep:225+ return 0226+ }227+ sep = c228+ }229+ return sep230+}231+232+// localDateTime writes a date or time entry as stored (en-US) as typed233+// in loc: a date of numbers in loc's order with its separator. ISO234+// dates, month names and times stay as they are.235+func localDateTime(t string, loc *locale.Locale) string {236+ head, tail, spaced := strings.Cut(t, " ")237+ sep := dateSep(head, locale.Canonical)238+ if sep == 0 {239+ return t240+ }241+ p := strings.Split(head, string(sep))242+ if len(p[0]) == 4 || len(p) < 2 {243+ return t // the year first: as typed, in every locale244+ }245+ m, d := p[0], p[1]246+ parts := []string{m, d}247+ switch {248+ case loc.Order == locale.DMY:249+ parts = []string{d, m}250+ if len(p) == 3 {251+ parts = append(parts, p[2])252+ }253+ case loc.Order == locale.YMD && len(p) == 3:254+ parts = []string{p[2], m, d}255+ case len(p) == 3:256+ parts = append(parts, p[2])257+ }258+ sep = loc.DateSep259+ if len(parts) == 2 && sep == loc.Decimal {260+ sep = '/' // 26.9 would be a number in de-CH261+ }262+ date := strings.Join(parts, string(sep))263+ if !spaced {264+ return date265+ }266+ if loc.Decimal == ',' {267+ tail = strings.Replace(tail, ".", ",", 1)268+ }269+ return date + " " + tail270+}271+272+// currencyCode is the Currency format's pattern in loc, with dec273+// decimals: "$"#,##0.00 in en-US, #,##0.00" €" in de-DE.274+func currencyCode(loc *locale.Locale, dec string) string {275+ sym := loc.Currency276+ if loc.Space {277+ if loc.After {278+ sym = " " + sym279+ } else {280+ sym += " "281+ }282+ }283+ if loc.After {284+ return "#,##0" + dec + `"` + sym + `"`285+ }286+ return `"` + sym + `"#,##0` + dec287+}288+289+// CodeIn is Code as shown in loc: the Currency format with loc's symbol290+// and the Date, Time and Date time formats in loc's order. Patterns of291+// their own are the same in every locale.292+func (f Format) CodeIn(loc *locale.Locale) string {293+ if loc.IsCanonical() || f.Pattern != "" && (f.Kind == FmtCustom || f.Kind.IsTime()) {294+ return f.Code()295+ }296+ switch f.Kind {297+ case FmtCurrency:298+ dec := ""299+ if f.Decimals > 0 {300+ dec = "." + strings.Repeat("0", min(f.Decimals, MaxDecimals))301+ }302+ return currencyCode(loc, dec)303+ case FmtDate:304+ return loc.Date305+ case FmtTime:306+ return loc.Time307+ case FmtDateTime:308+ return loc.DateTime()309+ }310+ return f.Code()311+}@@ -0,0 +1,215 @@
1+package value_test2+3+import (4+ "fmt"5+ "os"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/locale"10+ "github.com/FelineStateMachine/012/internal/numfmt"11+ "github.com/FelineStateMachine/012/internal/value"12+)13+14+func loc(t *testing.T, tag string) *locale.Locale {15+ t.Helper()16+ l, ok := locale.Lookup(tag)17+ if !ok {18+ t.Fatalf("no locale %s", tag)19+ }20+ return l21+}22+23+func TestParseValueInLocales(t *testing.T) {24+ date := numfmt.DateSerial(2026, 9, 26)25+ cases := []struct {26+ tag, typed string27+ ok bool28+ v float6429+ kind value.FormatKind30+ canonical string31+ }{32+ {"en-US", "1,234.5", true, 1234.5, value.FmtNumber, "1,234.5"},33+ {"en-US", "1,5", false, 0, 0, ""},34+ {"de-DE", "1.234,5", true, 1234.5, value.FmtNumber, "1,234.5"},35+ {"de-DE", "1,5", true, 1.5, value.FmtAuto, "1.5"},36+ {"de-DE", "-0,25", true, -0.25, value.FmtAuto, "-0.25"},37+ {"de-DE", "12,5 %", true, 0.125, value.FmtPercent, "12.5%"},38+ {"de-DE", "1.234,50 €", true, 1234.5, value.FmtCurrency, "$1,234.50"},39+ {"de-DE", "-5 €", true, -5, value.FmtCurrency, "-$5"},40+ {"de-DE", "1,5E3", true, 1500, value.FmtScientific, "1.5E3"},41+ {"de-DE", "1,234.5", false, 0, 0, ""},42+ {"de-DE", "$5", false, 0, 0, ""},43+ {"de-DE", "26.09.2026", true, date, value.FmtDate, "09/26/2026"},44+ {"de-DE", "26.9.2026.", true, date, value.FmtDate, "9/26/2026"},45+ {"de-DE", "26/09/2026", true, date, value.FmtDate, "09/26/2026"},46+ {"de-DE", "9/26/2026", false, 0, 0, ""},47+ {"de-DE", "2026-09-26", true, date, value.FmtDate, "2026-09-26"},48+ {"de-DE", "Sep 26, 2026", true, date, value.FmtDate, "Sep 26, 2026"},49+ {"de-DE", "26.09.2026 14:30", true, date + numfmt.TimeSerial(14, 30, 0), value.FmtDateTime, "09/26/2026 14:30"},50+ {"de-DE", "14:30", true, numfmt.TimeSerial(14, 30, 0), value.FmtTime, "14:30"},51+ {"en-GB", "26/09/2026", true, date, value.FmtDate, "09/26/2026"},52+ {"en-GB", "9/26/2026", false, 0, 0, ""},53+ {"en-GB", "£1,200", true, 1200, value.FmtCurrency, "$1,200"},54+ {"en-GB", "$1,200", false, 0, 0, ""},55+ {"en-GB", "1.5", true, 1.5, value.FmtAuto, "1.5"},56+ {"fr-FR", "1 234,5", true, 1234.5, value.FmtNumber, "1,234.5"},57+ {"fr-FR", "1 234,5", true, 1234.5, value.FmtNumber, "1,234.5"},58+ {"fr-FR", "12 %", true, 0.12, value.FmtPercent, "12%"},59+ {"fr-FR", "3,50 €", true, 3.5, value.FmtCurrency, "$3.50"},60+ {"fr-FR", "1.5", false, 0, 0, ""},61+ {"es-ES", "1.000,25", true, 1000.25, value.FmtNumber, "1,000.25"},62+ {"it-IT", "26/9", true, numfmt.DateSerial(value.Now().Year(), 9, 26), value.FmtDate, "9/26"},63+ {"pt-BR", "R$ 1.234,56", true, 1234.56, value.FmtCurrency, "$1,234.56"},64+ {"nl-NL", "26-9-2026", true, date, value.FmtDate, "9/26/2026"},65+ {"nl-NL", "€ 5", true, 5, value.FmtCurrency, "$5"},66+ {"sv-SE", "2026-09-26", true, date, value.FmtDate, "09/26/2026"},67+ {"sv-SE", "100 kr", true, 100, value.FmtCurrency, "$100"},68+ {"pl-PL", "1 000,5 zł", true, 1000.5, value.FmtCurrency, "$1,000.5"},69+ {"de-CH", "1'234.5", true, 1234.5, value.FmtNumber, "1,234.5"},70+ {"de-CH", "CHF 5", true, 5, value.FmtCurrency, "$5"},71+ {"ja-JP", "2026/9/26", true, date, value.FmtDate, "9/26/2026"},72+ {"ja-JP", "¥1,000", true, 1000, value.FmtCurrency, "$1,000"},73+ {"zh-CN", "26/9/26", true, date, value.FmtDate, "9/26/26"},74+ {"de-CH", "26.9", true, 26.9, value.FmtAuto, "26.9"},75+ }76+ for _, c := range cases {77+ l := loc(t, c.tag)78+ v, f, ok := value.ParseValueIn(c.typed, l)79+ if ok != c.ok {80+ t.Errorf("%s %q: parsed %v, want %v", c.tag, c.typed, ok, c.ok)81+ continue82+ }83+ if !ok {84+ continue85+ }86+ if v != c.v || f.Kind != c.kind {87+ t.Errorf("%s %q = %v %v, want %v %v", c.tag, c.typed, v, f.Kind, c.v, c.kind)88+ }89+ got, _ := value.Canonicalize(c.typed, l)90+ if got != c.canonical {91+ t.Errorf("%s %q: canonical %q, want %q", c.tag, c.typed, got, c.canonical)92+ }93+ }94+}95+96+// TestLocalizeRoundTrips checks that every entry as stored, written in97+// each locale's form, is typed back to the same value and format.98+func TestLocalizeRoundTrips(t *testing.T) {99+ stored := []string{"1234.5", "1,234.5", "$1,234.50", "-$5", "-12.5%", "1.5E3", "0.001",100+ "9/26/2026", "09/26/2026", "9/26", "2026-09-26", "Sep 26, 2026", "26 Sep 2026",101+ "14:30", "2:30 PM", "25:30", "9/26/2026 14:30:05", "14:30:15.5"}102+ for _, l := range locale.All() {103+ for _, s := range stored {104+ want, wf, ok := value.ParseValue(s)105+ if !ok {106+ t.Fatalf("%q isn't a value", s)107+ }108+ shown := value.Localize(s, l)109+ got, gf, ok := value.ParseValueIn(shown, l)110+ if !ok || got != want || gf.Kind != wf.Kind {111+ t.Errorf("%s: %q shown as %q reads %v %v %v, want %v %v", l.Tag, s, shown, got, gf.Kind, ok, want, wf.Kind)112+ }113+ }114+ }115+}116+117+func TestLocalizeForms(t *testing.T) {118+ cases := []struct{ tag, stored, shown string }{119+ {"en-US", "$1,234.50", "$1,234.50"},120+ {"de-DE", "$1,234.50", "1.234,50 €"},121+ {"de-DE", "-12.5%", "-12,5%"},122+ {"de-DE", "9/26/2026", "26.9.2026"},123+ {"de-DE", "9/26/2026 14:30:15.5", "26.9.2026 14:30:15,5"},124+ {"de-DE", "2026-09-26", "2026-09-26"},125+ {"de-DE", "hello", "hello"},126+ {"en-GB", "$5", "£5"},127+ {"fr-FR", "1,234.5", "1 234,5"},128+ {"pt-BR", "$5", "R$ 5"},129+ {"ja-JP", "9/26/2026", "2026/9/26"},130+ {"nl-NL", "9/26", "26-9"},131+ }132+ for _, c := range cases {133+ if got := value.Localize(c.stored, loc(t, c.tag)); got != c.shown {134+ t.Errorf("%s %q shown as %q, want %q", c.tag, c.stored, got, c.shown)135+ }136+ }137+}138+139+func TestDisplayInLocales(t *testing.T) {140+ v := 1234.5141+ date := numfmt.DateSerial(2026, 9, 26) + numfmt.TimeSerial(14, 5, 0)142+ cases := []struct {143+ tag string144+ f value.Format145+ n float64146+ want string147+ }{148+ {"en-US", value.Preset(value.FmtNumber), v, "1,234.50"},149+ {"en-US", value.Preset(value.FmtCurrency), v, "$1,234.50"},150+ {"en-US", value.Preset(value.FmtDate), date, "9/26/2026"},151+ {"en-US", value.Preset(value.FmtTime), date, "2:05:00 PM"},152+ {"de-DE", value.Preset(value.FmtNumber), v, "1.234,50"},153+ {"de-DE", value.Preset(value.FmtPercent), 0.125, "12,50%"},154+ {"de-DE", value.Preset(value.FmtScientific), v, "1,23E+03"},155+ {"de-DE", value.Preset(value.FmtCurrency), -v, "-1.234,50 €"},156+ {"de-DE", value.Preset(value.FmtDate), date, "26.9.2026"},157+ {"de-DE", value.Preset(value.FmtTime), date, "14:05:00"},158+ {"de-DE", value.Preset(value.FmtDateTime), date, "26.9.2026 14:05:00"},159+ {"de-DE", value.Format{Kind: value.FmtDate, Pattern: "yyyy-mm-dd"}, date, "2026-09-26"},160+ {"de-DE", value.Format{Kind: value.FmtCustom, Pattern: `"$"#,##0.0`}, v, "$1.234,5"},161+ {"fr-FR", value.Preset(value.FmtNumber), v, "1 234,50"},162+ {"en-GB", value.Preset(value.FmtCurrency), v, "£1,234.50"},163+ {"en-GB", value.Preset(value.FmtDate), date, "26/9/2026"},164+ {"pt-BR", value.Preset(value.FmtCurrency), v, "R$ 1.234,50"},165+ {"sv-SE", value.Preset(value.FmtDate), date, "2026-09-26"},166+ {"ja-JP", value.Preset(value.FmtDate), date, "2026/9/26"},167+ {"de-CH", value.Preset(value.FmtNumber), v, "1’234.50"},168+ }169+ for _, c := range cases {170+ l := loc(t, c.tag)171+ if got := numfmt.FormatIn(c.n, c.f.CodeIn(l), l); got != c.want {172+ t.Errorf("%s %v: %q, want %q", c.tag, c.f, got, c.want)173+ }174+ }175+}176+177+func TestAccountingInLocales(t *testing.T) {178+ cases := []struct {179+ tag string180+ v float64181+ want string182+ }{183+ {"en-US", 1234.5, " $ 1,234.50 "},184+ {"en-US", -1234.5, " $ (1,234.50)"},185+ {"de-DE", 1234.5, " 1.234,50 € "},186+ {"de-DE", 0, " - € "},187+ {"pt-BR", 5, " R$ 5,00 "},188+ }189+ for _, c := range cases {190+ got, ok := numfmt.AccountingIn(c.v, 2, 13, loc(t, c.tag))191+ if !ok || got != c.want {192+ t.Errorf("%s %v: %q %v, want %q", c.tag, c.v, got, ok, c.want)193+ }194+ }195+}196+197+// TestLocaleDocTable checks the table of locales in docs/sheets/locale.md198+// shows each as it renders.199+func TestLocaleDocTable(t *testing.T) {200+ doc, err := os.ReadFile("../../docs/sheets/locale.md")201+ if err != nil {202+ t.Fatal(err)203+ }204+ var want strings.Builder205+ date := numfmt.DateSerial(2026, 9, 26)206+ for _, l := range locale.All() {207+ fmt.Fprintf(&want, "| `%s` | %s | %s | %s | %s |\n", l.Tag, l.Name,208+ numfmt.FormatIn(1234.56, "#,##0.00", l),209+ numfmt.FormatIn(date, value.Preset(value.FmtDate).CodeIn(l), l),210+ numfmt.FormatIn(1234.56, value.Preset(value.FmtCurrency).CodeIn(l), l))211+ }212+ if !strings.Contains(string(doc), want.String()) {213+ t.Errorf("docs/sheets/locale.md's table isn't:\n%s", want.String())214+ }215+}@@ -84,9 +84,12 @@ func parseNumberFormat(s string) (float64, Format, bool) {
8484 }8585 8686 // validGrouping checks thousands separators: groups of three digits87-// before the decimal point.87+// before the decimal point, and none after it (1.234,5 is text).8888 func validGrouping(s string) bool {89- intPart, _, _ := strings.Cut(s, ".")89+ intPart, frac, _ := strings.Cut(s, ".")90+ if strings.Contains(frac, ",") {91+ return false92+ }9093 groups := strings.Split(intPart, ",")9194 if len(groups[0]) == 0 || len(groups[0]) > 3 {9295 return false@@ -0,0 +1,63 @@
1+2+# dist.sh - what `make dist VERSION=v1.2.3` does: cross-compile 012 as3+# pure Go for each platform into dist/, stamped with the version, one4+# archive per platform (tar.gz, zip for Windows) and a SHA256SUMS file.5+# Nothing is uploaded. See docs/contributing/releasing.md.6+#7+# VERSION=v1.2.3 required, a semantic version tag8+# PLATFORMS="darwin/arm64 linux/amd64" narrow the list9+set -euo pipefail10+11+root=$(cd "$(dirname "$0")/.." && pwd)12+cd "$root"13+14+version=${VERSION:-}15+if ! [[ $version =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then16+ echo "dist: VERSION must be a version like v1.2.3 (got '${version}')" >&217+ exit 118+fi19+if [ -n "$(git status --porcelain --untracked-files=no 2>/dev/null)" ]; then20+ echo "dist: warning: the working tree has uncommitted changes; the binaries include them" >&221+fi22+if tag=$(git rev-parse -q --verify "refs/tags/$version^{commit}" 2>/dev/null) && [ "$tag" != "$(git rev-parse HEAD)" ]; then23+ echo "dist: $version is tagged at another commit; check out the tag first" >&224+ exit 125+fi26+platforms=${PLATFORMS:-"darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64"}27+28+out="$root/dist"29+rm -rf "$out"30+mkdir -p "$out"31+32+for p in $platforms; do33+ goos=${p%/*}34+ goarch=${p#*/}35+ name="012_${version#v}_${goos}_${goarch}"36+ dir="$out/$name"37+ exe=01238+ if [ "$goos" = windows ]; then39+ exe=012.exe40+ fi41+ mkdir -p "$dir"42+ echo "dist: $name"43+ CGO_ENABLED=0 GOOS=$goos GOARCH=$goarch go build -trimpath \44+ -ldflags "-s -w -X main.version=$version" -o "$dir/$exe" ./cmd/01245+ cp LICENSE NOTICE README.md "$dir/"46+ if [ "$goos" = windows ]; then47+ (cd "$out" && zip -qrX "$name.zip" "$name")48+ else49+ tar -C "$out" -czf "$out/$name.tar.gz" "$name"50+ fi51+ rm -rf "$dir"52+done53+54+(55+ cd "$out"56+ if command -v sha256sum >/dev/null 2>&1; then57+ sha256sum -- 012_* >SHA256SUMS58+ else59+ shasum -a 256 -- 012_* >SHA256SUMS60+ fi61+)62+echo "dist: $(find "$out" -type f | wc -l | tr -d ' ') files in dist/"63+cat "$out/SHA256SUMS"@@ -154,28 +154,41 @@ func checkFrontMatter(docs map[string]*doc) []string {
154154 155155 var goDocPath = regexp.MustCompile(`\bdocs/[a-z0-9_/-]+\.md\b`)156156 157-// checkGoPaths reports doc paths named in Go code, in messages and158-// comments, that don't exist.157+// checkGoPaths reports doc paths named outside the docs, in Go code158+// (messages and comments), the Makefile, scripts and deploy configs,159+// that don't exist.159160 func checkGoPaths() []string {160161 var out []string161- for _, root := range []string{"cmd", "internal", "e2e", "oracle", "demos", "scripts"} {162+ for _, f := range []string{"Makefile", "CLAUDE.md"} {163+ out = append(out, missingDocPaths(f)...)164+ }165+ for _, root := range []string{"cmd", "internal", "e2e", "oracle", "demos", "scripts", "deploy"} {162166 filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {163- if err != nil || d.IsDir() || filepath.Ext(p) != ".go" {164- return nil165- }166- data, err := os.ReadFile(p)167- if err != nil {168- return nil169- }170- for i, l := range strings.Split(string(data), "\n") {171- for _, m := range goDocPath.FindAllString(l, -1) {172- if _, err := os.Stat(filepath.FromSlash(m)); err != nil {173- out = append(out, fmt.Sprintf("%s:%d: %s: no such doc", p, i+1, m))174- }175- }167+ if err == nil && !d.IsDir() && pathBearing[filepath.Ext(p)] {168+ out = append(out, missingDocPaths(p)...)176169 }177170 return nil178171 })179172 }180173 return out181174 }175+176+// pathBearing are the kinds of file outside docs/ that name doc pages.177+var pathBearing = map[string]bool{".go": true, ".sh": true, ".yml": true, ".yaml": true, ".tape": true, ".sql": true, ".json": true}178+179+// missingDocPaths reports the doc paths file p names that don't exist.180+func missingDocPaths(p string) []string {181+ data, err := os.ReadFile(p)182+ if err != nil {183+ return nil184+ }185+ var out []string186+ for i, l := range strings.Split(string(data), "\n") {187+ for _, m := range goDocPath.FindAllString(l, -1) {188+ if _, err := os.Stat(filepath.FromSlash(m)); err != nil {189+ out = append(out, fmt.Sprintf("%s:%d: %s: no such doc", p, i+1, m))190+ }191+ }192+ }193+ return out194+}@@ -30,7 +30,17 @@ CREATE OR REPLACE TEMP MACRO fmt_ns(ns) AS
3030 CREATE OR REPLACE TEMP TABLE runs AS3131 SELECT run_id, min(ts) AS ts, any_value(git_sha) AS git_sha, bool_or(git_dirty) AS dirty,3232 any_value(cpu) AS cpu, any_value(goos) AS goos, any_value(go_version) AS go_version,33- count(*) AS benchmarks,33+ count(DISTINCT (pkg, name)) AS benchmarks,3434 row_number() OVER (ORDER BY min(ts) DESC) AS age3535 FROM results3636 GROUP BY run_id;37+38+-- One row per benchmark per run: the median of its samples (a run made39+-- with COUNT=n has n) and their spread, (max - min) / median.40+CREATE OR REPLACE TEMP TABLE bench AS41+SELECT run_id, pkg, name, count(*) AS samples,42+ median(ns_op) AS ns_op, min(ns_op) AS min_ns, max(ns_op) AS max_ns,43+ CASE WHEN median(ns_op) > 0 THEN (max(ns_op) - min(ns_op)) / median(ns_op) ELSE 0 END AS spread,44+ median(allocs_op) AS allocs_op45+FROM results46+GROUP BY run_id, pkg, name;@@ -0,0 +1,71 @@
1+-- The latest run against the last release's recorded runs, with a2+-- threshold that allows for noise. Run by scripts/stress/report.sh after3+-- load.sql, with variables threshold, min_delta_ns and release_tags, a4+-- list of 'tag commit' strings, newest release first (from git), or a5+-- single run id in release_run.6+--7+-- The release's runs are every clean run of the newest tagged commit that8+-- has one on the latest run's CPU and OS, other than the latest run9+-- itself; its samples are pooled. A benchmark regresses when its median10+-- is slower than the release's by more than threshold + noise and by more11+-- than min_delta_ns, where noise is the larger spread,12+-- (max - min) / median, of its samples in either side.13+14+CREATE OR REPLACE TEMP TABLE release_tags AS15+SELECT split_part(t, ' ', 1) AS tag, split_part(t, ' ', 2) AS commit, i AS ord16+FROM (SELECT unnest(getvariable('release_tags')) AS t, generate_subscripts(getvariable('release_tags'), 1) AS i)17+WHERE t <> '';18+19+CREATE OR REPLACE TEMP TABLE release_pick AS20+WITH latest AS (SELECT * FROM runs WHERE age = 1),21+cand AS (22+ SELECT t.tag, t.ord, r.run_id23+ FROM release_tags t24+ JOIN runs r ON starts_with(t.commit, r.git_sha) AND length(r.git_sha) >= 725+ JOIN latest l ON r.cpu = l.cpu AND r.goos = l.goos26+ WHERE NOT r.dirty AND r.run_id <> l.run_id AND r.git_sha <> l.git_sha27+),28+tag AS (29+ SELECT tag FROM cand ORDER BY ord LIMIT 130+)31+SELECT run_id, tag FROM cand WHERE getvariable('release_run') = '' AND tag = (SELECT tag FROM tag)32+UNION ALL33+SELECT run_id, run_id AS tag FROM runs WHERE run_id = getvariable('release_run');34+35+CREATE OR REPLACE TEMP TABLE release_compared AS36+WITH l AS (SELECT r.* FROM bench r JOIN runs u USING (run_id) WHERE u.age = 1),37+rel AS (38+ SELECT r.pkg, r.name, count(*) AS samples, median(r.ns_op) AS ns_op,39+ CASE WHEN median(r.ns_op) > 0 THEN (max(r.ns_op) - min(r.ns_op)) / median(r.ns_op) ELSE 0 END AS spread40+ FROM results r JOIN release_pick p USING (run_id)41+ GROUP BY r.pkg, r.name42+)43+SELECT l.pkg, l.name, rel.ns_op AS before_ns, l.ns_op AS after_ns,44+ (l.ns_op - rel.ns_op) / rel.ns_op AS change,45+ greatest(l.spread, rel.spread) AS noise,46+ rel.samples AS release_samples, l.samples AS latest_samples,47+ CASE48+ WHEN (l.ns_op - rel.ns_op) / rel.ns_op > getvariable('threshold') + greatest(l.spread, rel.spread)49+ AND l.ns_op - rel.ns_op > getvariable('min_delta_ns') THEN 'REGRESSION'50+ WHEN (rel.ns_op - l.ns_op) / rel.ns_op > getvariable('threshold') + greatest(l.spread, rel.spread)51+ AND rel.ns_op - l.ns_op > getvariable('min_delta_ns') THEN 'faster'52+ ELSE ''53+ END AS flag54+FROM l JOIN rel USING (pkg, name);55+56+.print57+.print # Latest against the last release (threshold plus each benchmark's noise)58+SELECT (SELECT any_value(tag) FROM release_pick) AS release,59+ (SELECT count(*) FROM release_pick) AS release_runs,60+ count(*) AS compared,61+ count(*) FILTER (WHERE flag = 'REGRESSION') AS regressions,62+ count(*) FILTER (WHERE flag = 'faster') AS faster,63+ printf('%.0f%%', getvariable('threshold') * 100) AS threshold64+FROM release_compared;65+66+SELECT pkg, name, fmt_ns(before_ns) AS release, fmt_ns(after_ns) AS latest,67+ printf('%+.1f%%', change * 100) AS change,68+ printf('%.1f%%', noise * 100) AS noise,69+ release_samples || '/' || latest_samples AS samples, flag70+FROM release_compared WHERE flag <> ''71+ORDER BY flag DESC, change DESC;@@ -1,11 +1,17 @@
11 2-# report.sh - compare the latest stress run with the previous one and a3-# baseline, using DuckDB over the JSONL run records. No server needed.2+# report.sh - compare the latest stress run with the previous one, a3+# baseline and the last tagged release, using DuckDB over the JSONL run4+# records. No server needed. Exits 1 when the latest run regressed5+# against the last release's recorded run.46 #5-# THRESHOLD=0.10 flag changes over 10%7+# THRESHOLD=0.10 flag changes over 10% (plus the benchmark's noise8+# against the release)69 # MIN_DELTA_NS=2000 ignore changes under 2 us (noise)710 # BASELINE=run_id compare with this run (default: the oldest run)8-# FAIL_ON_REGRESSION=1 exit 1 when the latest run regressed against the previous11+# RELEASE=v1.2.3 compare with this tag's runs (default: the newest12+# tag reachable from HEAD that has a recorded run)13+# RELEASE_RUN=run_id compare with this run instead of a release's14+# FAIL_ON_REGRESSION=1 also exit 1 when the latest run regressed against the previous915 set -euo pipefail1016 1117 root=$(cd "$(dirname "$0")/../.." && pwd)@@ -19,20 +25,53 @@ if ! command -v duckdb >/dev/null 2>&1; then
1925 exit 12026 fi2127 28+# Release tags, newest version first, as 'tag commit' pairs.29+if [ -n "${RELEASE:-}" ]; then30+ tags=$(git -C "$root" rev-parse -q --verify "refs/tags/$RELEASE^{commit}" | sed "s/^/$RELEASE /") || {31+ echo "stress-report: no tag $RELEASE" >&232+ exit 133+ }34+else35+ tags=$(git -C "$root" tag -l 'v*' --merged HEAD --sort=-v:refname \36+ --format='%(refname:short) %(if)%(*objectname)%(then)%(*objectname)%(else)%(objectname)%(end)' 2>/dev/null || true)37+fi38+list=$(printf '%s\n' "$tags" | awk 'NF == 2 { printf "%s'\''%s'\''", (n++ ? ", " : ""), $0 }')39+2240 vars="SET VARIABLE results = '$results';2341 SET VARIABLE threshold = ${THRESHOLD:-0.10};2442 SET VARIABLE min_delta_ns = ${MIN_DELTA_NS:-2000};25-SET VARIABLE baseline = '${BASELINE:-}';"43+SET VARIABLE baseline = '${BASELINE:-}';44+SET VARIABLE release_run = '${RELEASE_RUN:-}';45+SET VARIABLE release_tags = [$list]::VARCHAR[];"2646 27-cat <(echo "$vars") "$root/scripts/stress/load.sql" "$root/scripts/stress/report.sql" | duckdb47+sql() {48+ cat <(echo "$vars") "$root/scripts/stress/load.sql" "$@"49+}2850 29-if [ "${FAIL_ON_REGRESSION:-}" = 1 ]; then30- n=$(cat <(echo "$vars") "$root/scripts/stress/load.sql" "$root/scripts/stress/report.sql" <(echo "51+sql "$root/scripts/stress/report.sql" "$root/scripts/stress/release.sql" | duckdb52+53+count() {54+ sql "$root/scripts/stress/report.sql" "$root/scripts/stress/release.sql" <(echo "3155 .mode list3256 .header off33-SELECT count(*) FROM compared WHERE kind = 'previous' AND flag = 'REGRESSION';") | duckdb | tail -1)57+$1") | duckdb | tail -158+}59+60+status=061+if [ "$(count 'SELECT count(*) FROM release_pick;')" = 0 ]; then62+ echo "stress-report: no recorded run of a release to compare with; record one with make stress on a tagged commit" >&263+else64+ n=$(count "SELECT count(*) FROM release_compared WHERE flag = 'REGRESSION';")65+ if [ "$n" != 0 ]; then66+ echo "stress-report: $n regressions against the last release" >&267+ status=168+ fi69+fi70+if [ "${FAIL_ON_REGRESSION:-}" = 1 ]; then71+ n=$(count "SELECT count(*) FROM compared WHERE kind = 'previous' AND flag = 'REGRESSION';")3472 if [ "$n" != 0 ]; then3573 echo "stress-report: $n regressions against the previous run" >&236- exit 174+ status=13775 fi3876 fi77+exit $statusNot shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.