- commit
- 11b3e2b4f8f7af4c89ed143a48ef3e834913d34d
- parent
- 5c71a188f5386df5b0c8fb8a3062ff06f4209483
- parent
- 08ad294dbc0b1db4aa3a7d8bab995aa88184656e
- author
- Dami <dami.etoile@gmail.com>
@@ -43,7 +43,8 @@ searches every command. `012 config edit` opens the settings file.
4343 while typing a formula, and Sheets' keys for moving between them.4444 - **Data tools.** Freeze rows and columns, multi-column sort, filters with4545 value pickers and conditions, find and replace with regular expressions,46- tracing precedents and dependents.46+ tracing precedents and dependents, live pivot tables and frequency47+ tables on sheets of their own.4748 - **Charts.** Column, bar, line and pie charts that float over the grid and4849 update live; real images in terminals with the kitty graphics protocol4950 (kitty, Ghostty, WezTerm), text elsewhere.@@ -39,7 +39,7 @@ quality keep pace with features: see [docs/UX.md](docs/UX.md).
3939 | Search (`n`/`N`) and non-destructive row filter | csvlens | | M |4040 | Fill down / fill series (1,2,3, Jan, Feb) | Excel, Sheets | needs Copy ref adjustment | S-M |4141 | Optional vim keymap (`hjkl`, counts, `:` commands). Done: File > Settings > Vim keys, [keys.md](docs/keys.md#vim-keys) | sc-im, csvlens | tables of command bindings, the command registry for `:` | M |42-| Frequency table of a column as a derived sheet | VisiData Shift+F | | M |42+| Frequency table of a column as a derived sheet | VisiData Shift+F | a preset pivot table | M, done: Data > Frequency table, Alt+Shift+F |4343 4444 ## Phase 3: terminal-native features4545 @@ -74,7 +74,7 @@ Avoid in the main binary: DuckDB and automerge (both need cgo).
7474 ## Phase 5: beyond7575 7676 - Multiple sheets with references between them, in Sheets' style (`Sheet2!A1`) rather than 1-2-3 R3's `B:A1`, and sheet tabs. Done.77-- Pivot tables as derived sheets. L.77+- Pivot tables as derived sheets. Done: Data > Pivot table and its editor, live results the engine owns, saved as their definition (file version 5), values in XLSX; see [docs/data.md](docs/data.md#pivot-tables).7878 - Macros: replay the command log; Starlark (`go.starlark.net`) for scripts, with step limits.7979 - Serve over SSH with `charm.land/wish/v2`. Done: `012 serve [dir]`, a 012 per session confined to the directory, public-key auth only ([docs/ssh.md](docs/ssh.md)). Not shared editing: see below.8080 - Decimal mode for currency (`cockroachdb/apd/v3`), opt-in. Done: File > Settings > Decimal arithmetic, boundary in the README.@@ -57,5 +57,9 @@ func startTelemetry(c telemetry.Config) (func() error, error) {
5757 slog.Bool("full", i.Full), slog.Int("evaluated", i.Evaluated), slog.Int("cells", i.Cells),5858 slog.Int("volatile", i.Volatile), slog.Bool("circular", i.Circular))5959 }60+ sheet.OnPivot = func(i sheet.PivotInfo) {61+ telemetry.Event("pivot", i.Duration,62+ slog.Int("records", i.Records), slog.Int("groups", i.Groups), slog.Int("cells", i.Cells), slog.Bool("failed", i.Failed))63+ }6064 return stop, nil6165 }@@ -91,6 +91,19 @@ style.
9191 are one step. A deleted sheet keeps its cells, so undo brings it back.9292 The history keeps at most 100 steps and about 256 MB of before-images,9393 counted as they're recorded, dropping the oldest steps first.94+- **Pivot tables.** A sheet may hold a pivot (`pivot.go`): a definition95+ naming its source by sheet name, as formulas do, and a region of96+ derived cells from A1 that the engine owns. When a recalculation marks97+ a cell of a pivot's source range, or its definition changes, the pivot98+ is recomputed after the formulas (`pivotcalc.go` groups the source99+ rows into a tree of accumulators, `pivotlayout.go` lays out Sheets'100+ table) and only the result cells that changed are rewritten, outside101+ the undo history, then what reads them recalculates, pivots of pivots102+ included (bounded, as a loop can't be defined). Undo restores the103+ source and the definition, and the results follow. Derived cells are104+ constants to formulas, values to copies and exports, refused by `Set`,105+ and never saved: the file keeps the definition (`pivotfile.go`). A106+ frequency table is a pivot with a preset definition.94107 - **JEV.** The engine never touches the network. JEV functions describe a95108 question and look up the answer in the workbook's `RemoteSource`, set96109 with `SetRemote`; `internal/jev` answers from a cache and queues new@@ -130,7 +143,10 @@ context menus, the command palette, the help overlay and mouse gestures
130143 that stand for one (double-clicking a tab renames it through131144 `sheet.rename`), so they can't disagree. `runCommand` is the one place a132145 command runs: telemetry times it there, and a command log or macro133-recorder would attach there.146+recorder would attach there. A command that writes cells declares the147+range it writes (`edits`), and `runCommand` refuses it over a pivot148+table's results, so a new editing command is guarded by saying what it149+edits.134150 135151 **Model and components.** `Model` (`model.go`) is the root: it holds the136152 file, the mode and the note on the context line, owns one component for@@ -144,7 +160,7 @@ draw. The components:
144160 | edit line | `lineEdit` | the one-line editor shared by cell entries, prompts and search fields (`line.go`) |145161 | cell entry | `entry`, `assist` | typing into a cell, pointing at references, other sheets while pointing, formula suggestions and signatures (`entry.go`, `assist.go`) |146162 | prompt | `prompt` | a question on the context line, typed or pointed at (`prompt.go`) |147-| overlays | `overlay` | whatever has taken over input: menus (`menuoverlay.go`), the palette and pickers (`palette.go`, `names.go`), the filter picker, the find, sort and choice bars, the chart editor and selection, the shortcuts |163+| overlays | `overlay` | whatever has taken over input: menus (`menuoverlay.go`), the palette and pickers (`palette.go`, `names.go`), the filter picker, the find, sort and choice bars, the chart editor and selection, the pivot editor (`pivoteditor.go`, `pivotactions.go`), the shortcuts |148164 | sheet tabs | `tabStrip` | where each sheet was left, the tab strip's scroll, layout and clicks (`tabstrip.go`) |149165 | mouse | `mouseState` | drags, hover, double clicks, the fill handle (`mouse.go`, `fill.go`) |150166 | import | `transfer` | the import in progress, its progress display and cancelling (`transfer.go`) |@@ -27,8 +27,8 @@ while typing enters the same entry into every selected cell.
2727 Ctrl+Z undoes and Ctrl+Y or Ctrl+Shift+Z redoes, 100 steps deep, across2828 every sheet: undo shows the sheet a step changed. Every change is covered:2929 typing, clearing, paste, fill, sort, insert and delete, formats, widths,30-names, charts, freeze, filters, and adding, deleting, renaming, moving and31-duplicating sheets. A multi-cell change is one step,30+names, charts, freeze, filters, pivot tables, and adding, deleting,31+renaming, moving and duplicating sheets. A multi-cell change is one step,3232 and the context line says what was undone, e.g. `Undid: clear B3:B5`.3333 3434 ## Rows and columns@@ -62,6 +62,67 @@ as "greater than" or "text contains". Filtered-out rows are hidden, not
6262 deleted: row numbers show the gap, navigation skips them, formulas still6363 count them, and the status line says how many rows are hidden.6464 65+## Pivot tables66+67+Data > Pivot table summarizes the selection, or the table around the68+active cell, on a new sheet named `Pivot Table 1`, as Sheets' Insert >69+Pivot table does. The data's first row names the fields. The pivot editor70+opens docked at the right of the grid, with the results updating behind it:71+72+- **Rows** group the data by a field's values, one row per value; with two73+ or more, the groups nest and each outer group gets a subtotal row.74+- **Columns** spread the groups across columns by a field's values.75+- **Values** summarize a field for each group: SUM, COUNTA, COUNT,76+ COUNTUNIQUE, AVERAGE, MAX or MIN (Sheets' "Summarize by"), shown as they77+ are or as a share of their row, column or grand total ("Show as").78+ A new value is summed when its field holds numbers, and counted79+ otherwise.80+- **Filters** leave out rows by a field's values or a condition, in the81+ same picker as a filter's column.82+- **Grand total row** and **Grand total column** add Sheets' totals.83+84+Up and Down pick a line. Space adds a field to the section it's on (pick85+it from the fields of the data, type to narrow the list), opens a filter's86+values, or flips a toggle; Space on the data range points at a new one on87+its sheet. On a row or column field, Left and Right order its groups A to88+Z, Z to A, or by a value's total, smallest or largest first, and89+Shift+Up and Shift+Down move it before or after the others, which changes90+how the groups nest. On a value, Left and Right change how it's91+summarized and S how it's shown. Del removes a field. Every change is an92+undo step; Enter keeps them, Esc undoes them, and removes a pivot just93+created. Data > Edit pivot table opens the editor again.94+95+Groups follow Sheets: text ignoring case (`east` joins `East`), numbers96+and dates by value, with the source's format, so dates show as dates.97+Numbers and text never share a group: `1` and the text `'1` are two.98+Groups sort numbers first, then text, booleans and errors, and blank99+cells, shown as `(blank)`, last. Rows blank across the whole range are100+left out, so a range can reach past the data. SUM, AVERAGE, MIN and MAX101+keep the column's number format; an error in a summed column shows as that102+error, as SUM would.103+104+The results are live: any change to the data, typed, pasted, filled or105+computed by a formula, recomputes the pivot, and rows or columns inserted106+inside the range widen it. Formulas can read the results (`='Pivot Table107+1'!B5`), and copying them pastes their values. The results themselves108+can't be edited, as in Sheets: typing, pasting, clearing, formatting,109+sorting or inserting rows or columns over them is refused with a note on110+the context line. A pivot shows `#REF!` in A1, explained on the context111+line, when its data's sheet is deleted or when its results would112+overwrite a cell typed next to them; undo or moving the cell brings it113+back. The sheet's columns widen to fit the results until you set a width114+yourself.115+116+## Frequency tables117+118+Data > Frequency table (column stats), or Alt+Shift+F (VisiData's119+Shift+F; plain Shift+F types an F), counts each value of the active120+column of the table around the active cell on a new sheet, `Frequency of121+<field>`: the value, how many rows have it and their percentage, most122+frequent first, with a Grand Total. Blank cells are counted too. It is a123+pivot table like any other, so it stays live, and the pivot editor124+changes it.125+65126 ## Find and replace66127 67128 Ctrl+F opens a find bar on the context line: matches highlight as you type,@@ -92,7 +92,7 @@ whole sheet per keystroke or per frame needs a cache or an index.
9292 9393 The `.012` format only gains optional fields, and the version rises only when9494 a file uses a feature older builds can't read (names, freeze and filters in95-v3, several sheets in v4). Old files always load. Settings that older95+v3, several sheets in v4, pivot tables in v5). Old files always load. Settings that older9696 builds can safely ignore (decimal arithmetic) don't raise the version.9797 9898 ## Keeping it honest@@ -10,7 +10,7 @@ bar; Esc cancels. Saving an imported sheet asks whether to save it as a
1010 | Format | Import | Download |1111 |---|---|---|1212 | CSV, TSV | Delimiter (`,` `;` tab `\|`), UTF-8 BOM, UTF-16 and Windows-1252 detected; entries become numbers, dates, currency and percentages as if typed; formulas stay text | Values as shown, as Sheets' Download does |13-| Excel `.xlsx` | Every sheet, opening on the one Excel showed: values, formulas (references between sheets too), workbook named ranges, number formats, bold, italic, underline, strikethrough, alignment, column widths. Formulas 012 can't read (unknown functions) keep their values | Every sheet, the same, with formulas in Excel's syntax and their results cached. JEV functions and `#AND#` save as values |13+| Excel `.xlsx` | Every sheet, opening on the one Excel showed: values, formulas (references between sheets too), workbook named ranges, number formats, bold, italic, underline, strikethrough, alignment, column widths. Formulas 012 can't read (unknown functions) keep their values; Excel pivot tables come in as the values they showed | Every sheet, the same, with formulas in Excel's syntax and their results cached. JEV functions and `#AND#` save as values, and so do pivot tables: Excel gets the results, not a pivot |1414 | 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 |1515 | Parquet | Every column, with dates and timestamps; lists joined with commas | |1616 | Lotus 1-2-3 `.wk1`, `.wks` | Numbers, labels with their alignment, formats, column widths, formulas translated (references, operators, `@SUM`, `@AVG`, `@IF`, `@ROUND`, `@PMT` and 60 more) or kept as values | |@@ -35,7 +35,8 @@ read naturally and files merge reasonably in version control:
3535 A cell without formatting is just what was typed; a formatted cell is a3636 small object. Version 3 adds named ranges, frozen panes and a filter, and is3737 only written when a sheet uses one of them, so older builds of 012 can open38-everything else. Charts are an optional `charts` field that older builds38+everything else (version 4 adds several sheets and version 5 pivot tables,39+below). Charts are an optional `charts` field that older builds3940 ignore. Saves are atomic: 012 writes a temporary file and renames it. Saving over4041 the open file when something else wrote it since it was opened or last4142 saved (another program, or another session of [012 serve](ssh.md)) asks@@ -77,3 +78,33 @@ arithmetic, the sheet shown when saved) stay at the top:
7778 ```7879 7980 Older builds refuse version 4 files rather than lose sheets.81+82+## Pivot tables83+84+A workbook with a pivot table is version 5: version 4 with a `pivot`85+field, on one line after the cells of the pivot's sheet, holding its86+definition. The results are never saved; they are computed again when87+the file opens, so the file stays small and can't disagree with its88+data. Builds that know only version 4 refuse the file rather than show an89+empty sheet; a workbook without pivots is still written as version 4.90+91+```json92+{93+ "name": "Pivot Table 1",94+ "cells": {},95+ "pivot": {"source":"Sales!A1:D200","rows":[{"column":"B"},{"column":"A","order":"desc","sortBy":1}],"columns":[{"column":"C"}],"values":[{"column":"D","summarize":"sum"},{"column":"D","summarize":"counta","showAs":"percent_of_total","name":"Share"}],"filters":[{"column":"A","hidden":["North"]}],"rowTotals":true,"columnTotals":false}96+}97+```98+99+- `source` is the data, with its sheet; `Sales!#REF!` once the range was100+ deleted. The sheet is by name, as in formulas, and follows renames.101+- `rows` and `columns` are fields by column letter on the source sheet,102+ with `order` `desc` for Z to A and `sortBy` the value, counting from 1,103+ whose totals order the groups.104+- `values` summarize a column: `sum`, `counta`, `count`, `countunique`,105+ `average`, `max`, `min`, or `rows` (every row, blank or not, which106+ frequency tables use); `showAs` is `percent_of_row`,107+ `percent_of_column` or `percent_of_total`; `name` replaces the header.108+- `filters` take a filter column's criteria: `hidden` values and a109+ `condition` with its `value`.110+- `rowTotals` and `columnTotals` are the grand total row and column.@@ -26,6 +26,8 @@ the app uses, so it never drifts from what the keys do.
2626 | View > Freeze | Keep rows or columns on screen while the rest scrolls |2727 | Data > Sort sheet, Sort range | Sort by the active column A to Z or Z to A, or pick columns and order on a bar (Left/Right column, Space order, Alt+A add, Alt+H header row) |2828 | Alt+Down, click ▾ in a header | With a filter (Data > Create a filter): pick the column's values (Space checks, type to search) or a condition |29+| Data > Pivot table | Summarize the table on a new sheet; in the pivot editor Up/Down pick a line, Space adds a field (or opens a filter, flips a total, changes the data range), Left/Right change a field's order or summary, S its "show as", Shift+Up/Down move it, Del removes it, Enter keeps, Esc undoes. Data > Edit pivot table reopens it |30+| Alt+Shift+F | Frequency table of the active column on a new sheet (Data > Frequency table), as VisiData's Shift+F |2931 | Ctrl+Alt+= / Ctrl+Alt+- | Insert rows above / delete the selected rows (columns when whole columns are selected) |3032 | Ctrl+Enter while typing | Enter the same entry in every selected cell |3133 | F4 while typing a formula | Cycle the reference at the caret through A1, $A$1, A$1, $A1 |@@ -30,6 +30,7 @@ it lags, and past a second it stalls.
3030 | Formula depth | 10,000 nested parentheses or IFs: under 5 ms | | No explicit limit; recursion grows the stack | Recursive parser and evaluator |3131 | Find, filter, sort, fill | Filter or sort 8191 rows: 1 to 32 ms; find over 213 k cells: 38 ms; fill 8192 rows: 4 ms | Replace all over 213 k cells: 280 ms | | Per-cell string conversion and regexp |3232 | SSH sessions (`012 serve`) | 50 sessions typing at once: frames within one frame interval (p95 16.6 ms), 1.3 MiB per session plus its sheets (upper bound) | | More than `--max-sessions` (8 by default): turned away | Bubble Tea's 60 fps pacing; per session, the terminal's cell buffers |33+| Pivot tables | A pivot over 8191 rows recomputes after an edit to its source in 1 to 4 ms | | Results past 8192 x 256 (the pivot shows #REF!) | Reading each source cell of its fields: a map lookup each |3334 3435 ## Sheet size3536 @@ -236,6 +237,27 @@ encryption twice, the client reading the frame); at 0.7 ms, 50 sessions
236237 typing continuously at 60 frames a second would keep about two cores237238 busy.238239 240+## Pivot tables241+242+A pivot table is recomputed whole whenever a cell of its source range is243+recalculated: it reads every source row once, groups it, and rewrites244+only the result cells that changed, so what reads the results245+recalculates only when they move. `BenchmarkPivot` edits one cell of a246+full 8191-row table (an id, a category of 8, a number and one of 1000247+items, twice) under a pivot and times the edit, the recalculation and248+the pivot together:249+250+| Pivot | Result rows | Edit | Allocations |251+|---|---|---|---|252+| Rows: 8 categories; SUM and COUNTA | 10 | 0.95 ms | 196 |253+| Rows: 8 x 8 categories with subtotals; columns: 8; SUM and AVERAGE | 75 | 2.7 to 2.9 ms | 2.5 k |254+| Rows: 1000 items sorted by their sum; SUM and COUNTUNIQUE | 1001 | 3.5 to 4.2 ms | 14 k |255+| Frequency table of the 1000 items | 1001 | 2.5 to 2.8 ms | 24 k |256+257+Groups are found by a comparable key struct rather than a string built258+per row: that took the 8-category pivot from 8.4 k to 196 allocations259+per edit, the others from 32 to 39 k to the figures above.260+239261 ## Hotspots found and fixed240262 241263 Each with its benchmark before and after, in the commit that fixed it.@@ -58,6 +58,8 @@ same, as an attribute for queries), `service` (`012`) and `dur_ms`.
5858 | `frame` | every frame, at debug level | `key_ms` |5959 | `command` | every registered command | `id`, e.g. `data.sort` |6060 | `sort`, `filter`, `find`, `replace`, `fill` | the operation itself | `rows`, `cols`, `keys`; `hidden`; `matches`; `replaced`; `cells` |61+| `pivot` | every pivot table recomputation (a change to its data or definition) | `records` (source rows summarized), `groups` (row groups at every depth), `cells` (results), `failed` (shows `#REF!`) |62+| `frequency` | making a frequency table | `rows` (data rows counted) |6163 | `import`, `export` | file transfers (`internal/fileio`) | `format`, `bytes`, `rows`, `cells`, `notes` |6264 | `save`, `open` | the native `.012` file | `cells`, `bytes` (save times serializing, on the UI goroutine) |6365 | `jev` | each question sent | `kind`, `queued` (waiting when sent), `outcome` (`ok` or `failed`) |@@ -66,7 +68,8 @@ same, as an attribute for queries), `service` (`012`) and `dur_ms`.
6668 Failures are logged at `WARN` with an `error` message.6769 6870 The engine doesn't log: `sheet.OnRecalc` hands each recalculation's69-counts to `cmd/012`, which logs them. The call sites use71+counts to `cmd/012`, which logs them, and `sheet.OnPivot` each pivot72+table's. The call sites use7073 `internal/telemetry` (`Start`/`End` spans, `Event`, `Frame`, `Set`), so7174 the backends (the JSON file, OTLP) sit behind it without touching them.7275 @@ -0,0 +1,100 @@
1+package e2e2+3+import (4+ "fmt"5+ "strings"6+ "testing"7+)8+9+// inventoryQty sums the inventory fixture's quantities by category.10+func inventoryQty() (fasteners, hardware int) {11+ for i := range 40 {12+ qty := (i*37)%90 + 513+ if k := i % 8; k == 3 || k == 4 || k == 6 {14+ hardware += qty15+ } else {16+ fasteners += qty17+ }18+ }19+ return fasteners, hardware20+}21+22+// pivotByCategory creates a pivot of the inventory with Category in Rows23+// and SUM of Qty in Values, leaving the editor open on the value.24+func pivotByCategory(s *session) {25+ inventory(s)26+ s.keys("<ctrl+k>", "pivot table", "<enter>")27+ s.waitFor("PIVOT")28+ s.waitFor("Data Sheet1!A1:F41")29+ s.keys("<space>", "categ", "<enter>")30+ s.waitFor(" Category")31+ s.keys("<down>", "<down>", "<space>", "qty", "<enter>")32+ s.waitFor("‹ SUM ›")33+}34+35+func TestPivotTable(t *testing.T) {36+ dir := t.TempDir()37+ s := start(t, dir)38+ pivotByCategory(s)39+ s.keys("<enter>")40+ s.waitFor("READY")41+ fasteners, hardware := inventoryQty()42+ s.eventually("pivot results", func() bool {43+ return strings.HasPrefix(s.line(gridRow1), " 1 Category") &&44+ strings.Contains(s.line(gridRow1+1), fmt.Sprint(fasteners)) &&45+ strings.Contains(s.line(gridRow1+3), fmt.Sprint(fasteners+hardware))46+ })47+ if !strings.Contains(s.line(int(s.rows)-1), "Pivot Table 1") {48+ t.Errorf("no tab for the pivot: %q", s.line(int(s.rows)-1))49+ }50+51+ // Its results can't be edited.52+ s.keys("<down>", "<right>", "7")53+ s.waitFor("Pivot table results can't be edited")54+55+ // It follows its source: Bolts 1's quantity goes up by 1000.56+ s.keys("<ctrl+pgup>", "<f5>", "D2", "<enter>", "1005", "<enter>", "<ctrl+pgdown>")57+ s.eventually("recomputed", func() bool {58+ return strings.Contains(s.line(gridRow1+1), fmt.Sprint(fasteners+1000))59+ })60+61+ // Saved as its definition, and computed again on opening.62+ s.keys("<ctrl+s>", "stock", "<enter>")63+ s.eventually("saved", func() bool { return s.title() == "012 - stock.012" })64+ s.keys("<ctrl+q>")65+ s.waitExit()66+ r := start(t, dir, "stock.012")67+ r.keys("<ctrl+pgdown>")68+ r.eventually("pivot after reopening", func() bool {69+ return strings.Contains(r.line(gridRow1+1), fmt.Sprint(fasteners+1000)) && strings.Contains(r.screen(), "Grand Total")70+ })71+72+ // Esc in the editor undoes its changes; undo takes the edit back.73+ r.keys("<ctrl+k>", "edit pivot", "<enter>")74+ r.waitFor("PIVOT")75+ r.keys("<down>", "<delete>")76+ r.eventually("category removed", func() bool { return !strings.Contains(r.line(gridRow1), "Category") })77+ r.keys("<esc>")78+ r.waitFor("READY")79+ r.eventually("category back", func() bool { return strings.HasPrefix(r.line(gridRow1), " 1 Category") })80+}81+82+func TestFrequencyTable(t *testing.T) {83+ s := start(t, "")84+ inventory(s)85+ s.keys("<right>", "<down>", "<alt+shift+f>")86+ s.waitFor("Counted Category: 2 distinct values")87+ fasteners := 088+ for i := range 40 {89+ if k := i % 8; k != 3 && k != 4 && k != 6 {90+ fasteners++91+ }92+ }93+ s.eventually("counts", func() bool {94+ return strings.HasPrefix(s.line(gridRow1), " 1 Category") &&95+ strings.Contains(s.line(gridRow1+1), "Fasteners") && strings.Contains(s.line(gridRow1+1), fmt.Sprint(fasteners)) &&96+ strings.Contains(s.line(gridRow1+3), "100.00%")97+ })98+ s.keys("<ctrl+z>")99+ s.eventually("undone", func() bool { return !strings.Contains(s.line(int(s.rows)-1), "Frequency of") })100+}@@ -426,12 +426,29 @@ var screens = []screen{
426426 s.keys("<enter>")427427 s.waitFor("1 of 2 on Sheet1")428428 }},429+ {name: "pivot-editor", setup: pivotEditor},430+ {name: "pivot-editor-narrow", opts: options{cols: 60, rows: 16}, setup: pivotEditor},431+ {name: "pivot", setup: func(s *session) {432+ pivotEditor(s)433+ s.keys("<enter>", "<down>", "<down>", "<right>", "<right>", "5")434+ s.waitFor("Pivot table results can't be edited")435+ }},436+ {name: "pivot-filter", setup: func(s *session) {437+ pivotEditor(s)438+ s.keys("<down>", "<space>", "item", "<enter>", "<space>", "bolts")439+ s.waitFor("Filter Item")440+ }},441+ {name: "frequency", setup: func(s *session) {442+ inventory(s)443+ s.keys("<right>", "<right>", "<down>", "<alt+shift+f>")444+ s.waitFor("Counted Bin: 12 distinct values")445+ }},429446 }430447 431448 // Key screens are also recorded on a light terminal, where the app picks432449 // its light theme from the reported background color.433450 func init() {434- for _, name := range []string{"budget", "point-range", "selection-stats", "menu", "palette-search", "context-menu-column", "functions", "quit-confirm", "help", "resizing-column", "copy-marker", "copy-marker-selected", "formats", "menu-format-number", "find", "replace", "jev", "import-picker", "import-progress", "import-xlsx", "frozen", "filter-picker", "filtered", "sort-bar", "fill-handle", "chart", "chart-editor", "chart-line", "chart-pie", "links-errors", "autocomplete", "signature", "named-ranges", "trace-precedents", "sheets", "sheets-point", "sheets-menu", "sheets-many", "vim-normal", "vim-visual", "vim-command"} {451+ for _, name := range []string{"budget", "point-range", "selection-stats", "menu", "palette-search", "context-menu-column", "functions", "quit-confirm", "help", "resizing-column", "copy-marker", "copy-marker-selected", "formats", "menu-format-number", "find", "replace", "jev", "import-picker", "import-progress", "import-xlsx", "frozen", "filter-picker", "filtered", "sort-bar", "fill-handle", "chart", "chart-editor", "chart-line", "chart-pie", "links-errors", "autocomplete", "signature", "named-ranges", "trace-precedents", "sheets", "sheets-point", "sheets-menu", "sheets-many", "vim-normal", "vim-visual", "vim-command", "pivot-editor", "pivot", "frequency"} {435452 for _, sc := range screens {436453 if sc.name == name {437454 sc.name += "-light"@@ -80,6 +80,23 @@ func inventory(s *session) {
8080 s.waitForName("A1")8181 }8282 83+// pivotEditor creates a pivot of the inventory in the editor: Category84+// in Rows, Reorder in Columns, the sum of Qty and the average Price,85+// leaving the average highlighted.86+func pivotEditor(s *session) {87+ inventory(s)88+ s.keys("<ctrl+k>", "pivot table", "<enter>")89+ s.waitFor("Data Sheet1!A1:F41")90+ s.keys("<space>", "categ", "<enter>")91+ s.waitFor(" Category")92+ s.keys("<down>", "<space>", "reord", "<enter>")93+ s.waitFor(" Reorder")94+ s.keys("<down>", "<space>", "qty", "<enter>")95+ s.waitFor("‹ SUM ›")96+ s.keys("a", "price", "<enter>", "<left>", "<left>", "<left>")97+ s.waitFor("‹ AVERAGE ›")98+}99+83100 // named is the budget with its expenses named and a formula using the84101 // name, ending on the total.85102 func named(s *session) {@@ -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)">A1</span><span style="background:var(--vt-palette-7)"> </span> Bin3+Counted Bin: 12 distinct values <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Z</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">undo</span>4+<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">A</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)">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="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-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">Bin</span><span style="background:var(--vt-palette-6)"> </span> <span style="font-weight:bold">Count</span> <span style="font-weight:bold">Percent</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> B01 4 10.00%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> B02 4 10.00%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> B03 4 10.00%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> B04 4 10.00%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> B05 3 7.50%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> B06 3 7.50%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> B07 3 7.50%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> B08 3 7.50%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> B09 3 7.50%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> B10 3 7.50%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> B11 3 7.50%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> B12 3 7.50%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> <span style="font-weight:bold">Grand</span> <span style="font-weight:bold">Total</span> <span style="font-weight:bold">40</span> <span style="font-weight:bold">100.00%</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+ Sheet1 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Frequency</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">of</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Bin</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></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)">A1</span><span style="background:var(--vt-palette-8)"> </span> Bin3+Counted Bin: 12 distinct values <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Z</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">undo</span>4+<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">A</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)">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="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-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">Bin</span><span style="background:var(--vt-palette-6)"> </span> <span style="font-weight:bold">Count</span> <span style="font-weight:bold">Percent</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> B01 4 10.00%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> B02 4 10.00%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> B03 4 10.00%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> B04 4 10.00%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> B05 3 7.50%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> B06 3 7.50%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> B07 3 7.50%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> B08 3 7.50%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> B09 3 7.50%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> B10 3 7.50%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> B11 3 7.50%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> B12 3 7.50%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="font-weight:bold">Grand</span> <span style="font-weight:bold">Total</span> <span style="font-weight:bold">40</span> <span style="font-weight:bold">100.00%</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+ Sheet1 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Frequency</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">of</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Bin</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></pre>\ No newline at end of file@@ -25,6 +25,6 @@
2525 <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> <span style="color:var(--vt-palette-8)">│</span> Deselect <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-8)">│</span>2626 <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> <span style="color:var(--vt-palette-8)">│</span> Select all <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+A</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>2727 <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> <span style="color:var(--vt-palette-8)">│</span> Select columns <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Space</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">1-25</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">83</span> <span style="color:var(--vt-palette-8)">─┘</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> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">1-25</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">84</span> <span style="color:var(--vt-palette-8)">─┘</span>2929 <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>3030 <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)">scroll</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@@ -11,6 +11,6 @@
1111 <span style="color:var(--vt-palette-8)">│</span> Last used cell <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+End</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>1212 <span style="color:var(--vt-palette-8)">│</span> Go to <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+G</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F5</span><span style="background:var(--vt-palette-8)"> </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)">│</span>14-<span style="color:var(--vt-palette-8)">└────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-11</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">83</span> <span style="color:var(--vt-palette-8)">─┘</span>14+<span style="color:var(--vt-palette-8)">└────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-11</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">84</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>1616 <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)">scroll</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@@ -40,6 +40,6 @@
4040 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">36</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Menu <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> Clear formatting <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)">│</span>4141 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">37</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Cell menu <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>4242 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">38</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Keyboard shortcuts <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="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F1</span><span style="background:var(--vt-palette-8)"> </span> <span style="font-weight:bold">Data</span> <span style="color:var(--vt-palette-8)">│</span>43-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">39</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-40</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">43</span> <span style="color:var(--vt-palette-8)">─┘</span>43+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">39</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-40</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">44</span> <span style="color:var(--vt-palette-8)">─┘</span>4444 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">40</span><span style="background:var(--vt-palette-8)"> </span>4545 <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)">scroll</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@@ -25,6 +25,6 @@
2525 <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> <span style="color:var(--vt-palette-8)">│</span> Deselect <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)">│</span>2626 <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> <span style="color:var(--vt-palette-8)">│</span> Select all <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+A</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>2727 <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> <span style="color:var(--vt-palette-8)">│</span> Select columns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Space</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-25</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">83</span> <span style="color: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> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-25</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">84</span> <span style="color:var(--vt-palette-8)">─┘</span>2929 <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>3030 <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)">scroll</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@@ -2,15 +2,15 @@
22 <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Search</span> <span style="font-weight:bold">the</span> <span style="font-weight:bold">menus</span> <span style="color:var(--vt-palette-8)">───────────────────────────────────────┐</span>33 <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> sa <span style="color:var(--vt-palette-8)">│</span>44 <span style="color:var(--vt-palette-8)">├──────────────────────────────────────────────────────────┤</span>5-<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);font-weight:bold;text-decoration:underline">S</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">ave</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold;text-decoration:underline">a</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">s</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">File</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6-<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">Sa</span>ve <span style="color:var(--vt-palette-8)">File</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+S</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>7-<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>elect <span style="color:var(--vt-palette-3);font-weight:bold">a</span>ll <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+A</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>8-<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>ort range <span style="color:var(--vt-palette-3);font-weight:bold">A</span> to Z <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Sor…</span> <span style="color:var(--vt-palette-8)">│</span>9-<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>ort range Z to <span style="color:var(--vt-palette-3);font-weight:bold">A</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Sor…</span> <span style="color:var(--vt-palette-8)">│</span>10-<span style="color:var(--vt-palette-8)">│</span> Sort <span style="color:var(--vt-palette-3);font-weight:bold">s</span>heet <span style="color:var(--vt-palette-3);font-weight:bold">A</span> to Z <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Sor…</span> <span style="color:var(--vt-palette-8)">│</span>11-<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)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Sor…</span> <span style="color:var(--vt-palette-8)">│</span>12-<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)">Data</span> <span style="color:var(--vt-palette-8)">│</span>13-<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)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Sor…</span> <span style="color:var(--vt-palette-8)">│</span>5+<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);font-weight:bold;text-decoration:underline">S</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">ave</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold;text-decoration:underline">a</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">s</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">File</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">Sa</span>ve <span style="color:var(--vt-palette-8)">File</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+S</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>7+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>elect <span style="color:var(--vt-palette-3);font-weight:bold">a</span>ll <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+A</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>8+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>ort range <span style="color:var(--vt-palette-3);font-weight:bold">A</span> to Z <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">…</span> <span style="color:var(--vt-palette-8)">│</span>9+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>ort range Z to <span style="color:var(--vt-palette-3);font-weight:bold">A</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">…</span> <span style="color:var(--vt-palette-8)">│</span>10+<span style="color:var(--vt-palette-8)">│</span> Sort <span style="color:var(--vt-palette-3);font-weight:bold">s</span>heet <span style="color:var(--vt-palette-3);font-weight:bold">A</span> to Z <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">…</span> <span style="color:var(--vt-palette-8)">│</span>11+<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)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">…</span> <span style="color:var(--vt-palette-8)">│</span>12+<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)">Data</span> <span style="color:var(--vt-palette-8)">│</span>13+<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)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">…</span> <span style="color:var(--vt-palette-8)">│</span>1414 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Select</span> <span style="color:var(--vt-palette-8)">chart</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)">25</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">114</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)">26</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">117</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@@ -1,20 +1,20 @@
11 <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)">B7</span><span style="background:var(--vt-palette-7)"> </span> =SUM<span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Search</span> <span style="font-weight:bold">the</span> <span style="font-weight:bold">menus</span> <span style="color:var(--vt-palette-8)">───────────────────────────────────────────────┐</span>3- <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> col <span style="color:var(--vt-palette-8)">│</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="color:var(--vt-palette-8)">├──────────────────────────────────────────────────────────────────┤</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> Household<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);font-weight:bold;text-decoration:underline">Col</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">umn</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">width</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Format</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> Delete <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">Edit</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> Rent <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-4);font-weight:bold">co</span>lumns <span style="color:var(--vt-palette-4);font-weight:bold">l</span>eft <span style="color:var(--vt-palette-0)">Insert</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> Groceries<span style="color:var(--vt-palette-8)">│</span> Select <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Space</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</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> Transit <span style="color:var(--vt-palette-8)">│</span> Freeze 1 <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umn <span style="color:var(--vt-palette-0)">View</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Freeze</span> <span style="color:var(--vt-palette-8)">│</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> Savings r<span style="color:var(--vt-palette-8)">│</span> Unfreeze <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">View</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Freeze</span> <span style="color:var(--vt-palette-8)">│</span>11-<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">7</span><span style="background:var(--vt-palette-6)"> </span> Total <span style="color:var(--vt-palette-8)">│</span> Freeze 2 <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">View</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Freeze</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Filter</span> <span style="color:var(--vt-palette-8)">by</span> <span style="color:var(--vt-palette-8)">column</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">Alt+Down</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> Reset <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umn width <span style="color:var(--vt-palette-0)">Format</span> <span style="color:var(--vt-palette-8)">│</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> <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>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> <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>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> <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)">18</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">114</span> <span style="color:var(--vt-palette-8)">─┘</span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B7</span><span style="background:var(--vt-palette-7)"> </span> =S<span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Search</span> <span style="font-weight:bold">the</span> <span style="font-weight:bold">menus</span> <span style="color:var(--vt-palette-8)">──────────────────────────────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> col <span style="color:var(--vt-palette-8)">│</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="color:var(--vt-palette-8)">├─────────────────────────────────────────────────────────────────────┤</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> Househo<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);font-weight:bold;text-decoration:underline">Col</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">umn</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">width</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Format</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> Delete <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">Edit</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> Rent <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-4);font-weight:bold">co</span>lumns <span style="color:var(--vt-palette-4);font-weight:bold">l</span>eft <span style="color:var(--vt-palette-0)">Insert</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> Groceri<span style="color:var(--vt-palette-8)">│</span> Select <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Space</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</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> Transit<span style="color:var(--vt-palette-8)">│</span> Freeze 1 <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umn <span style="color:var(--vt-palette-0)">View</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Freeze</span> <span style="color:var(--vt-palette-8)">│</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> Savings<span style="color:var(--vt-palette-8)">│</span> Unfreeze <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">View</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Freeze</span> <span style="color:var(--vt-palette-8)">│</span>11+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">7</span><span style="background:var(--vt-palette-6)"> </span> Total <span style="color:var(--vt-palette-8)">│</span> Freeze 2 <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">View</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Freeze</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Filter</span> <span style="color:var(--vt-palette-8)">by</span> <span style="color:var(--vt-palette-8)">column</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">Alt+Down</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> Reset <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umn width <span style="color:var(--vt-palette-0)">Format</span> <span style="color:var(--vt-palette-8)">│</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> <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>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> <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>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> <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)">19</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">117</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>@@ -1,20 +1,20 @@
11 <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)">B7</span><span style="background:var(--vt-palette-8)"> </span> =SUM<span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Search</span> <span style="font-weight:bold">the</span> <span style="font-weight:bold">menus</span> <span style="color:var(--vt-palette-8)">───────────────────────────────────────────────┐</span>3- <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> col <span style="color:var(--vt-palette-8)">│</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="color:var(--vt-palette-8)">├──────────────────────────────────────────────────────────────────┤</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> Household<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);font-weight:bold;text-decoration:underline">Col</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">umn</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">width</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Format</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> Delete <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">Edit</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> Rent <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-3);font-weight:bold">co</span>lumns <span style="color:var(--vt-palette-3);font-weight:bold">l</span>eft <span style="color:var(--vt-palette-8)">Insert</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> Groceries<span style="color:var(--vt-palette-8)">│</span> Select <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Space</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> Transit <span style="color:var(--vt-palette-8)">│</span> Freeze 1 <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umn <span style="color:var(--vt-palette-8)">View</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Freeze</span> <span style="color: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> Savings r<span style="color:var(--vt-palette-8)">│</span> Unfreeze <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">View</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Freeze</span> <span style="color:var(--vt-palette-8)">│</span>11-<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">7</span><span style="background:var(--vt-palette-6)"> </span> Total <span style="color:var(--vt-palette-8)">│</span> Freeze 2 <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">View</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Freeze</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Filter</span> <span style="color:var(--vt-palette-8)">by</span> <span style="color:var(--vt-palette-8)">column</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">Alt+Down</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Reset <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umn width <span style="color:var(--vt-palette-8)">Format</span> <span style="color: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> <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>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> 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>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> 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)">18</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">114</span> <span style="color:var(--vt-palette-8)">─┘</span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B7</span><span style="background:var(--vt-palette-8)"> </span> =S<span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Search</span> <span style="font-weight:bold">the</span> <span style="font-weight:bold">menus</span> <span style="color:var(--vt-palette-8)">──────────────────────────────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> col <span style="color:var(--vt-palette-8)">│</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="color:var(--vt-palette-8)">├─────────────────────────────────────────────────────────────────────┤</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> Househo<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);font-weight:bold;text-decoration:underline">Col</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">umn</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">width</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Format</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> Delete <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">Edit</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> Rent <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-3);font-weight:bold">co</span>lumns <span style="color:var(--vt-palette-3);font-weight:bold">l</span>eft <span style="color:var(--vt-palette-8)">Insert</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> Groceri<span style="color:var(--vt-palette-8)">│</span> Select <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Space</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> Transit<span style="color:var(--vt-palette-8)">│</span> Freeze 1 <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umn <span style="color:var(--vt-palette-8)">View</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Freeze</span> <span style="color: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> Savings<span style="color:var(--vt-palette-8)">│</span> Unfreeze <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">View</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Freeze</span> <span style="color:var(--vt-palette-8)">│</span>11+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">7</span><span style="background:var(--vt-palette-6)"> </span> Total <span style="color:var(--vt-palette-8)">│</span> Freeze 2 <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">View</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Freeze</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Filter</span> <span style="color:var(--vt-palette-8)">by</span> <span style="color:var(--vt-palette-8)">column</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">Alt+Down</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Reset <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umn width <span style="color:var(--vt-palette-8)">Format</span> <span style="color: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> <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>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> 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>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> 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)">19</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">117</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>@@ -1,20 +1,20 @@
11 <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)">B7</span><span style="background:var(--vt-palette-8)"> </span> =SUM(B3:B5) <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Search</span> <span style="font-weight:bold">the</span> <span style="font-weight:bold">menus</span> <span style="color:var(--vt-palette-8)">───────────────────────────────────────────────┐</span>3- <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> sel <span style="color:var(--vt-palette-8)">│</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-8)">├──────────────────────────────────────────────────────────────────┤</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">N</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">O</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">P</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Q</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">R</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">S</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> Household budget 2026 <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);font-weight:bold;text-decoration:underline">Sel</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">ect</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">all</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Edit</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Ctrl+A</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> <span style="color:var(--vt-palette-3);font-weight:bold">Sel</span>ect rows <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+Space</span><span style="background:var(--vt-palette-8)"> </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> Rent 1450 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Select</span> <span style="color:var(--vt-palette-8)">chart</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> Groceries 612.4 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">Sel</span>ect columns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Space</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> Transit 96 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Move</span> <span style="color:var(--vt-palette-8)">sheet</span> <span style="color:var(--vt-palette-8)">left</span> <span style="color:var(--vt-palette-8)">Edit</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Sheet</span> <span style="color: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> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span> <span style="color:var(--vt-palette-8)">│</span> De<span style="color:var(--vt-palette-3);font-weight:bold">sel</span>ect <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)">│</span>11-<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">7</span><span style="background:var(--vt-palette-6)"> </span> Total <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">2158.4</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt columns <span style="color:var(--vt-palette-3);font-weight:bold">l</span>eft <span style="color:var(--vt-palette-8)">Insert</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>av<span style="color:var(--vt-palette-3);font-weight:bold">e</span> <span style="color:var(--vt-palette-8)">Fi</span><span style="color:var(--vt-palette-3);font-weight:bold">l</span><span style="color: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)">Ctrl+S</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>av<span style="color:var(--vt-palette-3);font-weight:bold">e</span> as <span style="color:var(--vt-palette-8)">Fi</span><span style="color:var(--vt-palette-3);font-weight:bold">l</span><span style="color:var(--vt-palette-8)">e</span> <span style="color: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> <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>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> 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>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> 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)">114</span> <span style="color:var(--vt-palette-8)">─┘</span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B7</span><span style="background:var(--vt-palette-8)"> </span> =SUM(B3:B5) <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Search</span> <span style="font-weight:bold">the</span> <span style="font-weight:bold">menus</span> <span style="color:var(--vt-palette-8)">──────────────────────────────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> sel <span style="color:var(--vt-palette-8)">│</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-8)">├─────────────────────────────────────────────────────────────────────┤</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">N</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">O</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">P</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Q</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">R</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">S</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> Household budget 2026 <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);font-weight:bold;text-decoration:underline">Sel</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">ect</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">all</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Edit</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Ctrl+A</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> <span style="color:var(--vt-palette-3);font-weight:bold">Sel</span>ect rows <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+Space</span><span style="background:var(--vt-palette-8)"> </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> Rent 1450 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Select</span> <span style="color:var(--vt-palette-8)">chart</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> Groceries 612.4 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">Sel</span>ect columns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Space</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> Transit 96 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Move</span> <span style="color:var(--vt-palette-8)">sheet</span> <span style="color:var(--vt-palette-8)">left</span> <span style="color:var(--vt-palette-8)">Edit</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Sheet</span> <span style="color: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> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span> <span style="color:var(--vt-palette-8)">│</span> De<span style="color:var(--vt-palette-3);font-weight:bold">sel</span>ect <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)">│</span>11+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">7</span><span style="background:var(--vt-palette-6)"> </span> Total <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">2158.4</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt columns <span style="color:var(--vt-palette-3);font-weight:bold">l</span>eft <span style="color:var(--vt-palette-8)">Insert</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>av<span style="color:var(--vt-palette-3);font-weight:bold">e</span> <span style="color:var(--vt-palette-8)">Fi</span><span style="color:var(--vt-palette-3);font-weight:bold">l</span><span style="color: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)">Ctrl+S</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>av<span style="color:var(--vt-palette-3);font-weight:bold">e</span> as <span style="color:var(--vt-palette-8)">Fi</span><span style="color:var(--vt-palette-3);font-weight:bold">l</span><span style="color:var(--vt-palette-8)">e</span> <span style="color: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> <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>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> 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>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> 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)">117</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>@@ -1,20 +1,20 @@
11 <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)">B7</span><span style="background:var(--vt-palette-8)"> </span> =SUM<span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Search</span> <span style="font-weight:bold">the</span> <span style="font-weight:bold">menus</span> <span style="color:var(--vt-palette-8)">───────────────────────────────────────────────┐</span>3- <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> <span style="color:var(--vt-palette-8)">Type</span> <span style="color:var(--vt-palette-8)">a</span> <span style="color:var(--vt-palette-8)">command,</span> <span style="color:var(--vt-palette-8)">e.g.</span> <span style="color:var(--vt-palette-8)">save</span> <span style="color:var(--vt-palette-8)">or</span> <span style="color:var(--vt-palette-8)">width</span> <span style="color:var(--vt-palette-8)">│</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="color:var(--vt-palette-8)">├──────────────────────────────────────────────────────────────────┤</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> Household<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)">New</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">File</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> Open <span style="color:var(--vt-palette-8)">File</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+O</span><span style="background:var(--vt-palette-8)"> </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> Rent <span style="color:var(--vt-palette-8)">│</span> Import <span style="color:var(--vt-palette-8)">File</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> Groceries<span style="color:var(--vt-palette-8)">│</span> Save <span style="color:var(--vt-palette-8)">File</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+S</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> Transit <span style="color:var(--vt-palette-8)">│</span> Save as <span style="color:var(--vt-palette-8)">File</span> <span style="color: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> Savings r<span style="color:var(--vt-palette-8)">│</span> Download as CSV <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>11-<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">7</span><span style="background:var(--vt-palette-6)"> </span> Total <span style="color:var(--vt-palette-8)">│</span> Download as TSV <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>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> <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>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> <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>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> <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)">114</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">114</span> <span style="color:var(--vt-palette-8)">─┘</span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B7</span><span style="background:var(--vt-palette-8)"> </span> =S<span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Search</span> <span style="font-weight:bold">the</span> <span style="font-weight:bold">menus</span> <span style="color:var(--vt-palette-8)">──────────────────────────────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> <span style="color:var(--vt-palette-8)">Type</span> <span style="color:var(--vt-palette-8)">a</span> <span style="color:var(--vt-palette-8)">command,</span> <span style="color:var(--vt-palette-8)">e.g.</span> <span style="color:var(--vt-palette-8)">save</span> <span style="color:var(--vt-palette-8)">or</span> <span style="color:var(--vt-palette-8)">width</span> <span style="color:var(--vt-palette-8)">│</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="color:var(--vt-palette-8)">├─────────────────────────────────────────────────────────────────────┤</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> Househo<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)">New</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">File</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> Open <span style="color:var(--vt-palette-8)">File</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+O</span><span style="background:var(--vt-palette-8)"> </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> Rent <span style="color:var(--vt-palette-8)">│</span> Import <span style="color:var(--vt-palette-8)">File</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> Groceri<span style="color:var(--vt-palette-8)">│</span> Save <span style="color:var(--vt-palette-8)">File</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+S</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> Transit<span style="color:var(--vt-palette-8)">│</span> Save as <span style="color:var(--vt-palette-8)">File</span> <span style="color: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> Savings<span style="color:var(--vt-palette-8)">│</span> Download as CSV <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>11+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">7</span><span style="background:var(--vt-palette-6)"> </span> Total <span style="color:var(--vt-palette-8)">│</span> Download as TSV <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>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> <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>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> <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>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> <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)">117</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">117</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>@@ -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">PIVOT</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> Reorder3+4+<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">A</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)">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="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>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-0);background:var(--vt-palette-6);font-weight:bold">Reorder</span><span style="background:var(--vt-palette-6)"> </span> <span style="font-weight:bold">no</span> <span style="font-weight:bold">yes</span> <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Pivot</span> <span style="font-weight:bold">table</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="font-weight:bold">Category</span> <span style="font-weight:bold">SUM</span> <span style="font-weight:bold">of</span> <span style="font-weight:bold">Qty</span> <span style="font-weight:bold">AVERAGE</span> <span style="font-weight:bold">of</span> <span style="font-weight:bold">Price</span> <span style="font-weight:bold">SUM</span> <span style="color:var(--vt-palette-8)">│</span> Data <span style="font-weight:bold">Sheet1!A1:F41</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> Fasteners 796 8.87941176470588 <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> Hardware 489 14.055 <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Rows</span> <span style="color:var(--vt-palette-8)">│</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> <span style="font-weight:bold">Grand</span> <span style="font-weight:bold">Total</span> <span style="font-weight:bold">1285</span> <span style="font-weight:bold">10.7962962962963</span> <span style="color:var(--vt-palette-8)">│</span> Category <span style="color:var(--vt-palette-0)">A→Z</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Columns</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> Reorder <span style="color:var(--vt-palette-0)">A→Z</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Values</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> Qty <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">‹</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">SUM</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">›</span> <span style="color:var(--vt-palette-8)">│</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> <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)">Price</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-0);background:var(--vt-palette-6)">AVERAGE</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>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> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Filters</span> <span style="color:var(--vt-palette-0)">Space</span> <span style="color:var(--vt-palette-0)">add</span> <span style="color:var(--vt-palette-8)">│</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> <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> [x] Grand total row <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> [x] Grand total column <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">└────────────────────────────</span> <span style="color:var(--vt-palette-0)">results</span> <span style="color:var(--vt-palette-0)">A1:G5</span> <span style="color:var(--vt-palette-8)">─┘</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+Average the numbers <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">←/→</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">summarize</span> <span style="color:var(--vt-palette-0)">by</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">S</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">show</span> <span style="color:var(--vt-palette-0)">as</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Del</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">remove</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)">done</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></pre>\ No newline at end of file@@ -0,0 +1,16 @@
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">PIVOT</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> Reorder3+4+<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">A</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)">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>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-0);background:var(--vt-palette-6);font-weight:bold">Reorder</span><span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Pivot</span> <span style="font-weight:bold">table</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="font-weight:bold">Categor</span><span style="color:var(--vt-palette-8)">│</span> Data <span style="font-weight:bold">Sheet1!A1:F41</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> Fastene<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> Hardwar<span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Rows</span> <span style="color: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> <span style="font-weight:bold">Grand</span> <span style="font-weight:bold">T</span><span style="color:var(--vt-palette-8)">│</span> Category <span style="color:var(--vt-palette-8)">A→Z</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Columns</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Reorder <span style="color:var(--vt-palette-8)">A→Z</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Values</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Qty <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">‹</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">SUM</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">›</span> <span style="color: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> <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)">Price</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-0);background:var(--vt-palette-6)">AVERAGE</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>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> <span style="color:var(--vt-palette-8)">results</span> <span style="color:var(--vt-palette-8)">A1:G5</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)">←/→</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">summarize</span> <span style="color:var(--vt-palette-8)">by</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)">done</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></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">PIVOT</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> Reorder3+4+<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">A</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)">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="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>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-0);background:var(--vt-palette-6);font-weight:bold">Reorder</span><span style="background:var(--vt-palette-6)"> </span> <span style="font-weight:bold">no</span> <span style="font-weight:bold">yes</span> <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Pivot</span> <span style="font-weight:bold">table</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="font-weight:bold">Category</span> <span style="font-weight:bold">SUM</span> <span style="font-weight:bold">of</span> <span style="font-weight:bold">Qty</span> <span style="font-weight:bold">AVERAGE</span> <span style="font-weight:bold">of</span> <span style="font-weight:bold">Price</span> <span style="font-weight:bold">SUM</span> <span style="color:var(--vt-palette-8)">│</span> Data <span style="font-weight:bold">Sheet1!A1:F41</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> Fasteners 796 8.87941176470588 <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> Hardware 489 14.055 <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Rows</span> <span style="color: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> <span style="font-weight:bold">Grand</span> <span style="font-weight:bold">Total</span> <span style="font-weight:bold">1285</span> <span style="font-weight:bold">10.7962962962963</span> <span style="color:var(--vt-palette-8)">│</span> Category <span style="color:var(--vt-palette-8)">A→Z</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Columns</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Reorder <span style="color:var(--vt-palette-8)">A→Z</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Values</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Qty <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">‹</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">SUM</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">›</span> <span style="color: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> <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)">Price</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-0);background:var(--vt-palette-6)">AVERAGE</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>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> <span style="font-weight:bold">Filters</span> <span style="color:var(--vt-palette-8)">Space</span> <span style="color:var(--vt-palette-8)">add</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>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> [x] Grand total row <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> [x] Grand total column <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> <span style="color:var(--vt-palette-8)">results</span> <span style="color:var(--vt-palette-8)">A1:G5</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>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+Average the numbers <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">←/→</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">summarize</span> <span style="color:var(--vt-palette-8)">by</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">S</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">show</span> <span style="color:var(--vt-palette-8)">as</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Del</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">remove</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)">done</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></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">FILTER</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> Reorder3+4+<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">A</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)">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="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>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-0);background:var(--vt-palette-6);font-weight:bold">Reorder</span><span style="background:var(--vt-palette-6)"> </span> <span style="font-weight:bold">no</span> <span style="font-weight:bold">yes</span> <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Filter</span> <span style="font-weight:bold">Item</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="font-weight:bold">Category</span> <span style="font-weight:bold">SUM</span> <span style="font-weight:bold">of</span> <span style="font-weight:bold">Qty</span> <span style="font-weight:bold">AVERAGE</span> <span style="font-weight:bold">of</span> <span style="font-weight:bold">Price</span> <span style="font-weight:bold">SUM</span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">If</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">‹</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">None</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">›</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> Fasteners 796 8.87941176470588 <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> Hardware 489 14.055 <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> bolts <span style="color: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> <span style="font-weight:bold">Grand</span> <span style="font-weight:bold">Total</span> <span style="font-weight:bold">1285</span> <span style="font-weight:bold">10.7962962962963</span> <span style="color: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> <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)">[x]</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Select</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">all</span><span style="background:var(--vt-palette-6)"> </span><span style="color: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> <span style="color:var(--vt-palette-8)">│</span> [x] Bolts 1 <span style="color:var(--vt-palette-8)">1</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> [x] Bolts 2 <span style="color:var(--vt-palette-8)">1</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> [x] Bolts 3 <span style="color:var(--vt-palette-8)">1</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> [x] Bolts 4 <span style="color:var(--vt-palette-8)">1</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> [x] Bolts 5 <span style="color:var(--vt-palette-8)">1</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> <span style="color:var(--vt-palette-8)">5</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">40</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>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="color:var(--vt-palette-8)">Type</span> <span style="color:var(--vt-palette-8)">to</span> <span style="color:var(--vt-palette-8)">search</span> <span style="color:var(--vt-palette-8)">the</span> <span style="color:var(--vt-palette-8)">values</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Space</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">check</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Tab</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">condition</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)">apply</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></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-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C3</span><span style="background:var(--vt-palette-7)"> </span> 8.8794117647058843+Pivot table results can't be edited: change the pivot with Data > Edit pivot table4+<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="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">C</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)">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>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> <span style="font-weight:bold">Reorder</span> <span style="font-weight:bold">no</span> <span style="font-weight:bold">yes</span> <span style="font-weight:bold">Grand</span> <span style="font-weight:bold">Total</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="font-weight:bold">Category</span> <span style="font-weight:bold">SUM</span> <span style="font-weight:bold">of</span> <span style="font-weight:bold">Qty</span> <span style="font-weight:bold">AVERAGE</span> <span style="font-weight:bold">of</span> <span style="font-weight:bold">Price</span> <span style="font-weight:bold">SUM</span> <span style="font-weight:bold">of</span> <span style="font-weight:bold">Qty</span> <span style="font-weight:bold">AVERAGE</span> <span style="font-weight:bold">of</span> <span style="font-weight:bold">Price</span> <span style="font-weight:bold">SUM</span> <span style="font-weight:bold">of</span> <span style="font-weight:bold">Qty</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> Fasteners 796 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">8.87941176470588</span><span style="background:var(--vt-palette-6)"> </span> 434 7.5375 12308+<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> Hardware 489 14.055 251 11.04 7409+<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> <span style="font-weight:bold">Grand</span> <span style="font-weight:bold">Total</span> <span style="font-weight:bold">1285</span> <span style="font-weight:bold">10.7962962962963</span> <span style="font-weight:bold">685</span> <span style="font-weight:bold">8.88461538461539</span> <span style="font-weight:bold">1970</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+ Sheet1 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Pivot</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Table</span><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)">+</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">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)">C3</span><span style="background:var(--vt-palette-8)"> </span> 8.8794117647058843+Pivot table results can't be edited: change the pivot with Data > Edit pivot table4+<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="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">C</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)">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>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> <span style="font-weight:bold">Reorder</span> <span style="font-weight:bold">no</span> <span style="font-weight:bold">yes</span> <span style="font-weight:bold">Grand</span> <span style="font-weight:bold">Total</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="font-weight:bold">Category</span> <span style="font-weight:bold">SUM</span> <span style="font-weight:bold">of</span> <span style="font-weight:bold">Qty</span> <span style="font-weight:bold">AVERAGE</span> <span style="font-weight:bold">of</span> <span style="font-weight:bold">Price</span> <span style="font-weight:bold">SUM</span> <span style="font-weight:bold">of</span> <span style="font-weight:bold">Qty</span> <span style="font-weight:bold">AVERAGE</span> <span style="font-weight:bold">of</span> <span style="font-weight:bold">Price</span> <span style="font-weight:bold">SUM</span> <span style="font-weight:bold">of</span> <span style="font-weight:bold">Qty</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> Fasteners 796 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">8.87941176470588</span><span style="background:var(--vt-palette-6)"> </span> 434 7.5375 12308+<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> Hardware 489 14.055 251 11.04 7409+<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> <span style="font-weight:bold">Grand</span> <span style="font-weight:bold">Total</span> <span style="font-weight:bold">1285</span> <span style="font-weight:bold">10.7962962962963</span> <span style="font-weight:bold">685</span> <span style="font-weight:bold">8.88461538461539</span> <span style="font-weight:bold">1970</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+ Sheet1 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Pivot</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Table</span><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="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@@ -251,6 +251,37 @@ func TestExcelFormulas(t *testing.T) {
251251 }252252 }253253 254+// A pivot table goes out as the values it shows: Excel gets no pivot255+// cache, and its results read back as plain cells.256+func TestXLSXPivotAsValues(t *testing.T) {257+ src := build(t, map[string]string{"A1": "Item", "B1": "Qty", "A2": "Pen", "B2": "3", "A3": "Ink", "B3": "4", "A4": "Pen", "B4": "5"})258+ r := sheet.NewRect(addr(t, "A1"), addr(t, "B4"))259+ p := sheet.NewPivot(src, r)260+ p.Rows = []sheet.PivotGroup{{Col: 0}}261+ p.Values = []sheet.PivotValue{{Col: 1, Summarize: sheet.SumBy}}262+ pv, err := src.Book().CreatePivot(src, r, "", p)263+ if err != nil {264+ t.Fatal(err)265+ }266+ name := filepath.Join(t.TempDir(), "pivot.xlsx")267+ if _, err := Export(context.Background(), name, XLSX, SnapBook(pv), ExportOptions{}); err != nil {268+ t.Fatal(err)269+ }270+ x, err := excelize.OpenFile(name)271+ if err != nil {272+ t.Fatal(err)273+ }274+ defer x.Close()275+ for cell, want := range map[string]string{"A1": "Item", "B1": "SUM of Qty", "A3": "Pen", "B3": "8", "B4": "12"} {276+ if got, _ := x.GetCellValue("Pivot Table 1", cell); got != want {277+ t.Errorf("%s = %q, want %q", cell, got, want)278+ }279+ if f, _ := x.GetCellFormula("Pivot Table 1", cell); f != "" {280+ t.Errorf("%s has formula %q", cell, f)281+ }282+ }283+}284+254285 // Every sheet goes out to Excel and comes back, with references between255286 // sheets, the sheet shown and named ranges.256287 func TestXLSXWorkbookRoundTrip(t *testing.T) {@@ -31,6 +31,9 @@ func (s *Sheet) explain(a Addr, path []loc) string {
3131 here := loc{s, a}3232 c := s.cells.get(a)3333 v := s.Value(a)34+ if c != nil && c.derived && v.Kind == Error && s.pivot.err != "" {35+ return s.pivot.err36+ }3437 if c == nil || v.Kind != Error || !c.IsFormula() {3538 return ""3639 }@@ -20,7 +20,9 @@ const FileExt = ".012"
2020 // Version 4 holds several sheets; a workbook of one sheet whose formulas2121 // name no sheet is still written as version 2 or 3, its sheet's name in2222 // a "name" field that earlier builds ignore.23-const fileVersion = 423+// Version 5 is version 4 with pivot tables; a workbook without one is24+// still written as version 4.25+const fileVersion = 52426 2527 // The file is JSON with one entry per cell, keyed by address. A cell2628 // without formatting is just its input, as in version 1; a formatted cell@@ -58,6 +60,7 @@ type fileSheet struct {
5860 fileView5961 Cells map[string]json.RawMessage `json:"cells"`6062 Charts []fileChart `json:"charts,omitempty"`63+ Pivot *filePivot `json:"pivot,omitempty"` // version 56164 }6265 6366 // fileChart is a chart, one per line after the cells:@@ -188,7 +191,11 @@ func (w *Workbook) Write(out io.Writer) error {
188191 }189192 b.WriteString("\n}\n")190193 } else {191- fmt.Fprintf(&b, "{\n \"version\": %d,\n", fileVersion)194+ version := 4195+ if w.hasPivots() {196+ version = fileVersion197+ }198+ fmt.Fprintf(&b, "{\n \"version\": %d,\n", version)192199 if head := w.headLines(); head != "" {193200 b.WriteString(" " + head + ",\n")194201 }@@ -215,7 +222,7 @@ func (w *Workbook) Write(out io.Writer) error {
215222 // single reports whether the workbook fits the single-sheet format of216223 // versions 2 and 3: one sheet, and no formula naming a sheet.217224 func (w *Workbook) single() bool {218- return len(w.sheets) == 1 && len(w.crossUsers) == 0225+ return len(w.sheets) == 1 && len(w.crossUsers) == 0 && !w.hasPivots()219226 }220227 221228 func jsonString(s string) string {@@ -275,8 +282,10 @@ func (s *Sheet) writeBody(b *bytes.Buffer, indent, names string) error {
275282 return err276283 }277284 addrs := make([]Addr, 0, s.cells.len())278- for a := range s.cells.all() {279- addrs = append(addrs, a)285+ for a, c := range s.cells.all() {286+ if !c.derived { // a pivot's results are computed, not saved287+ addrs = append(addrs, a)288+ }280289 }281290 sortAddrs(addrs)282291 b.WriteString(indent + `"cells": {`)@@ -294,6 +303,12 @@ func (s *Sheet) writeBody(b *bytes.Buffer, indent, names string) error {
294303 b.WriteString("\n" + indent)295304 }296305 b.WriteString("}")306+ return s.writeObjects(b, indent)307+}308+309+// writeObjects writes the fields after the cells: the charts, one per310+// line, and the pivot table's definition.311+func (s *Sheet) writeObjects(b *bytes.Buffer, indent string) error {297312 if len(s.charts) > 0 {298313 b.WriteString(",\n" + indent + `"charts": [`)299314 for i, c := range s.charts {@@ -308,6 +323,13 @@ func (s *Sheet) writeBody(b *bytes.Buffer, indent, names string) error {
308323 }309324 b.WriteString("\n" + indent + "]")310325 }326+ if p := s.pivot.def; p != nil {327+ raw, err := encodePivot(p)328+ if err != nil {329+ return err330+ }331+ fmt.Fprintf(b, ",\n%s\"pivot\": %s", indent, raw)332+ }311333 return nil312334 }313335 @@ -429,5 +451,12 @@ func (s *Sheet) read(f fileSheet, version int) error {
429451 }430452 s.charts = append(s.charts, c)431453 }454+ if f.Pivot != nil {455+ p, err := decodePivot(*f.Pivot)456+ if err != nil {457+ return err458+ }459+ s.pivot = pivotState{def: p, stale: true}460+ }432461 return s.readView(f.fileView)433462 }@@ -334,15 +334,21 @@ func (s *Sheet) FilterValues(col int) []FilterValue {
334334 if f == nil {335335 return nil336336 }337- tests := s.criteriaTests(f, col)337+ return s.valuesList(f.Range, col, s.criteriaTests(f, col), f.Cols[col].Hidden, false)338+}339+340+// valuesList lists the distinct values shown in column col of r's data341+// rows among those passing tests, with hidden unchecked, as a filter's342+// values list. With skipBlank, rows blank across r are left out.343+func (s *Sheet) valuesList(r Rect, col int, tests []colTest, hide []string, skipBlank bool) []FilterValue {338344 hidden := map[string]bool{}339- for _, h := range f.Cols[col].Hidden {345+ for _, h := range hide {340346 hidden[h] = true341347 }342348 counts := map[string]int{}343349 values := map[string]Value{}344- for row := f.Range.From.Row + 1; row <= f.Range.To.Row; row++ {345- if !s.rowPasses(row, tests) {350+ for row := r.From.Row + 1; row <= r.To.Row; row++ {351+ if !s.rowPasses(row, tests) || skipBlank && s.rowBlank(row, r) {346352 continue347353 }348354 a := Addr{Col: col, Row: row}@@ -86,7 +86,7 @@ func (s *Sheet) Replace(a Addr, query, repl string, o FindOptions) (bool, error)
8686 8787 func (s *Sheet) replace(a Addr, f *finder, repl string) (bool, error) {8888 c := s.cells.get(a)89- if c == nil || (c.IsFormula() && !f.o.InFormulas) {89+ if c == nil || c.derived || (c.IsFormula() && !f.o.InFormulas) {9090 return false, nil9191 }9292 old := f.text(c)@@ -50,6 +50,8 @@ type step struct {
5050 views map[*Sheet]*viewState5151 // charts holds each touched sheet's charts before the step.5252 charts map[*Sheet][]Chart53+ // pivots holds each touched sheet's pivot definition before the step.54+ pivots map[*Sheet]*Pivot5355 // sheets is the sheet list before the step, when it changed.5456 sheets *sheetList5557 // decimal is the arithmetic setting before the step, when it changed.@@ -70,7 +72,7 @@ type sheetList struct {
7072 7173 func newStep(label string, s *Sheet, focus Rect) *step {7274 return &step{label: label, sheet: s, focus: focus, cells: map[loc]*Cell{}, widths: map[colKey]int{},73- names: map[string]*Name{}, views: map[*Sheet]*viewState{}, charts: map[*Sheet][]Chart{}}75+ names: map[string]*Name{}, views: map[*Sheet]*viewState{}, charts: map[*Sheet][]Chart{}, pivots: map[*Sheet]*Pivot{}}7476 }7577 7678 func (st *step) empty() bool {@@ -79,7 +81,7 @@ func (st *step) empty() bool {
7981 8082 // widthOnly reports whether the step changed nothing but column widths.8183 func (st *step) widthOnly() bool {82- return len(st.cells) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && st.sheets == nil && st.decimal == nil84+ return len(st.cells) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && st.sheets == nil && st.decimal == nil8385 }8486 8587 func (c *Cell) clone() *Cell {@@ -290,6 +292,11 @@ func (w *Workbook) dropUnchanged(st *step) {
290292 delete(st.views, s)291293 }292294 }295+ for s, p := range st.pivots {296+ if samePivot(p, s.pivot.def) {297+ delete(st.pivots, s)298+ }299+ }293300 if st.sheets != nil && st.sheets.equal(w.sheetList()) {294301 st.sheets = nil295302 }@@ -420,6 +427,10 @@ func (w *Workbook) swap(undo bool) (Change, bool) {
420427 inv.charts[s] = s.charts421428 s.charts = slices.Clone(charts)422429 }430+ for s, p := range st.pivots {431+ inv.pivots[s] = s.pivot.def432+ s.pivot.def, s.pivot.stale = p, true433+ }423434 if st.decimal != nil {424435 cur := w.decimal425436 inv.decimal, w.decimal = &cur, *st.decimal@@ -124,6 +124,7 @@ func (s *Sheet) restructure(rows bool, sp formula.Span) {
124124 s.shiftWidths(sp)125125 }126126 s.shiftView(rows, sp)127+ s.shiftPivots(rows, sp)127128 })128129 }129130 @@ -157,7 +158,7 @@ type Clip struct {
157158 func (s *Sheet) Copy(r Rect) *Clip {158159 c := &Clip{Src: r, cells: map[Addr]*Cell{}}159160 for _, a := range s.cellsIn(r) {160- c.cells[Addr{Col: a.Col - r.From.Col, Row: a.Row - r.From.Row}] = s.cells.get(a).clone()161+ c.cells[Addr{Col: a.Col - r.From.Col, Row: a.Row - r.From.Row}] = s.cells.get(a).plain()161162 }162163 return c163164 }@@ -0,0 +1,403 @@
1+package sheet2+3+import (4+ "errors"5+ "fmt"6+ "reflect"7+ "slices"8+9+ "github.com/FelineStateMachine/012/internal/formula"10+)11+12+// A pivot table summarizes a range of another sheet, as Sheets' Insert >13+// Pivot table: rows grouped by the values of some columns (Rows), spread14+// across others (Columns), with values summarized per group (Values), and15+// rows left out by Filters. It lives on a sheet of its own, starting at16+// A1. The engine owns its results: they are derived cells, recomputed17+// whenever the source's cells change, never saved (the file keeps the18+// definition) and never edited by hand. A sheet has at most one pivot.19+type Pivot struct {20+ // Source names the sheet the data is on. Like a reference written21+ // with a sheet name, it follows renames, and while no sheet has the22+ // name the pivot shows #REF!.23+ Source string24+ // Range is the data on the source sheet; its first row holds the25+ // headers, which name the fields.26+ Range Rect27+ Rows []PivotGroup28+ Columns []PivotGroup29+ Values []PivotValue30+ Filters []PivotFilter31+ // RowTotals adds a Grand Total row, and a subtotal row after each32+ // outer group when there are several row groups. ColumnTotals adds a33+ // Grand Total column when there are column groups.34+ RowTotals, ColumnTotals bool35+ // Lost is set when the source range was deleted.36+ Lost bool37+}38+39+// PivotGroup is a column of the source whose distinct values become rows40+// or columns of the pivot.41+type PivotGroup struct {42+ Col int // column on the source sheet43+ Desc bool // Z to A, or largest first44+ // SortBy orders the groups by their label (0) or by the grand total45+ // of Values[SortBy-1].46+ SortBy int47+}48+49+// PivotValue is a column of the source summarized per group.50+type PivotValue struct {51+ Col int52+ Summarize Summarize53+ ShowAs ShowAs54+ Name string // the header; "" for Sheets' "SUM of Sales"55+}56+57+// PivotFilter leaves out the source rows whose value in Col doesn't meet58+// Criteria, as a filter's column does.59+type PivotFilter struct {60+ Col int61+ Criteria Criteria62+}63+64+// ShowAs is how a summarized value shows: as itself, or as a share of a65+// total, as Sheets' "Show as".66+type ShowAs uint867+68+const (69+ ShowValue ShowAs = iota70+ ShowPctRow71+ ShowPctColumn72+ ShowPctTotal73+ numShowAs74+)75+76+var showAsNames = [numShowAs]string{"", "percent_of_row", "percent_of_column", "percent_of_total"}77+var showAsTitles = [numShowAs]string{"Default", "% of row", "% of column", "% of grand total"}78+79+// ShowAsList lists the choices in Sheets' order.80+func ShowAsList() []ShowAs { return []ShowAs{ShowValue, ShowPctRow, ShowPctColumn, ShowPctTotal} }81+82+func (s ShowAs) String() string { return showAsNames[s] }83+func (s ShowAs) Title() string { return showAsTitles[s] }84+85+// ParseShowAs is the inverse of ShowAs.String.86+func ParseShowAs(name string) (ShowAs, bool) {87+ i := slices.Index(showAsNames[:], name)88+ return ShowAs(max(i, 0)), i >= 089+}90+91+// Errors of pivot tables.92+var (93+ // ErrPivotEdit is what editing a pivot table's results says, as94+ // Sheets refuses to.95+ ErrPivotEdit = errors.New("Pivot table results can't be edited: change the pivot with Data > Edit pivot table")96+ errPivotSelf = errors.New("A pivot table can't summarize its own sheet")97+ errPivotLoop = errors.New("A pivot table can't summarize a pivot table that reads it")98+)99+100+// pivotState is what a sheet keeps about its pivot. def is the definition101+// (in the undo history and the file, never modified in place); the rest102+// is derived from it and the source.103+type pivotState struct {104+ def *Pivot105+ stale bool // recompute at the end of the current change106+ out Rect // what the last result covered, from A1107+ err string // why the pivot shows #REF!, or ""108+ // blocked is where results in the way of cells would have gone, so109+ // clearing those cells recomputes the pivot.110+ blocked Rect111+ // fit has the result's columns widened to fit, while the sheet is112+ // the pivot's own: from its creation until a width is set by hand.113+ // The widths aren't undo steps; undoing the creation removes them114+ // with the sheet.115+ fit bool116+}117+118+// clone returns a deep copy, so a stored pivot never shares slices.119+func (p *Pivot) clone() *Pivot {120+ if p == nil {121+ return nil122+ }123+ cp := *p124+ cp.Rows, cp.Columns, cp.Values = slices.Clone(p.Rows), slices.Clone(p.Columns), slices.Clone(p.Values)125+ cp.Filters = make([]PivotFilter, len(p.Filters))126+ for i, f := range p.Filters {127+ f.Criteria.Hidden = slices.Clone(f.Criteria.Hidden)128+ cp.Filters[i] = f129+ }130+ if len(p.Filters) == 0 {131+ cp.Filters = nil132+ }133+ return &cp134+}135+136+func samePivot(a, b *Pivot) bool {137+ if a == nil || b == nil {138+ return a == b139+ }140+ return reflect.DeepEqual(a.clone(), b.clone())141+}142+143+// Pivot returns a copy of the sheet's pivot table, and false if it has144+// none.145+func (s *Sheet) Pivot() (Pivot, bool) {146+ if s.pivot.def == nil {147+ return Pivot{}, false148+ }149+ return *s.pivot.def.clone(), true150+}151+152+// PivotRange returns the cells the pivot's results cover, from A1 (at153+// least A1, even while it shows nothing), and false without a pivot.154+func (s *Sheet) PivotRange() (Rect, bool) {155+ if s.pivot.def == nil {156+ return Rect{}, false157+ }158+ return s.pivot.out, true159+}160+161+// InPivot reports whether r overlaps the pivot table's results, which162+// can't be edited.163+func (s *Sheet) InPivot(r Rect) bool {164+ out, ok := s.PivotRange()165+ return ok && out.From.Col <= r.To.Col && r.From.Col <= out.To.Col &&166+ out.From.Row <= r.To.Row && r.From.Row <= out.To.Row167+}168+169+// PivotError says why the pivot shows #REF!, or "" when it doesn't.170+func (s *Sheet) PivotError() string { return s.pivot.err }171+172+// PivotSource returns the sheet the pivot reads, or nil when no sheet has173+// its name.174+func (s *Sheet) PivotSource() *Sheet {175+ if s.pivot.def == nil {176+ return nil177+ }178+ return s.wb.Lookup(s.pivot.def.Source)179+}180+181+// NextPivotName is the name Sheets gives a new pivot sheet: Pivot Table 1,182+// then 2 and so on.183+func (w *Workbook) NextPivotName() string {184+ for n := 1; ; n++ {185+ if name := fmt.Sprintf("Pivot Table %d", n); w.Lookup(name) == nil {186+ return name187+ }188+ }189+}190+191+// CreatePivot adds a sheet named name ("" for the next Pivot Table N)192+// right after src, holding p over r on src, as one undo step.193+func (w *Workbook) CreatePivot(src *Sheet, r Rect, name string, p Pivot) (*Sheet, error) {194+ if name == "" {195+ name = w.NextPivotName()196+ }197+ name = w.freeName(name)198+ if err := w.checkName(nil, name); err != nil {199+ return nil, err200+ }201+ p.Source, p.Range = src.name, r202+ s := w.newSheet(name)203+ w.change(s, "create "+name, Rect{}, func() {204+ w.recordSheets()205+ w.insert(s, w.Index(src)+1)206+ s.pivot = pivotState{def: p.clone(), stale: true, fit: true}207+ })208+ return s, nil209+}210+211+// SetPivot replaces the sheet's pivot definition as one undo step,212+// labelled label ("edit pivot table" when empty).213+func (s *Sheet) SetPivot(p Pivot, label string) error {214+ if err := s.checkPivot(p); err != nil {215+ return err216+ }217+ if samePivot(&p, s.pivot.def) {218+ return nil219+ }220+ if label == "" {221+ label = "edit pivot table"222+ }223+ s.change(label, Rect{}, func() { s.putPivot(p.clone()) })224+ return nil225+}226+227+// checkPivot refuses a pivot reading its own sheet, or a pivot whose228+// results it reads.229+func (s *Sheet) checkPivot(p Pivot) error {230+ name := p.Source231+ for range len(s.wb.sheets) + 1 {232+ src := s.wb.Lookup(name)233+ switch {234+ case src == nil || src.pivot.def == nil:235+ return nil236+ case src == s && name == p.Source:237+ return errPivotSelf238+ case src == s:239+ return errPivotLoop240+ }241+ name = src.pivot.def.Source242+ }243+ return errPivotLoop244+}245+246+// putPivot stores a pivot definition (nil for none), recording the old one247+// for undo, and has it recomputed.248+func (s *Sheet) putPivot(p *Pivot) {249+ s.recordPivot()250+ s.pivot.def = p251+ s.pivot.stale = true252+}253+254+// recordPivot saves the sheet's pivot before its first change in the255+// open step.256+func (s *Sheet) recordPivot() {257+ if st := s.wb.hist.open; st != nil {258+ if _, seen := st.pivots[s]; !seen {259+ st.pivots[s] = s.pivot.def260+ }261+ }262+}263+264+// pivotsOn returns the sheets whose pivot reads src.265+func (w *Workbook) pivotsOn(src *Sheet) []*Sheet {266+ var out []*Sheet267+ for _, s := range w.sheets {268+ if p := s.pivot.def; p != nil && w.Lookup(p.Source) == src {269+ out = append(out, s)270+ }271+ }272+ return out273+}274+275+// renamePivots points pivots reading the sheet with key old at name.276+func (w *Workbook) renamePivots(old, name string) {277+ for _, s := range w.sheets {278+ if p := s.pivot.def; p != nil && formula.SheetKey(p.Source) == old {279+ np := p.clone()280+ np.Source = name281+ s.putPivot(np)282+ }283+ }284+}285+286+// shiftPivots keeps the pivots reading s in step with rows or columns287+// inserted or deleted on it: the range moves or resizes like a range288+// reference, fields on deleted columns go, and a pivot whose whole range289+// was deleted shows #REF!.290+func (s *Sheet) shiftPivots(rows bool, sp formula.Span) {291+ cell, rng := formula.AxisMaps(rows, sp)292+ for _, t := range s.wb.pivotsOn(s) {293+ p := t.pivot.def.clone()294+ if r, ok := rng(p.Range); ok && !p.Lost {295+ p.Range = r296+ } else {297+ p.Lost = true298+ }299+ if !rows {300+ col := func(c int) (int, bool) {301+ to, ok := cell(Addr{Col: c})302+ return to.Col, ok303+ }304+ p.Rows, p.Columns = shiftGroups(p.Rows, col), shiftGroups(p.Columns, col)305+ p.Values = shiftCols(p.Values, func(v *PivotValue) *int { return &v.Col }, col)306+ p.Filters = shiftCols(p.Filters, func(f *PivotFilter) *int { return &f.Col }, col)307+ }308+ if !samePivot(p, t.pivot.def) {309+ t.putPivot(p)310+ }311+ }312+}313+314+func shiftGroups(gs []PivotGroup, col func(int) (int, bool)) []PivotGroup {315+ return shiftCols(gs, func(g *PivotGroup) *int { return &g.Col }, col)316+}317+318+// shiftCols moves the column of each item, dropping those on deleted319+// columns.320+func shiftCols[T any](items []T, at func(*T) *int, col func(int) (int, bool)) []T {321+ var out []T322+ for _, it := range items {323+ c := at(&it)324+ if to, ok := col(*c); ok {325+ *c = to326+ out = append(out, it)327+ }328+ }329+ return out330+}331+332+// FieldName is the header of column col in the pivot's source, or333+// "Column B" when the header is blank.334+func (w *Workbook) FieldName(p Pivot, col int) string {335+ if src := w.Lookup(p.Source); src != nil && !p.Lost {336+ if h := src.displayText(Addr{Col: col, Row: p.Range.From.Row}); h != "" {337+ return h338+ }339+ }340+ return "Column " + ColName(col)341+}342+343+// ValueTitle is the header of a value: its name, or Sheets' "SUM of344+// Sales".345+func (w *Workbook) ValueTitle(p Pivot, v PivotValue) string {346+ if v.Name != "" {347+ return v.Name348+ }349+ return v.Summarize.Title() + " of " + w.FieldName(p, v.Col)350+}351+352+// DefaultSummarize is how a new value summarizes column col, as in353+// Sheets: SUM when the column holds a number, COUNTA otherwise.354+func (w *Workbook) DefaultSummarize(p Pivot, col int) Summarize {355+ if src := w.Lookup(p.Source); src != nil {356+ for row := p.Range.From.Row + 1; row <= p.Range.To.Row; row++ {357+ if src.Value(Addr{Col: col, Row: row}).Kind == Number {358+ return SumBy359+ }360+ }361+ }362+ return CountABy363+}364+365+// PivotFilterValues lists the distinct values of column col of the366+// pivot's source, among the rows its other filters let through, as a367+// filter's values list does.368+func (w *Workbook) PivotFilterValues(p Pivot, col int) []FilterValue {369+ src := w.Lookup(p.Source)370+ if src == nil || p.Lost {371+ return nil372+ }373+ var others []PivotFilter374+ var hidden []string375+ for _, f := range p.Filters {376+ if f.Col == col {377+ hidden = f.Criteria.Hidden378+ } else {379+ others = append(others, f)380+ }381+ }382+ return src.valuesList(p.Range, col, pivotTests(others), hidden, true)383+}384+385+// NewPivot starts a pivot over r on src with nothing chosen yet and both386+// grand totals on, as Sheets' new pivot tables.387+func NewPivot(src *Sheet, r Rect) Pivot {388+ return Pivot{Source: src.name, Range: r, RowTotals: true, ColumnTotals: true}389+}390+391+// FrequencyPivot is a frequency table of column col of r: each distinct392+// value with how many rows have it and their share, most frequent first,393+// as VisiData's Shift+F. It is a pivot like any other; it counts rows394+// rather than values, so blanks are counted too.395+func FrequencyPivot(src *Sheet, r Rect, col int) Pivot {396+ p := NewPivot(src, r)397+ p.Rows = []PivotGroup{{Col: col, Desc: true, SortBy: 1}}398+ p.Values = []PivotValue{399+ {Col: col, Summarize: CountRowsBy, Name: "Count"},400+ {Col: col, Summarize: CountRowsBy, ShowAs: ShowPctTotal, Name: "Percent"},401+ }402+ return p403+}@@ -0,0 +1,69 @@
1+//go:build stress2+3+package sheet_test4+5+import (6+ "testing"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+ "github.com/FelineStateMachine/012/internal/stress"10+)11+12+// BenchmarkPivot is a pivot over a full 8192-row table recomputing after13+// one edit to its source: the edit, the recalculation and the pivot's14+// regrouping and rewriting of its results. The table's columns are an15+// id, a category of 8, a number and one of 1000 items, twice.16+func BenchmarkPivot(b *testing.B) {17+ num := func(col int) sheet.PivotValue { return sheet.PivotValue{Col: col, Summarize: sheet.SumBy} }18+ shapes := []struct {19+ name string20+ make func(src *sheet.Sheet, r sheet.Rect) sheet.Pivot21+ }{22+ {"rows-8", func(src *sheet.Sheet, r sheet.Rect) sheet.Pivot {23+ p := sheet.NewPivot(src, r)24+ p.Rows = []sheet.PivotGroup{{Col: 1}}25+ p.Values = []sheet.PivotValue{num(2), {Col: 3, Summarize: sheet.CountABy}}26+ return p27+ }},28+ {"rows-8x8-cols-8", func(src *sheet.Sheet, r sheet.Rect) sheet.Pivot {29+ p := sheet.NewPivot(src, r)30+ p.Rows = []sheet.PivotGroup{{Col: 1}, {Col: 5}}31+ p.Columns = []sheet.PivotGroup{{Col: 5}}32+ p.Values = []sheet.PivotValue{num(2), {Col: 6, Summarize: sheet.AverageBy}}33+ return p34+ }},35+ {"rows-1000-unique", func(src *sheet.Sheet, r sheet.Rect) sheet.Pivot {36+ p := sheet.NewPivot(src, r)37+ p.Rows = []sheet.PivotGroup{{Col: 3, Desc: true, SortBy: 1}}38+ p.Values = []sheet.PivotValue{num(2), {Col: 7, Summarize: sheet.CountUniqueBy}}39+ return p40+ }},41+ {"frequency-1000", func(src *sheet.Sheet, r sheet.Rect) sheet.Pivot {42+ return sheet.FrequencyPivot(src, r, 3)43+ }},44+ }45+ for _, sh := range shapes {46+ b.Run(sh.name, func(b *testing.B) {47+ src := stress.Table(sheet.MaxRows-1, 8)48+ r := sheet.NewRect(sheet.Addr{}, sheet.Addr{Col: 7, Row: sheet.MaxRows - 1})49+ pv, err := src.Book().CreatePivot(src, r, "", sh.make(src, r))50+ if err != nil {51+ b.Fatal(err)52+ }53+ if pv.PivotError() != "" {54+ b.Fatal(pv.PivotError())55+ }56+ edit := sheet.Addr{Col: 2, Row: 4000}57+ inputs := [2]string{"1234.5", "4321.5"}58+ i := 059+ for b.Loop() {60+ if err := src.Set(edit, inputs[i%2]); err != nil {61+ b.Fatal(err)62+ }63+ i++64+ }65+ out, _ := pv.PivotRange()66+ b.ReportMetric(float64(out.To.Row+1), "rows-out")67+ })68+ }69+}@@ -0,0 +1,497 @@
1+package sheet2+3+import (4+ "bytes"5+ "reflect"6+ "strings"7+ "testing"8+)9+10+// sales is a small table: region, product, date, units, price.11+func sales(t *testing.T) *Workbook {12+ t.Helper()13+ return bookOf(t, page{"Sales", map[string]string{14+ "A1": "Region", "B1": "Product", "C1": "Date", "D1": "Units", "E1": "Price",15+ "A2": "East", "B2": "Pens", "C2": "1/5/2026", "D2": "10", "E2": "$2.50",16+ "A3": "West", "B3": "Ink", "C3": "1/5/2026", "D3": "4", "E3": "$9.00",17+ "A4": "east", "B4": "Ink", "C4": "2/9/2026", "D4": "6", "E4": "$9.00",18+ "A5": "North", "B5": "Pens", "C5": "2/9/2026", "D5": "", "E5": "$2.50",19+ "A6": "West", "B6": "Pens", "C6": "1/5/2026", "D6": "3", "E6": "$2.75",20+ }})21+}22+23+// pivotGrid is what a pivot sheet shows, row by row, cells separated by24+// |, trailing blanks dropped.25+func pivotGrid(s *Sheet) []string {26+ out, ok := s.PivotRange()27+ if !ok {28+ return nil29+ }30+ var rows []string31+ for row := 0; row <= out.To.Row; row++ {32+ var cells []string33+ for col := 0; col <= out.To.Col; col++ {34+ cells = append(cells, s.ShownText(Addr{Col: col, Row: row}))35+ }36+ rows = append(rows, strings.TrimRight(strings.Join(cells, "|"), "|"))37+ }38+ return rows39+}40+41+func checkGrid(t *testing.T, s *Sheet, want ...string) {42+ t.Helper()43+ if got := pivotGrid(s); !reflect.DeepEqual(got, want) {44+ t.Errorf("pivot shows\n %s\nwant\n %s\n(error %q)", strings.Join(got, "\n "), strings.Join(want, "\n "), s.PivotError())45+ }46+}47+48+func newPivot(t *testing.T, w *Workbook, p func(*Pivot)) *Sheet {49+ t.Helper()50+ src := w.Lookup("Sales")51+ used, _ := src.UsedRange()52+ pv := NewPivot(src, used)53+ p(&pv)54+ s, err := w.CreatePivot(src, used, "", pv)55+ if err != nil {56+ t.Fatal(err)57+ }58+ return s59+}60+61+func TestPivotGroupsRows(t *testing.T) {62+ w := sales(t)63+ s := newPivot(t, w, func(p *Pivot) {64+ p.Rows = []PivotGroup{{Col: 0}}65+ p.Values = []PivotValue{{Col: 3, Summarize: SumBy}, {Col: 3, Summarize: CountABy}}66+ })67+ if s.Name() != "Pivot Table 1" || w.Index(s) != 1 {68+ t.Errorf("sheet %q at %d, want Pivot Table 1 after Sales", s.Name(), w.Index(s))69+ }70+ // Text groups ignore case, keeping the first spelling, as Sheets.71+ checkGrid(t, s,72+ "Region|SUM of Units|COUNTA of Units",73+ "East|16|2",74+ "North|0|0",75+ "West|7|2",76+ "Grand Total|23|4")77+ if c := s.Cell(at("A5")); !c.Style.Bold {78+ t.Error("the Grand Total row isn't bold")79+ }80+ // A new pivot sheet's columns fit the results, until set by hand.81+ if s.ColWidth(0) != 13 || s.ColWidth(2) != 17 {82+ t.Errorf("widths %d %d, want 13 17", s.ColWidth(0), s.ColWidth(2))83+ }84+ s.SetColWidth(0, 20)85+ w.Lookup("Sales").Set(at("A2"), "Eastern seaboard region")86+ if s.ColWidth(0) != 20 {87+ t.Errorf("width %d after setting it by hand", s.ColWidth(0))88+ }89+}90+91+func TestPivotFilterValues(t *testing.T) {92+ w := sales(t)93+ s := newPivot(t, w, func(p *Pivot) {94+ p.Filters = []PivotFilter{95+ {Col: 0, Criteria: Criteria{Hidden: []string{"West"}}},96+ {Col: 1, Criteria: Criteria{Hidden: []string{"Ink"}}},97+ }98+ })99+ p, _ := s.Pivot()100+ // Region's values among the rows Product's filter lets through.101+ got := w.PivotFilterValues(p, 0)102+ want := []FilterValue{{"East", 1, true}, {"North", 1, true}, {"West", 1, false}}103+ if !reflect.DeepEqual(got, want) {104+ t.Errorf("values %v, want %v", got, want)105+ }106+}107+108+func TestPivotColumnsAndSubtotals(t *testing.T) {109+ w := sales(t)110+ s := newPivot(t, w, func(p *Pivot) {111+ p.Rows = []PivotGroup{{Col: 1}, {Col: 0}}112+ p.Columns = []PivotGroup{{Col: 2}}113+ p.Values = []PivotValue{{Col: 3, Summarize: SumBy}}114+ })115+ // Dates group by day and show as dates.116+ checkGrid(t, s,117+ "|Date|1/5/2026|2/9/2026|Grand Total",118+ "Product|Region|SUM of Units|SUM of Units|SUM of Units",119+ "Ink|east||6|6",120+ "|West|4||4",121+ "Ink Total||4|6|10",122+ "Pens|East|10||10",123+ "|North||0|0",124+ "|West|3||3",125+ "Pens Total||13|0|13",126+ "Grand Total||17|6|23")127+ p, _ := s.Pivot()128+ p.RowTotals, p.ColumnTotals = false, false129+ if err := s.SetPivot(p, ""); err != nil {130+ t.Fatal(err)131+ }132+ checkGrid(t, s,133+ "|Date|1/5/2026|2/9/2026",134+ "Product|Region|SUM of Units|SUM of Units",135+ "Ink|east||6",136+ "|West|4",137+ "Pens|East|10",138+ "|North||0",139+ "|West|3")140+}141+142+func TestPivotSummaries(t *testing.T) {143+ w := sales(t)144+ var vals []PivotValue145+ for _, f := range Summaries() {146+ vals = append(vals, PivotValue{Col: 4, Summarize: f})147+ }148+ s := newPivot(t, w, func(p *Pivot) {149+ p.Rows = []PivotGroup{{Col: 1}}150+ p.Values = vals151+ })152+ // Sums, averages and extremes keep the column's currency format.153+ checkGrid(t, s,154+ "Product|SUM of Price|COUNTA of Price|COUNT of Price|COUNTUNIQUE of Price|AVERAGE of Price|MAX of Price|MIN of Price",155+ "Ink|$18.00|2|2|1|$9.00|$9.00|$9.00",156+ "Pens|$7.75|3|3|2|$2.58|$2.75|$2.50",157+ "Grand Total|$25.75|5|5|3|$5.15|$9.00|$2.50")158+}159+160+func TestPivotMixedKeysAndBlanks(t *testing.T) {161+ w := bookOf(t, page{"Sales", map[string]string{162+ "A1": "Key", "B1": "N",163+ "A2": "1", "B2": "1",164+ "A3": "'1", "B3": "2",165+ "A4": "TRUE", "B4": "4",166+ "A5": "", "B5": "8",167+ "A6": "b", "B6": "16",168+ "A7": "=1/0", "B7": "32",169+ "A8": "B", "B8": "=1/0",170+ }})171+ s := newPivot(t, w, func(p *Pivot) {172+ p.Rows = []PivotGroup{{Col: 0}}173+ p.Values = []PivotValue{{Col: 1, Summarize: SumBy}, {Col: 1, Summarize: CountBy}}174+ })175+ // Numbers, then text, booleans and errors; blanks last. 1 and "1"176+ // are different keys; an error in the values shows in their sums.177+ checkGrid(t, s,178+ "Key|SUM of N|COUNT of N",179+ "1|1|1",180+ "1|2|1",181+ "b|#DIV/0!|1",182+ "TRUE|4|1",183+ "#DIV/0!|32|1",184+ "(blank)|8|1",185+ "Grand Total|#DIV/0!|6")186+}187+188+func TestPivotSortByValueAndShowAs(t *testing.T) {189+ w := sales(t)190+ s := newPivot(t, w, func(p *Pivot) {191+ p.Rows = []PivotGroup{{Col: 0, Desc: true, SortBy: 1}}192+ p.Values = []PivotValue{{Col: 3, Summarize: SumBy}, {Col: 3, Summarize: SumBy, ShowAs: ShowPctTotal}}193+ })194+ checkGrid(t, s,195+ "Region|SUM of Units|SUM of Units",196+ "East|16|69.57%",197+ "West|7|30.43%",198+ "North|0|0.00%",199+ "Grand Total|23|100.00%")200+ p, _ := s.Pivot()201+ p.Rows[0] = PivotGroup{Col: 0, Desc: true}202+ s.SetPivot(p, "")203+ checkGrid(t, s,204+ "Region|SUM of Units|SUM of Units",205+ "West|7|30.43%",206+ "North|0|0.00%",207+ "East|16|69.57%",208+ "Grand Total|23|100.00%")209+}210+211+func TestPivotFilters(t *testing.T) {212+ w := sales(t)213+ s := newPivot(t, w, func(p *Pivot) {214+ p.Rows = []PivotGroup{{Col: 1}}215+ p.Values = []PivotValue{{Col: 3, Summarize: SumBy}}216+ p.Filters = []PivotFilter{217+ {Col: 0, Criteria: Criteria{Hidden: []string{"North"}}},218+ {Col: 3, Criteria: Criteria{Cond: Condition{Op: CondGreater, Arg: "3"}}},219+ }220+ })221+ checkGrid(t, s,222+ "Product|SUM of Units",223+ "Ink|10",224+ "Pens|10",225+ "Grand Total|20")226+}227+228+func TestPivotFrequency(t *testing.T) {229+ w := sales(t)230+ src := w.Lookup("Sales")231+ r, _ := src.UsedRange()232+ s, err := w.CreatePivot(src, r, "Frequency of Product", FrequencyPivot(src, r, 1))233+ if err != nil {234+ t.Fatal(err)235+ }236+ checkGrid(t, s,237+ "Product|Count|Percent",238+ "Pens|3|60.00%",239+ "Ink|2|40.00%",240+ "Grand Total|5|100.00%")241+ // Blanks are counted too.242+ src.Set(at("B6"), "")243+ checkGrid(t, s,244+ "Product|Count|Percent",245+ "Ink|2|40.00%",246+ "Pens|2|40.00%",247+ "(blank)|1|20.00%",248+ "Grand Total|5|100.00%")249+}250+251+func TestPivotLiveUndoAndReads(t *testing.T) {252+ w := sales(t)253+ s := newPivot(t, w, func(p *Pivot) {254+ p.Rows = []PivotGroup{{Col: 0}}255+ p.Values = []PivotValue{{Col: 3, Summarize: SumBy}}256+ })257+ src := w.Lookup("Sales")258+ // A formula elsewhere reads the pivot's result, and follows it.259+ if err := src.Set(at("G1"), "='Pivot Table 1'!B5*2"); err != nil {260+ t.Fatal(err)261+ }262+ if got := show(t, w, "Sales!G1"); got != "46" {263+ t.Fatalf("G1 = %s, want 46", got)264+ }265+ src.Set(at("D2"), "100")266+ checkGrid(t, s, "Region|SUM of Units", "East|106", "North|0", "West|7", "Grand Total|113")267+ if got := show(t, w, "Sales!G1"); got != "226" {268+ t.Errorf("G1 = %s after the edit, want 226", got)269+ }270+ // Rows inserted inside the source range grow it.271+ if err := src.InsertRows(5, 1); err != nil {272+ t.Fatal(err)273+ }274+ src.Set(at("A6"), "South")275+ src.Set(at("D6"), "1")276+ checkGrid(t, s, "Region|SUM of Units", "East|106", "North|0", "South|1", "West|7", "Grand Total|114")277+278+ for range 4 {279+ w.Undo()280+ }281+ checkGrid(t, s, "Region|SUM of Units", "East|16", "North|0", "West|7", "Grand Total|23")282+ if got := show(t, w, "Sales!G1"); got != "46" {283+ t.Errorf("G1 = %s after undo, want 46", got)284+ }285+ // Undoing the pivot's creation removes its sheet; redo brings it back.286+ w.Undo()287+ w.Undo()288+ if w.Lookup("Pivot Table 1") != nil {289+ t.Fatal("undo left the pivot sheet")290+ }291+ w.Redo()292+ checkGrid(t, w.Lookup("Pivot Table 1"), "Region|SUM of Units", "East|16", "North|0", "West|7", "Grand Total|23")293+}294+295+func TestPivotEditsAreOneStep(t *testing.T) {296+ w := sales(t)297+ s := newPivot(t, w, func(p *Pivot) { p.Rows = []PivotGroup{{Col: 0}} })298+ p, _ := s.Pivot()299+ p.Values = []PivotValue{{Col: 3, Summarize: MaxBy}}300+ if err := s.SetPivot(p, "add a value"); err != nil {301+ t.Fatal(err)302+ }303+ checkGrid(t, s, "Region|MAX of Units", "East|10", "North|0", "West|4", "Grand Total|10")304+ c, ok := w.Undo()305+ if !ok || c.Label != "add a value" {306+ t.Fatalf("undo %q, want add a value", c.Label)307+ }308+ checkGrid(t, s, "Region", "East", "North", "West")309+}310+311+func TestPivotRefusesEdits(t *testing.T) {312+ w := sales(t)313+ s := newPivot(t, w, func(p *Pivot) {314+ p.Rows = []PivotGroup{{Col: 0}}315+ p.Values = []PivotValue{{Col: 3, Summarize: SumBy}}316+ })317+ if err := s.Set(at("B2"), "5"); err != ErrPivotEdit {318+ t.Errorf("Set in the results: %v, want ErrPivotEdit", err)319+ }320+ if !s.InPivot(NewRect(at("C4"), at("B9"))) || s.InPivot(NewRect(at("C1"), at("D9"))) {321+ t.Error("InPivot is wrong about the results' range B1:B5")322+ }323+ // Next to the results is fine; a result growing into it is an error324+ // the pivot shows, as in Sheets.325+ if err := s.Set(at("A6"), "note"); err != nil {326+ t.Fatal(err)327+ }328+ src := w.Lookup("Sales")329+ src.InsertRows(5, 1)330+ src.Set(at("A6"), "South")331+ if got := show(t, w, "'Pivot Table 1'!A1"); got != "#REF!" || !strings.Contains(s.ExplainError(at("A1")), "overwrite data in A6") {332+ t.Errorf("A1 = %s (%q), want #REF! explaining the overlap", got, s.ExplainError(at("A1")))333+ }334+ if got := show(t, w, "'Pivot Table 1'!A6"); got != "note" {335+ t.Errorf("A6 = %q, want the note kept", got)336+ }337+ // Clearing the note makes room again.338+ if err := s.Set(at("A6"), ""); err != nil {339+ t.Fatal(err)340+ }341+ checkGrid(t, s, "Region|SUM of Units", "East|16", "North|0", "South|0", "West|7", "Grand Total|23")342+ w.Undo()343+ // Copying results pastes their values.344+ w.Undo()345+ w.Undo()346+ clip := s.Copy(NewRect(at("A2"), at("B2")))347+ if _, err := src.Paste(clip, NewRect(at("H1"), at("H1")), false); err != nil {348+ t.Fatal(err)349+ }350+ if got := show(t, w, "Sales!input:I1"); got != "16" || src.Cell(at("I1")).derived {351+ t.Errorf("pasted %q, want the plain value 16", got)352+ }353+}354+355+func TestPivotFollowsSourceSheet(t *testing.T) {356+ w := sales(t)357+ s := newPivot(t, w, func(p *Pivot) {358+ p.Rows = []PivotGroup{{Col: 1}}359+ p.Values = []PivotValue{{Col: 3, Summarize: SumBy}}360+ })361+ src := w.Lookup("Sales")362+ if err := w.RenameSheet(src, "Q1"); err != nil {363+ t.Fatal(err)364+ }365+ if p, _ := s.Pivot(); p.Source != "Q1" {366+ t.Errorf("source %q after renaming, want Q1", p.Source)367+ }368+ // A column inserted before the data moves the fields along.369+ if err := src.InsertCols(0, 1); err != nil {370+ t.Fatal(err)371+ }372+ p, _ := s.Pivot()373+ if p.Range != NewRect(at("B1"), at("F6")) || p.Rows[0].Col != 2 || p.Values[0].Col != 4 {374+ t.Errorf("after inserting a column: %+v", p)375+ }376+ checkGrid(t, s, "Product|SUM of Units", "Ink|10", "Pens|13", "Grand Total|23")377+ // Deleting the value column drops the value.378+ src.DeleteCols(4, 1)379+ checkGrid(t, s, "Product", "Ink", "Pens")380+ // Deleting the source sheet shows #REF!, and undo brings it back.381+ if err := w.DeleteSheet(src); err != nil {382+ t.Fatal(err)383+ }384+ if got := show(t, w, "'Pivot Table 1'!A1"); got != "#REF!" || !strings.Contains(s.PivotError(), "doesn't exist") {385+ t.Errorf("A1 = %s (%q) without the source", got, s.PivotError())386+ }387+ w.Undo()388+ checkGrid(t, s, "Product", "Ink", "Pens")389+}390+391+func TestPivotRefusesLoops(t *testing.T) {392+ w := sales(t)393+ a := newPivot(t, w, func(p *Pivot) { p.Rows = []PivotGroup{{Col: 0}} })394+ b, err := w.CreatePivot(a, NewRect(at("A1"), at("A4")), "", NewPivot(a, NewRect(at("A1"), at("A4"))))395+ if err != nil {396+ t.Fatal(err)397+ }398+ p, _ := a.Pivot()399+ p.Source = b.Name()400+ if err := a.SetPivot(p, ""); err != errPivotLoop {401+ t.Errorf("a loop: %v", err)402+ }403+ p.Source = a.Name()404+ if err := a.SetPivot(p, ""); err != errPivotSelf {405+ t.Errorf("its own sheet: %v", err)406+ }407+}408+409+func TestPivotFileRoundTrip(t *testing.T) {410+ w := sales(t)411+ s := newPivot(t, w, func(p *Pivot) {412+ p.Rows = []PivotGroup{{Col: 0, Desc: true, SortBy: 1}}413+ p.Columns = []PivotGroup{{Col: 1}}414+ p.Values = []PivotValue{{Col: 3, Summarize: AverageBy, ShowAs: ShowPctRow, Name: "Share"}}415+ p.Filters = []PivotFilter{{Col: 2, Criteria: Criteria{Hidden: []string{"2/9/2026"}, Cond: Condition{Op: CondNotEmpty}}}}416+ p.ColumnTotals = false417+ })418+ want := pivotGrid(s)419+ var buf bytes.Buffer420+ if err := w.Write(&buf); err != nil {421+ t.Fatal(err)422+ }423+ text := buf.String()424+ if !strings.Contains(text, `"version": 5`) || !strings.Contains(text, `"pivot": {"source":"Sales!A1:E6"`) {425+ t.Fatalf("file:\n%s", text)426+ }427+ if strings.Contains(text, `"Grand Total"`) {428+ t.Error("the pivot's results were saved")429+ }430+ w2, err := ReadBook(strings.NewReader(text))431+ if err != nil {432+ t.Fatal(err)433+ }434+ s2 := w2.Lookup("Pivot Table 1")435+ p1, _ := s.Pivot()436+ p2, _ := s2.Pivot()437+ if !reflect.DeepEqual(p1, p2) {438+ t.Errorf("read back\n%+v\nwant\n%+v", p2, p1)439+ }440+ if got := pivotGrid(s2); !reflect.DeepEqual(got, want) {441+ t.Errorf("read back shows %q, want %q", got, want)442+ }443+ // Without a pivot, the file stays version 4.444+ w2.DeleteSheet(s2)445+ w2.AddSheet("", 5)446+ buf.Reset()447+ w2.Write(&buf)448+ if !strings.Contains(buf.String(), `"version": 4`) {449+ t.Errorf("without pivots: %.40s", buf.String())450+ }451+}452+453+// The example in docs/files.md reads.454+func TestPivotFileExample(t *testing.T) {455+ file := `{"version": 5, "sheets": [456+ {"name": "Sales", "cells": {"A1": "Region", "B1": "Item", "C1": "Year", "D1": "Units", "A2": "East", "B2": "Pens", "C2": "2026", "D2": "3"}},457+ {"name": "Pivot Table 1", "cells": {},458+ "pivot": {"source":"Sales!A1:D200","rows":[{"column":"B"},{"column":"A","order":"desc","sortBy":1}],"columns":[{"column":"C"}],"values":[{"column":"D","summarize":"sum"},{"column":"D","summarize":"counta","showAs":"percent_of_total","name":"Share"}],"filters":[{"column":"A","hidden":["North"]}],"rowTotals":true,"columnTotals":false}}459+ ]}`460+ w, err := ReadBook(strings.NewReader(file))461+ if err != nil {462+ t.Fatal(err)463+ }464+ s := w.Lookup("Pivot Table 1")465+ checkGrid(t, s,466+ "|Year|2026",467+ "Item|Region|SUM of Units|Share",468+ "Pens|East|3|100.00%",469+ "Pens Total||3|100.00%",470+ "Grand Total||3|100.00%")471+}472+473+func TestPivotDuplicate(t *testing.T) {474+ w := sales(t)475+ s := newPivot(t, w, func(p *Pivot) { p.Rows = []PivotGroup{{Col: 1}} })476+ cp, err := w.DuplicateSheet(s)477+ if err != nil {478+ t.Fatal(err)479+ }480+ checkGrid(t, cp, "Product", "Ink", "Pens")481+ w.Lookup("Sales").Set(at("B2"), "Paper")482+ checkGrid(t, cp, "Product", "Ink", "Paper", "Pens")483+}484+485+func TestPivotTelemetry(t *testing.T) {486+ var got []PivotInfo487+ OnPivot = func(i PivotInfo) { got = append(got, i) }488+ defer func() { OnPivot = nil }()489+ w := sales(t)490+ newPivot(t, w, func(p *Pivot) {491+ p.Rows = []PivotGroup{{Col: 0}, {Col: 1}}492+ p.Values = []PivotValue{{Col: 3, Summarize: SumBy}}493+ })494+ if len(got) != 1 || got[0].Records != 5 || got[0].Groups != 8 || got[0].Failed {495+ t.Errorf("reported %+v", got)496+ }497+}@@ -0,0 +1,444 @@
1+package sheet2+3+import (4+ "cmp"5+ "slices"6+ "strings"7+)8+9+// Summarize is how a pivot value summarizes a group's cells, as Sheets'10+// "Summarize by". Each is one entry of summaries.11+type Summarize uint812+13+const (14+ SumBy Summarize = iota15+ CountABy16+ CountBy17+ CountUniqueBy18+ AverageBy19+ MaxBy20+ MinBy21+ // CountRowsBy counts rows, blank or not. Sheets has no such choice;22+ // frequency tables use it so blank values are counted.23+ CountRowsBy24+ numSummarize25+)26+27+// summary is one way of summarizing: its name in files, its title (the28+// function Sheets names it after), what it does, whether the result29+// keeps the column's number format, and how it's computed.30+type summary struct {31+ name, title, desc string32+ keepsFormat bool33+ result func(a *accum) Value34+}35+36+var summaries = [numSummarize]summary{37+ SumBy: {"sum", "SUM", "Add the numbers", true, func(a *accum) Value {38+ return a.or(num(a.sum))39+ }},40+ CountABy: {"counta", "COUNTA", "Count the cells that aren't blank", false, func(a *accum) Value {41+ return num(float64(a.counta))42+ }},43+ CountBy: {"count", "COUNT", "Count the numbers", false, func(a *accum) Value {44+ return num(float64(a.n))45+ }},46+ CountUniqueBy: {"countunique", "COUNTUNIQUE", "Count the distinct values", false, func(a *accum) Value {47+ return num(float64(len(a.uniq)))48+ }},49+ AverageBy: {"average", "AVERAGE", "Average the numbers", true, func(a *accum) Value {50+ if a.n == 0 {51+ return a.or(ErrDiv0)52+ }53+ return a.or(num(a.sum / float64(a.n)))54+ }},55+ MaxBy: {"max", "MAX", "The largest number", true, func(a *accum) Value {56+ return a.or(num(a.hi))57+ }},58+ MinBy: {"min", "MIN", "The smallest number", true, func(a *accum) Value {59+ return a.or(num(a.lo))60+ }},61+ CountRowsBy: {"rows", "ROWS", "Count the rows, blank or not", false, func(a *accum) Value {62+ return num(float64(a.rows))63+ }},64+}65+66+// Summaries lists the choices of "Summarize by", in Sheets' order.67+func Summaries() []Summarize {68+ return []Summarize{SumBy, CountABy, CountBy, CountUniqueBy, AverageBy, MaxBy, MinBy}69+}70+71+func (f Summarize) String() string { return summaries[f].name }72+func (f Summarize) Title() string { return summaries[f].title }73+func (f Summarize) Desc() string { return summaries[f].desc }74+75+// ParseSummarize is the inverse of Summarize.String.76+func ParseSummarize(name string) (Summarize, bool) {77+ for i, s := range summaries {78+ if s.name == name {79+ return Summarize(i), true80+ }81+ }82+ return SumBy, false83+}84+85+// accum gathers one value of one group: what every summary needs, so a86+// value can be summarized any way without reading the source again.87+type accum struct {88+ rows, counta, n int89+ sum, lo, hi float6490+ err Value // the first error, which SUM and the rest show91+ uniq map[groupKey]struct{}92+}93+94+func (a *accum) add(v Value, unique bool) {95+ a.rows++96+ switch v.Kind {97+ case Empty:98+ return99+ case Number:100+ if a.n == 0 {101+ a.lo, a.hi = v.Num, v.Num102+ }103+ a.lo, a.hi = min(a.lo, v.Num), max(a.hi, v.Num)104+ a.n++105+ a.sum += v.Num106+ case Error:107+ if a.err.Kind == Empty {108+ a.err = v109+ }110+ }111+ a.counta++112+ if unique {113+ if a.uniq == nil {114+ a.uniq = map[groupKey]struct{}{}115+ }116+ a.uniq[keyOf(v, false)] = struct{}{}117+ }118+}119+120+// or returns the first error gathered, if any, else v.121+func (a *accum) or(v Value) Value {122+ if a.err.Kind == Error {123+ return a.err124+ }125+ return v126+}127+128+// groupKey identifies a value for grouping: numbers (dates too) by value,129+// text ignoring case when folded, as Sheets groups "east" with "East".130+// Numbers and text never share a key, so 1 and "1" are two groups. It is131+// a comparable struct, so looking a group up allocates nothing.132+type groupKey struct {133+ kind Kind134+ num float64135+ str string136+}137+138+func keyOf(v Value, fold bool) groupKey {139+ k := groupKey{kind: v.Kind, num: v.Num, str: v.Str}140+ if fold && v.Kind == Text {141+ k.str = strings.ToLower(v.Str)142+ }143+ return k144+}145+146+// pivotNode is a group of source rows: the root holds them all, its147+// children the groups of the first row field, and so on. Each keeps its148+// accumulators by column group (-1 for all of them), then by value.149+type pivotNode struct {150+ label Value151+ format Format152+ kids []*pivotNode153+ byKey map[groupKey]*pivotNode154+ acc map[int][]accum155+}156+157+// colGroup is one combination of column fields' values.158+type colGroup struct {159+ id int160+ labels []Value161+ formats []Format162+}163+164+// colNode finds column groups by each column field's value in turn.165+type colNode struct {166+ kids map[groupKey]*colNode167+ group *colGroup168+}169+170+// pivotCalc is a pivot being computed.171+type pivotCalc struct {172+ w *Workbook173+ p *Pivot174+ src *Sheet175+ root *pivotNode176+ cols []*colGroup // sorted once gathered177+ colRoot colNode178+ unique []bool // by value: whether to gather distinct values179+ formats []Format // by value: the format results show in180+ records int // source rows summarized181+}182+183+func newPivotCalc(w *Workbook, p *Pivot, src *Sheet) *pivotCalc {184+ c := &pivotCalc{w: w, p: p, src: src, root: &pivotNode{}}185+ for _, v := range p.Values {186+ c.unique = append(c.unique, v.Summarize == CountUniqueBy)187+ c.formats = append(c.formats, c.valueFormat(v))188+ }189+ return c190+}191+192+// valueFormat is the format a value's results show in: percent for193+// shares, the column's own for sums, averages, minimums and maximums.194+func (c *pivotCalc) valueFormat(v PivotValue) Format {195+ if v.ShowAs != ShowValue {196+ return Preset(FmtPercent)197+ }198+ if !summaries[v.Summarize].keepsFormat {199+ return Format{}200+ }201+ for row := c.p.Range.From.Row + 1; row <= c.p.Range.To.Row; row++ {202+ if a := (Addr{Col: v.Col, Row: row}); !c.src.cells.get(a).Blank() {203+ return c.src.DisplayFormat(a)204+ }205+ }206+ return Format{}207+}208+209+// gather reads the source rows the filters let through, skipping rows210+// blank across the range, into groups.211+func (c *pivotCalc) gather() {212+ tests := pivotTests(c.p.Filters)213+ r := c.p.Range214+ for row := r.From.Row + 1; row <= r.To.Row; row++ {215+ if c.src.rowBlank(row, r) || !c.src.rowPasses(row, tests) {216+ continue217+ }218+ c.records++219+ col := c.colGroupOf(row)220+ n := c.root221+ c.add(n, col, row)222+ for _, g := range c.p.Rows {223+ n = n.child(c.src, Addr{Col: g.Col, Row: row})224+ c.add(n, col, row)225+ }226+ }227+ c.sortRows(c.root, 0)228+ c.sortCols()229+}230+231+// rowBlank reports whether row is blank across r's columns.232+func (s *Sheet) rowBlank(row int, r Rect) bool {233+ for col := r.From.Col; col <= r.To.Col; col++ {234+ if !s.cells.get(Addr{Col: col, Row: row}).Blank() {235+ return false236+ }237+ }238+ return true239+}240+241+// pivotTests prepares a pivot's filters as a filter's column tests.242+func pivotTests(fs []PivotFilter) []colTest {243+ var tests []colTest244+ for _, f := range fs {245+ if f.Criteria.IsZero() {246+ continue247+ }248+ t := colTest{col: f.Col, cond: f.Criteria.Cond.test()}249+ if len(f.Criteria.Hidden) > 0 {250+ t.hidden = make(map[string]bool, len(f.Criteria.Hidden))251+ for _, h := range f.Criteria.Hidden {252+ t.hidden[h] = true253+ }254+ }255+ tests = append(tests, t)256+ }257+ return tests258+}259+260+// child returns the group of n for the value at a, adding it if new.261+func (n *pivotNode) child(src *Sheet, a Addr) *pivotNode {262+ v := src.Value(a)263+ k := keyOf(v, true)264+ if kid, ok := n.byKey[k]; ok {265+ return kid266+ }267+ if n.byKey == nil {268+ n.byKey = map[groupKey]*pivotNode{}269+ }270+ kid := &pivotNode{label: v, format: src.DisplayFormat(a)}271+ n.byKey[k] = kid272+ n.kids = append(n.kids, kid)273+ return kid274+}275+276+// colGroupOf returns the id of row's column group, or -1 without column277+// fields.278+func (c *pivotCalc) colGroupOf(row int) int {279+ if len(c.p.Columns) == 0 || len(c.p.Values) == 0 {280+ return -1281+ }282+ n := &c.colRoot283+ for _, g := range c.p.Columns {284+ k := keyOf(c.src.Value(Addr{Col: g.Col, Row: row}), true)285+ kid := n.kids[k]286+ if kid == nil {287+ if n.kids == nil {288+ n.kids = map[groupKey]*colNode{}289+ }290+ kid = &colNode{}291+ n.kids[k] = kid292+ }293+ n = kid294+ }295+ if n.group == nil {296+ cg := &colGroup{id: len(c.cols)}297+ for _, g := range c.p.Columns {298+ a := Addr{Col: g.Col, Row: row}299+ cg.labels = append(cg.labels, c.src.Value(a))300+ cg.formats = append(cg.formats, c.src.DisplayFormat(a))301+ }302+ n.group = cg303+ c.cols = append(c.cols, cg)304+ }305+ return n.group.id306+}307+308+// add gathers row's values into n, for its column group and for all.309+func (c *pivotCalc) add(n *pivotNode, col, row int) {310+ if len(c.p.Values) == 0 {311+ return312+ }313+ if n.acc == nil {314+ n.acc = map[int][]accum{}315+ }316+ for _, id := range [2]int{-1, col} {317+ accs := n.acc[id]318+ if accs == nil {319+ accs = make([]accum, len(c.p.Values))320+ n.acc[id] = accs321+ }322+ for i, v := range c.p.Values {323+ accs[i].add(c.src.Value(Addr{Col: v.Col, Row: row}), c.unique[i])324+ }325+ if col == -1 {326+ return327+ }328+ }329+}330+331+// result is value i of n in column group col, as gathered (not yet shown332+// as a share), and false when no row of n is in col.333+func (c *pivotCalc) result(n *pivotNode, col, i int) (Value, bool) {334+ accs, ok := n.acc[col]335+ if !ok {336+ return Value{}, false337+ }338+ return summaries[c.p.Values[i].Summarize].result(&accs[i]), true339+}340+341+// sortRows orders n's children by the row field at depth, then theirs.342+func (c *pivotCalc) sortRows(n *pivotNode, depth int) {343+ if depth >= len(c.p.Rows) {344+ return345+ }346+ g := c.p.Rows[depth]347+ slices.SortStableFunc(n.kids, func(a, b *pivotNode) int {348+ return c.order(g, []Value{a.label}, []Value{b.label}, func(i int) (Value, Value) {349+ va, _ := c.result(a, -1, i)350+ vb, _ := c.result(b, -1, i)351+ return va, vb352+ })353+ })354+ for _, kid := range n.kids {355+ c.sortRows(kid, depth+1)356+ }357+}358+359+// sortCols orders the column groups by each column field in turn.360+func (c *pivotCalc) sortCols() {361+ slices.SortStableFunc(c.cols, func(a, b *colGroup) int {362+ for k, g := range c.p.Columns {363+ d := c.order(g, a.labels[k:k+1], b.labels[k:k+1], func(i int) (Value, Value) {364+ va, _ := c.result(c.root, a.id, i)365+ vb, _ := c.result(c.root, b.id, i)366+ return va, vb367+ })368+ if d != 0 {369+ return d370+ }371+ }372+ return 0373+ })374+}375+376+// order compares two groups by a field's order: by label, as Sheets sorts377+// (blanks last either way), or by a value's total, labels breaking ties.378+func (c *pivotCalc) order(g PivotGroup, a, b []Value, totals func(i int) (Value, Value)) int {379+ sign := 1380+ if g.Desc {381+ sign = -1382+ }383+ if i := g.SortBy - 1; i >= 0 && i < len(c.p.Values) {384+ va, vb := totals(i)385+ if d := compareTotals(va, vb); d != 0 {386+ return sign * d387+ }388+ sign = 1389+ }390+ return labelOrder(a[0], b[0], sign)391+}392+393+// labelOrder compares group labels A to Z (sign 1) or Z to A (-1), blanks394+// last either way.395+func labelOrder(a, b Value, sign int) int {396+ if (a.Kind == Empty) != (b.Kind == Empty) {397+ if a.Kind == Empty {398+ return 1399+ }400+ return -1401+ }402+ return sign * sortCompare(a, b)403+}404+405+// compareTotals orders totals by number, anything else (an error, a406+// group with no rows) below every number.407+func compareTotals(a, b Value) int {408+ an, bn := a.Kind == Number, b.Kind == Number409+ switch {410+ case an && bn:411+ return cmp.Compare(a.Num, b.Num)412+ case an:413+ return 1414+ case bn:415+ return -1416+ }417+ return 0418+}419+420+// shown is value i of n in column group col as the pivot shows it: as421+// gathered, or as a share of its row's, column's or the grand total.422+func (c *pivotCalc) shown(n *pivotNode, col, i int) (Value, bool) {423+ v, ok := c.result(n, col, i)424+ show := c.p.Values[i].ShowAs425+ if !ok || show == ShowValue || v.Kind != Number {426+ return v, ok427+ }428+ var total Value429+ switch show {430+ case ShowPctRow:431+ total, _ = c.result(n, -1, i)432+ case ShowPctColumn:433+ total, _ = c.result(c.root, col, i)434+ default:435+ total, _ = c.result(c.root, -1, i)436+ }437+ switch {438+ case total.Kind != Number:439+ return total, true440+ case total.Num == 0:441+ return ErrDiv0, true442+ }443+ return num(v.Num / total.Num), true444+}@@ -0,0 +1,151 @@
1+package sheet2+3+import (4+ "encoding/json"5+ "fmt"6+7+ "github.com/FelineStateMachine/012/internal/formula"8+)9+10+// filePivot is a pivot table in the file (version 5): its definition, on11+// one line after the cells of its sheet, never its results, which are12+// computed again on loading:13+//14+// "pivot": {"source": "Sales!A1:D200", "rows": [{"column": "B"}],15+// "values": [{"column": "D", "summarize": "sum"}], "rowTotals": true, "columnTotals": true}16+type filePivot struct {17+ Source string `json:"source"`18+ Rows []filePivotGroup `json:"rows,omitempty"`19+ Columns []filePivotGroup `json:"columns,omitempty"`20+ Values []filePivotValue `json:"values,omitempty"`21+ Filters []filePivotFilter `json:"filters,omitempty"`22+ RowTotals bool `json:"rowTotals"`23+ ColumnTotals bool `json:"columnTotals"`24+}25+26+type filePivotGroup struct {27+ Column string `json:"column"`28+ Order string `json:"order,omitempty"` // "desc" for Z to A29+ SortBy int `json:"sortBy,omitempty"` // by the total of this value, from 130+}31+32+type filePivotValue struct {33+ Column string `json:"column"`34+ Summarize string `json:"summarize"`35+ ShowAs string `json:"showAs,omitempty"`36+ Name string `json:"name,omitempty"`37+}38+39+type filePivotFilter struct {40+ Column string `json:"column"`41+ fileCriteria42+}43+44+// hasPivots reports whether any sheet has a pivot table, which needs45+// version 5.46+func (w *Workbook) hasPivots() bool {47+ for _, s := range w.sheets {48+ if s.pivot.def != nil {49+ return true50+ }51+ }52+ return false53+}54+55+func encodePivot(p *Pivot) ([]byte, error) {56+ rng := p.Range.String()57+ if p.Lost {58+ rng = "#REF!"59+ }60+ fp := filePivot{Source: formula.QuoteSheet(p.Source) + "!" + rng, RowTotals: p.RowTotals, ColumnTotals: p.ColumnTotals}61+ groups := func(gs []PivotGroup) []filePivotGroup {62+ var out []filePivotGroup63+ for _, g := range gs {64+ fg := filePivotGroup{Column: ColName(g.Col), SortBy: g.SortBy}65+ if g.Desc {66+ fg.Order = "desc"67+ }68+ out = append(out, fg)69+ }70+ return out71+ }72+ fp.Rows, fp.Columns = groups(p.Rows), groups(p.Columns)73+ for _, v := range p.Values {74+ fp.Values = append(fp.Values, filePivotValue{Column: ColName(v.Col), Summarize: v.Summarize.String(), ShowAs: v.ShowAs.String(), Name: v.Name})75+ }76+ for _, f := range p.Filters {77+ cr := f.Criteria78+ fp.Filters = append(fp.Filters, filePivotFilter{Column: ColName(f.Col),79+ fileCriteria: fileCriteria{Hidden: cr.Hidden, Condition: cr.Cond.Op.String(), Value: cr.Cond.Arg}})80+ }81+ return json.Marshal(fp)82+}83+84+func decodePivot(fp filePivot) (*Pivot, error) {85+ sheet, rest := SplitSheet(fp.Source)86+ if sheet == "" {87+ return nil, fmt.Errorf("pivot source %q names no sheet", fp.Source)88+ }89+ p := &Pivot{Source: sheet, RowTotals: fp.RowTotals, ColumnTotals: fp.ColumnTotals, Lost: rest == "#REF!"}90+ if !p.Lost {91+ r, ok := ParseRange(rest)92+ if !ok {93+ return nil, fmt.Errorf("invalid pivot source %q", fp.Source)94+ }95+ p.Range = r96+ }97+ var err error98+ if p.Rows, err = decodeGroups(fp.Rows); err != nil {99+ return nil, err100+ }101+ if p.Columns, err = decodeGroups(fp.Columns); err != nil {102+ return nil, err103+ }104+ for _, fv := range fp.Values {105+ v, err := decodeValue(fv)106+ if err != nil {107+ return nil, err108+ }109+ p.Values = append(p.Values, v)110+ }111+ for _, ff := range fp.Filters {112+ col, ok := ParseCol(ff.Column)113+ if !ok {114+ return nil, fmt.Errorf("invalid pivot filter column %q", ff.Column)115+ }116+ op, ok := ParseCondOp(ff.Condition)117+ if !ok {118+ return nil, fmt.Errorf("unknown pivot filter condition %q", ff.Condition)119+ }120+ p.Filters = append(p.Filters, PivotFilter{Col: col, Criteria: Criteria{Hidden: ff.Hidden, Cond: Condition{Op: op, Arg: ff.Value}}})121+ }122+ return p, nil123+}124+125+func decodeGroups(fgs []filePivotGroup) ([]PivotGroup, error) {126+ var out []PivotGroup127+ for _, fg := range fgs {128+ col, ok := ParseCol(fg.Column)129+ if !ok {130+ return nil, fmt.Errorf("invalid pivot column %q", fg.Column)131+ }132+ out = append(out, PivotGroup{Col: col, Desc: fg.Order == "desc", SortBy: max(fg.SortBy, 0)})133+ }134+ return out, nil135+}136+137+func decodeValue(fv filePivotValue) (PivotValue, error) {138+ col, ok := ParseCol(fv.Column)139+ if !ok {140+ return PivotValue{}, fmt.Errorf("invalid pivot value column %q", fv.Column)141+ }142+ f, ok := ParseSummarize(fv.Summarize)143+ if !ok {144+ return PivotValue{}, fmt.Errorf("unknown pivot summary %q", fv.Summarize)145+ }146+ show, ok := ParseShowAs(fv.ShowAs)147+ if !ok {148+ return PivotValue{}, fmt.Errorf("unknown pivot show as %q", fv.ShowAs)149+ }150+ return PivotValue{Col: col, Summarize: f, ShowAs: show, Name: fv.Name}, nil151+}@@ -0,0 +1,450 @@
1+package sheet2+3+import (4+ "fmt"5+ "slices"6+ "time"7+)8+9+// A pivot's results are laid out as Sheets lays them out, from A1: header10+// rows (one per column field, naming the field and its values, then one11+// naming the row fields and the values), then a row per group of the row12+// fields, their labels shown where they change, with a subtotal row after13+// each outer group and a Grand Total row when row totals are on. Columns14+// are the row labels, then each column group's values, then the Grand15+// Total column's.16+17+// pivotCell is one cell of a pivot's results.18+type pivotCell struct {19+ a Addr20+ v Value21+ f Format22+ bold bool23+}24+25+// pivotLayout is a pivot's results being laid out.26+type pivotLayout struct {27+ c *pivotCalc28+ cells []pivotCell29+ cols []*colGroup // the column groups shown, nil for the Grand Total30+ lc int // label columns31+ y int // the next row32+ over bool // something fell off the sheet33+}34+35+// blankLabel stands for a group of blank cells, as in Excel.36+const blankLabel = "(blank)"37+38+func (c *pivotCalc) layout() *pivotLayout {39+ l := &pivotLayout{c: c, lc: max(len(c.p.Rows), 1)}40+ if len(c.p.Values) > 0 {41+ if len(c.p.Columns) == 0 {42+ l.cols = []*colGroup{nil}43+ } else {44+ l.cols = slices.Clone(c.cols)45+ if c.p.ColumnTotals {46+ l.cols = append(l.cols, nil)47+ }48+ }49+ }50+ l.header()51+ l.body()52+ return l53+}54+55+func (l *pivotLayout) put(col, row int, v Value, f Format, bold bool) {56+ if col >= MaxCols || row >= MaxRows {57+ l.over = true58+ return59+ }60+ l.cells = append(l.cells, pivotCell{Addr{Col: col, Row: row}, v, f, bold})61+}62+63+func (l *pivotLayout) text(col, row int, s string, bold bool) {64+ l.put(col, row, Value{Kind: Text, Str: s}, Format{}, bold)65+}66+67+// label shows a group's value in its own format, or (blank).68+func (l *pivotLayout) label(col, row int, v Value, f Format, bold bool) {69+ if v.Kind == Empty {70+ l.text(col, row, blankLabel, bold)71+ return72+ }73+ l.put(col, row, v, f, bold)74+}75+76+func (l *pivotLayout) header() {77+ c := l.c78+ nv := len(c.p.Values)79+ if nv > 0 {80+ for k, g := range c.p.Columns {81+ l.text(l.lc-1, l.y, c.w.FieldName(*c.p, g.Col), true)82+ var prev *colGroup83+ for j, cg := range l.cols {84+ x := l.lc + j*nv85+ switch {86+ case cg == nil && k == 0:87+ l.text(x, l.y, "Grand Total", true)88+ case cg == nil:89+ case prev == nil || !sameLabels(prev.labels[:k+1], cg.labels[:k+1]):90+ l.label(x, l.y, cg.labels[k], cg.formats[k], true)91+ }92+ prev = cg93+ }94+ l.y++95+ }96+ }97+ for d, g := range c.p.Rows {98+ l.text(d, l.y, c.w.FieldName(*c.p, g.Col), true)99+ }100+ for j := range l.cols {101+ for i, v := range c.p.Values {102+ l.text(l.lc+j*nv+i, l.y, c.w.ValueTitle(*c.p, v), true)103+ }104+ }105+ l.y++106+}107+108+func sameLabels(a, b []Value) bool {109+ for i := range a {110+ if keyOf(a[i], true) != keyOf(b[i], true) {111+ return false112+ }113+ }114+ return true115+}116+117+func (l *pivotLayout) body() {118+ c := l.c119+ if len(c.p.Rows) == 0 {120+ if len(c.p.Values) > 0 {121+ l.total(c.root, "Grand Total", 0)122+ }123+ return124+ }125+ l.rows(c.root, 0)126+ if l.totals() {127+ l.total(c.root, "Grand Total", 0)128+ }129+}130+131+// rows lays out n's groups at depth: a label where each starts, then its132+// groups or, at the last depth, its values.133+func (l *pivotLayout) rows(n *pivotNode, depth int) {134+ last := depth == len(l.c.p.Rows)-1135+ for _, kid := range n.kids {136+ l.label(depth, l.y, kid.label, kid.format, false)137+ if last {138+ l.data(kid, false)139+ l.y++140+ continue141+ }142+ l.rows(kid, depth+1)143+ if l.totals() {144+ name := blankLabel145+ if kid.label.Kind != Empty {146+ name = FormatText(kid.label, kid.format)147+ }148+ l.total(kid, name+" Total", depth)149+ }150+ }151+}152+153+// totals reports whether total rows show: with row totals on, and values154+// to total.155+func (l *pivotLayout) totals() bool { return l.c.p.RowTotals && len(l.c.p.Values) > 0 }156+157+// total is a bold row of n's values labelled name in column col.158+func (l *pivotLayout) total(n *pivotNode, name string, col int) {159+ l.text(col, l.y, name, true)160+ l.data(n, true)161+ l.y++162+}163+164+// data lays out n's values in the current row.165+func (l *pivotLayout) data(n *pivotNode, bold bool) {166+ nv := len(l.c.p.Values)167+ for j, cg := range l.cols {168+ id := -1169+ if cg != nil {170+ id = cg.id171+ }172+ for i := range nv {173+ if v, ok := l.c.shown(n, id, i); ok {174+ l.put(l.lc+j*nv+i, l.y, v, l.c.formats[i], bold)175+ }176+ }177+ }178+}179+180+// PivotInfo describes one pivot recomputation, for telemetry. It holds181+// counts only, never contents.182+type PivotInfo struct {183+ Records int // source rows summarized184+ Groups int // groups of the row fields185+ Cells int // cells of the results186+ Failed bool187+ Duration time.Duration188+}189+190+// OnPivot, when set, is called after every pivot recomputation, like191+// OnRecalc.192+var OnPivot func(PivotInfo)193+194+// maxPivotChain bounds pivots reading pivots that are refreshed in turn195+// after one change.196+const maxPivotChain = 16197+198+// stalePivots returns the sheets whose pivot needs recomputing: its199+// definition changed, a cell in its results was overwritten, or a cell200+// of its source range is being recalculated. It reads the sheets' calc201+// marks, so it runs between affected and evaluate.202+func (w *Workbook) stalePivots() []*Sheet {203+ var out []*Sheet204+ for _, s := range w.sheets {205+ if s.pivot.stale {206+ out = append(out, s)207+ continue208+ }209+ p := s.pivot.def210+ if p == nil {211+ continue212+ }213+ if src := w.Lookup(p.Source); src != nil && src != s && touched(src.calc, p.Range) {214+ out = append(out, s)215+ }216+ }217+ return out218+}219+220+func touched(calc map[Addr]int, r Rect) bool {221+ for a := range calc {222+ if r.Contains(a) {223+ return true224+ }225+ }226+ return false227+}228+229+// allPivots returns every live sheet that has a pivot or was told to230+// recompute one.231+func (w *Workbook) allPivots() []*Sheet {232+ var out []*Sheet233+ for _, s := range w.sheets {234+ if s.pivot.def != nil || s.pivot.stale {235+ out = append(out, s)236+ }237+ }238+ return out239+}240+241+// refreshPivots recomputes the pivots of sheets, then recalculates what242+// reads their results, which may be other pivots.243+func (w *Workbook) refreshPivots(sheets []*Sheet) {244+ var changed []loc245+ for _, s := range sheets {246+ changed = append(changed, w.refreshPivot(s)...)247+ }248+ if len(changed) == 0 {249+ return250+ }251+ if w.pivotDepth >= maxPivotChain {252+ w.Circular = true253+ return254+ }255+ circular := w.Circular256+ w.pivotDepth++257+ w.recalc(changed)258+ w.pivotDepth--259+ w.Circular = w.Circular || circular260+}261+262+// refreshPivot recomputes s's pivot and writes its results, returning the263+// cells that changed.264+func (w *Workbook) refreshPivot(s *Sheet) []loc {265+ var start time.Time266+ if OnPivot != nil {267+ start = time.Now()268+ }269+ s.pivot.stale = false270+ want, calc, err := w.computePivot(s)271+ s.pivot.err, s.pivot.blocked = "", Rect{}272+ if err != nil {273+ // Where the results would go, so clearing a cell in their way274+ // has the pivot try again.275+ s.pivot.blocked = extent(want)276+ s.pivot.err = err.Error()277+ want = []pivotCell{{v: ErrRef, bold: true}}278+ if c := s.cells.get(Addr{}); c != nil && !c.derived && !c.Blank() {279+ want = nil // never write over what the user typed280+ }281+ }282+ s.pivot.out = extent(want)283+ changed := s.writeDerived(want)284+ if s.pivot.fit {285+ s.fitPivot(want)286+ }287+ if OnPivot != nil {288+ info := PivotInfo{Cells: len(want), Failed: err != nil, Duration: time.Since(start)}289+ if calc != nil {290+ info.Records, info.Groups = calc.records, calc.groups()291+ }292+ OnPivot(info)293+ }294+ return changed295+}296+297+// extent is the range from A1 covering cells.298+func extent(cells []pivotCell) Rect {299+ r := Rect{}300+ for _, pc := range cells {301+ r = union(r, Rect{From: pc.a, To: pc.a})302+ }303+ return r304+}305+306+// groups counts the groups of the row fields, at every depth.307+func (c *pivotCalc) groups() int {308+ n := 0309+ var walk func(p *pivotNode)310+ walk = func(p *pivotNode) {311+ n += len(p.kids)312+ for _, k := range p.kids {313+ walk(k)314+ }315+ }316+ walk(c.root)317+ return n318+}319+320+// computePivot works out the cells of s's pivot, or why it can't.321+func (w *Workbook) computePivot(s *Sheet) ([]pivotCell, *pivotCalc, error) {322+ p := s.pivot.def323+ if p == nil || !s.live {324+ return nil, nil, nil325+ }326+ src := w.Lookup(p.Source)327+ switch {328+ case src == nil:329+ return nil, nil, fmt.Errorf("The pivot table's source sheet %s doesn't exist", p.Source)330+ case p.Lost:331+ return nil, nil, fmt.Errorf("The pivot table's source range was deleted")332+ case src == s:333+ return nil, nil, errPivotSelf334+ }335+ for _, col := range p.fieldCols() {336+ if col < p.Range.From.Col || col > p.Range.To.Col {337+ return nil, nil, fmt.Errorf("Column %s is outside the pivot table's source range %s", ColName(col), p.Range)338+ }339+ }340+ c := newPivotCalc(w, p, src)341+ c.gather()342+ l := c.layout()343+ if l.over {344+ return nil, c, fmt.Errorf("The pivot table doesn't fit on a sheet")345+ }346+ for _, pc := range l.cells {347+ if old := s.cells.get(pc.a); old != nil && !old.derived && !old.Blank() {348+ return l.cells, c, fmt.Errorf("The pivot table's results would overwrite data in %s", pc.a)349+ }350+ }351+ return l.cells, c, nil352+}353+354+// fieldCols lists the source columns the pivot's fields read.355+func (p *Pivot) fieldCols() []int {356+ var cols []int357+ for _, g := range slices.Concat(p.Rows, p.Columns) {358+ cols = append(cols, g.Col)359+ }360+ for _, v := range p.Values {361+ cols = append(cols, v.Col)362+ }363+ for _, f := range p.Filters {364+ cols = append(cols, f.Col)365+ }366+ return cols367+}368+369+// writeDerived makes the sheet's derived cells exactly want, touching only370+// cells that differ, outside the undo history (undo restores the source371+// and the definition, and the pivot is recomputed from them). It returns372+// the cells that changed.373+func (s *Sheet) writeDerived(want []pivotCell) []loc {374+ next := make(map[Addr]*Cell, len(want))375+ for _, pc := range want {376+ c := &Cell{Input: derivedInput(pc.v), Value: pc.v, Format: pc.f, derived: true}377+ c.Style.Bold = pc.bold378+ next[pc.a] = c379+ }380+ var changed []loc381+ for a, c := range s.cells.all() {382+ if c.derived && next[a] == nil {383+ s.setDerived(a, nil)384+ changed = append(changed, loc{s, a})385+ }386+ }387+ for a, c := range next {388+ if old := s.cells.get(a); old != nil && old.derived && old.Input == c.Input && old.Value == c.Value &&389+ old.Format == c.Format && old.Style == c.Style {390+ continue391+ }392+ s.setDerived(a, c)393+ changed = append(changed, loc{s, a})394+ }395+ return changed396+}397+398+// setDerived stores or removes a derived cell without recording it for399+// undo. Derived cells have no formula, so there are no references to400+// index.401+func (s *Sheet) setDerived(a Addr, c *Cell) {402+ s.version++403+ s.unlink(a)404+ if c != nil {405+ s.cells.set(a, c)406+ }407+}408+409+// maxPivotWidth caps how wide fitting makes a pivot's column.410+const maxPivotWidth = 30411+412+// fitPivot widens the result's columns to their widest text, never below413+// the default width. Numbers in the General format count at most414+// maxGeneralFit characters: General shows fewer digits in a narrower415+// column, as an average's 15 digits needn't all show.416+func (s *Sheet) fitPivot(cells []pivotCell) {417+ const maxGeneralFit = 10418+ widest := map[int]int{}419+ for _, pc := range cells {420+ n := len([]rune(s.ShownText(pc.a)))421+ if pc.v.Kind == Number && pc.f.IsZero() {422+ n = min(n, maxGeneralFit)423+ }424+ widest[pc.a.Col] = max(widest[pc.a.Col], n)425+ }426+ for col, w := range widest {427+ s.setWidth(col, clampInt(w+2, DefaultWidth, maxPivotWidth))428+ }429+}430+431+// derivedInput is what the formula bar shows for a result: the value.432+func derivedInput(v Value) string {433+ if v.Kind == Text {434+ return v.Str435+ }436+ return valueInput(v)437+}438+439+// plain returns a copy of c as an ordinary cell: a pivot's result becomes440+// the constant it shows, so copying results pastes values.441+func (c *Cell) plain() *Cell {442+ if c == nil || !c.derived {443+ return c.clone()444+ }445+ p, err := newCell(valueInput(c.Value), c.Format, c.Style, false)446+ if err != nil || p == nil {447+ return &Cell{Input: "'" + c.Value.String(), Value: c.Value, Format: c.Format, Style: c.Style}448+ }449+ return p450+}@@ -38,6 +38,7 @@ func (w *Workbook) recalcAll() {
3838 }3939 w.evaluate()4040 w.observe(true, start, n)41+ w.refreshPivots(w.allPivots())4142 }4243 4344 // Recalculation states of a cell.@@ -52,8 +53,10 @@ const (
5253 func (w *Workbook) recalc(changed []loc) {5354 start := recalcStart()5455 n := w.affected(changed)56+ stale := w.stalePivots()5557 w.evaluate()5658 w.observe(false, start, n)59+ w.refreshPivots(stale)5760 }5861 5962 // affected marks dirty, in each sheet's calc, the changed cells, volatile@@ -178,6 +181,9 @@ func (w *Workbook) evaluate() {
178181 return ErrRef179182 case st != dirty:180183 return c.Value184+ case c.derived: // a pivot's result, set when the pivot was computed185+ s.calc[a] = done186+ return c.Value181187 }182188 s.calc[a] = visiting183189 c.auto = Format{}@@ -37,6 +37,7 @@ type Cell struct {
3737 xrefs []xref // references that name a sheet, e.g. Sheet2!A13838 names []string // names in expr, as keys of Workbook.names3939 volatile bool // expr calls TODAY, NOW, RAND...40+ derived bool // a pivot table's result, owned by the engine: see pivot.go4041 }4142 4243 // IsFormula reports whether the cell holds a formula.@@ -77,7 +78,8 @@ type Sheet struct {
7778 calcGet lookup7879 calcFmt func(string, Addr) Format7980 80- charts []Chart // floating charts, bottom first; see chart.go81+ charts []Chart // floating charts, bottom first; see chart.go82+ pivot pivotState // the sheet's pivot table, if any; see pivot.go8183 8284 view viewState // frozen panes and the filter, see view.go8385 hidden hiddenCache // rows the filter hides, see filter.go@@ -142,6 +144,7 @@ func (s *Sheet) ColWidth(c int) int {
142144 143145 // SetColWidth sets column c's width; w <= 0 resets it to the default.144146 func (s *Sheet) SetColWidth(c, w int) {147+ s.pivot.fit = false // the user's widths win over the pivot's145148 s.change("column width", colRect(c, c), func() { s.setWidth(c, w) })146149 }147150 @@ -202,8 +205,11 @@ func classify(input string) (Node, Format, error) {
202205 // Set stores an entry at a and recalculates affected cells. An empty input203206 // erases the contents but keeps the cell's formatting. A formula that204207 // fails to parse is rejected with a *ParseError and the sheet is left205-// unchanged.208+// unchanged. A pivot table's results can't be set: that's ErrPivotEdit.206209 func (s *Sheet) Set(a Addr, input string) error {210+ if s.InPivot(Rect{From: a, To: a}) {211+ return ErrPivotEdit212+ }207213 var err error208214 s.change("edit "+a.String(), Rect{From: a, To: a}, func() { err = s.put(a, input) })209215 return err@@ -291,10 +297,14 @@ func (c *Cell) setExpr(n Node) {
291297 292298 // place stores c at a (nil blanks it), recording the old cell for undo and293299 // keeping the dependency indexes current. Every cell mutation goes through294-// here.300+// here, except a pivot table writing its results (setDerived). Anything301+// placed over those has the pivot recomputed, which reports it in the way.295302 func (s *Sheet) place(a Addr, c *Cell) {296303 s.version++297304 s.record(a)305+ if s.pivot.def != nil && (s.pivot.out.Contains(a) || s.pivot.err != "" && s.pivot.blocked.Contains(a)) {306+ s.pivot.stale = true307+ }298308 s.unlink(a)299309 if c == nil {300310 return@@ -48,6 +48,10 @@ type Workbook struct {
4848 // the open change, which then recalculates everything: references by4949 // sheet name may now resolve differently.5050 structural bool51+52+ // pivotDepth counts pivots refreshed because a pivot they read53+ // changed, to stop a loop; see pivotlayout.go.54+ pivotDepth int5155 }5256 5357 // loc is a cell on a particular sheet.@@ -219,6 +223,7 @@ func (w *Workbook) DuplicateSheet(s *Sheet) (*Sheet, error) {
219223 cp.view = s.view220224 cp.view.filter = s.view.filter.clone()221225 cp.charts = slices.Clone(s.charts)226+ cp.pivot = pivotState{def: s.pivot.def.clone(), stale: s.pivot.def != nil, out: s.pivot.out, fit: s.pivot.fit}222227 w.change(cp, "duplicate "+s.name, Rect{}, func() {223228 w.recordSheets()224229 w.insert(cp, w.Index(s)+1)@@ -265,6 +270,7 @@ func (w *Workbook) RenameSheet(s *Sheet, name string) error {
265270 if old != formula.SheetKey(name) {266271 w.renameRefs(old, name)267272 }273+ w.renamePivots(old, name)268274 })269275 return nil270276 }@@ -27,9 +27,9 @@ type clipboard struct {
2727 func init() {2828 register(2929 &command{id: "edit.copy", title: "Copy", desc: "Copy the selection", run: func(m *Model) tea.Cmd { return m.copy(false) }},30- &command{id: "edit.cut", title: "Cut", desc: "Cut the selection, to move it where you paste", run: func(m *Model) tea.Cmd { return m.copy(true) }},31- &command{id: "edit.paste", title: "Paste", desc: "Paste into the selection, adjusting references", run: func(m *Model) tea.Cmd { return m.paste(false) }},32- &command{id: "edit.paste_values", title: "Paste values only", desc: "Paste the copied values without formulas", run: func(m *Model) tea.Cmd { return m.paste(true) }},30+ &command{id: "edit.cut", title: "Cut", desc: "Cut the selection, to move it where you paste", edits: (*Model).selection, run: func(m *Model) tea.Cmd { return m.copy(true) }},31+ &command{id: "edit.paste", title: "Paste", desc: "Paste into the selection, adjusting references", edits: (*Model).pasteTarget, run: func(m *Model) tea.Cmd { return m.paste(false) }},32+ &command{id: "edit.paste_values", title: "Paste values only", desc: "Paste the copied values without formulas", edits: (*Model).pasteTarget, run: func(m *Model) tea.Cmd { return m.paste(true) }},3333 )3434 keymap["ctrl+c"] = "edit.copy"3535 keymap["ctrl+x"] = "edit.cut"@@ -138,6 +138,9 @@ func (m *Model) pasteText(content string) bool {
138138 m.fail(sheet.ErrPasteEdge.Error())139139 return true140140 }141+ if m.refusePivot(r) {142+ return true143+ }141144 m.sheet.Batch(sheet.Change{Label: "paste into " + r.String(), Focus: r}, func() error {142145 for i, row := range rows {143146 for j := range width {@@ -29,6 +29,11 @@ type command struct {
2929 // checked, when set, makes the command a setting: menus show a ✓3030 // while it is on.3131 checked func(m *Model) bool32+33+ // edits, when set, is the range of the sheet shown that the command34+ // changes. A command that would change a pivot table's results is35+ // refused, as Sheets refuses (see pivot.go).36+ edits func(m *Model) sheet.Rect3237 }3338 3439 // available reports whether the command can run in m's current state.@@ -141,17 +146,20 @@ func (m *Model) runCommand(id string) tea.Cmd {
141146 // one span times every one of them.142147 span := telemetry.Start("command", slog.String("id", id))143148 defer span.End()149+ if c.edits != nil && m.refusePivot(c.edits(m)) {150+ return nil151+ }144152 return c.run(m)145153 }146154 147155 func init() {148156 register(149- &command{id: "edit", title: "Edit cell", desc: "Edit the active cell's contents", run: (*Model).startEdit},157+ &command{id: "edit", title: "Edit cell", desc: "Edit the active cell's contents", run: (*Model).startEdit, edits: cellTarget},150158 &command{id: "goto", title: "Go to", desc: "Move to a cell address", run: func(m *Model) tea.Cmd {151159 m.openGoto()152160 return nil153161 }},154- &command{id: "clear", title: "Clear", desc: "Clear the contents of the selected cells", run: func(m *Model) tea.Cmd {162+ &command{id: "clear", title: "Clear", desc: "Clear the contents of the selected cells", edits: (*Model).selection, run: func(m *Model) tea.Cmd {155163 m.sheet.EraseRange(m.selection())156164 m.changed = true157165 return nil@@ -219,7 +219,7 @@ func TestFilterButtonClick(t *testing.T) {
219219 t.Fatalf("button at %d in %q", x, line(m, headerLine))220220 }221221 click(m, x, headerLine, 0)222- if p, ok := m.overlay.(*filterPicker); !ok || p.col != 1 {222+ if p, ok := m.overlay.(*filterPicker); !ok || !strings.HasPrefix(p.title, "Filter B") {223223 t.Fatalf("overlay %T", m.overlay)224224 }225225 // Clicking a value toggles it; clicking outside cancels.@@ -20,40 +20,40 @@ func init() {
2020 register(2121 &command{id: "edit.undo", title: "Undo", desc: "Undo the last change", run: (*Model).undo},2222 &command{id: "edit.redo", title: "Redo", desc: "Redo the last undone change", run: (*Model).redo},23- &command{id: "edit.fill_down", title: "Fill down", desc: "Copy the top row of the selection into the rows below it", run: func(m *Model) tea.Cmd {23+ &command{id: "edit.fill_down", title: "Fill down", desc: "Copy the top row of the selection into the rows below it", edits: (*Model).selection, run: func(m *Model) tea.Cmd {2424 return m.fill(m.sheet.FillDown)2525 }},26- &command{id: "edit.fill_right", title: "Fill right", desc: "Copy the left column of the selection into the columns right of it", run: func(m *Model) tea.Cmd {26+ &command{id: "edit.fill_right", title: "Fill right", desc: "Copy the left column of the selection into the columns right of it", edits: (*Model).selection, run: func(m *Model) tea.Cmd {2727 return m.fill(m.sheet.FillRight)2828 }},29- &command{id: "insert.row_above", title: "Insert rows above", desc: "Insert as many rows above the selection as it spans", run: func(m *Model) tea.Cmd {29+ &command{id: "insert.row_above", title: "Insert rows above", desc: "Insert as many rows above the selection as it spans", edits: rowsTarget, run: func(m *Model) tea.Cmd {3030 r := m.selection()3131 return m.structural(m.sheet.InsertRows(r.From.Row, r.To.Row-r.From.Row+1))3232 }},33- &command{id: "insert.row_below", title: "Insert rows below", desc: "Insert as many rows below the selection as it spans", run: func(m *Model) tea.Cmd {33+ &command{id: "insert.row_below", title: "Insert rows below", desc: "Insert as many rows below the selection as it spans", edits: rowsBelowTarget, run: func(m *Model) tea.Cmd {3434 r := m.selection()3535 if r.To.Row == sheet.MaxRows-1 {3636 return m.structural(sheet.ErrPushedOff)3737 }3838 return m.structural(m.sheet.InsertRows(r.To.Row+1, r.To.Row-r.From.Row+1))3939 }},40- &command{id: "insert.col_left", title: "Insert columns left", desc: "Insert as many columns left of the selection as it spans", run: func(m *Model) tea.Cmd {40+ &command{id: "insert.col_left", title: "Insert columns left", desc: "Insert as many columns left of the selection as it spans", edits: colsTarget, run: func(m *Model) tea.Cmd {4141 r := m.selection()4242 return m.structural(m.sheet.InsertCols(r.From.Col, r.To.Col-r.From.Col+1))4343 }},44- &command{id: "insert.col_right", title: "Insert columns right", desc: "Insert as many columns right of the selection as it spans", run: func(m *Model) tea.Cmd {44+ &command{id: "insert.col_right", title: "Insert columns right", desc: "Insert as many columns right of the selection as it spans", edits: colsRightTarget, run: func(m *Model) tea.Cmd {4545 r := m.selection()4646 if r.To.Col == sheet.MaxCols-1 {4747 return m.structural(sheet.ErrPushedOff)4848 }4949 return m.structural(m.sheet.InsertCols(r.To.Col+1, r.To.Col-r.From.Col+1))5050 }},51- &command{id: "delete.row", title: "Delete rows", desc: "Delete the selected rows; references to them become #REF!", run: func(m *Model) tea.Cmd {51+ &command{id: "delete.row", title: "Delete rows", desc: "Delete the selected rows; references to them become #REF!", edits: rowsTarget, run: func(m *Model) tea.Cmd {5252 r := m.selection()5353 m.sheet.DeleteRows(r.From.Row, r.To.Row-r.From.Row+1)5454 return nil5555 }},56- &command{id: "delete.col", title: "Delete columns", desc: "Delete the selected columns; references to them become #REF!", run: func(m *Model) tea.Cmd {56+ &command{id: "delete.col", title: "Delete columns", desc: "Delete the selected columns; references to them become #REF!", edits: colsTarget, run: func(m *Model) tea.Cmd {5757 r := m.selection()5858 m.sheet.DeleteCols(r.From.Col, r.To.Col-r.From.Col+1)5959 return nil@@ -37,6 +37,9 @@ type entry struct {
3737 }3838 3939 func (m *Model) startEntry(md mode, text string) {40+ if m.refusePivot(sheet.Rect{From: m.cur, To: m.cur}) {41+ return42+ }4043 m.mode = md4144 m.entry.home = nil4245 m.line.clear()@@ -47,7 +50,7 @@ func (m *Model) startEntry(md mode, text string) {
4750 // startEdit edits the active cell's contents with the caret at the end.4851 func (m *Model) startEdit() tea.Cmd {4952 m.startEntry(modeEdit, "")50- if c := m.sheet.Cell(m.cur); c != nil {53+ if c := m.sheet.Cell(m.cur); c != nil && m.mode == modeEdit {5154 m.line.set(c.Input)5255 }5356 return nil@@ -72,7 +72,7 @@ func (m *Model) dragFillTo(a sheet.Addr) {
7272 func (m *Model) finishFill() {7373 m.mouse.drag = dragNone7474 src, dst := m.selection(), m.mouse.fillTo75- if dst == src {75+ if dst == src || m.refusePivot(dst) {7676 return7777 }7878 span := telemetry.Start("fill", slog.Int("cells", (dst.To.Row-dst.From.Row+1)*(dst.To.Col-dst.From.Col+1)))@@ -94,16 +94,23 @@ func (g *grid) filterButtonX(c int) int {
9494 return g.colStart(c) + (w-lw)/2 + lw - 19595 }9696 97-// filterPicker is the values list and condition for one column.97+// filterPicker is the values list and condition for one column: of the98+// sheet's filter, or of a pivot table's (pivoteditor.go), which set what99+// applying and cancelling do.98100 type filterPicker struct {99- col int100- values []sheet.FilterValue101- checked map[string]bool102- cond sheet.Condition103- field int // 0 is the search, 1 the condition's value104- fields [2]string // the text of each field105- shown []int // values matching the search, best first106- list // over the rows: "Select all", then shown101+ title string102+ x int // where the box goes, e.g. over its column103+ onApply func(m *Model, cr sheet.Criteria)104+ // onCancel, when set, runs after Esc or a click outside closes the105+ // picker.106+ onCancel func(m *Model)107+ values []sheet.FilterValue108+ checked map[string]bool109+ cond sheet.Condition110+ field int // 0 is the search, 1 the condition's value111+ fields [2]string // the text of each field112+ shown []int // values matching the search, best first113+ list // over the rows: "Select all", then shown107114 }108115 109116 const filterID = "filter"@@ -118,8 +125,19 @@ func (m *Model) openFilterPicker(col int) {
118125 if !on || col < r.From.Col || col > r.To.Col {119126 return120127 }121- f := m.sheet.Filter()122- p := &filterPicker{col: col, values: m.sheet.FilterValues(col), checked: map[string]bool{}, cond: f.Cols[col].Cond}128+ title := "Filter " + sheet.ColName(col)129+ if h := m.sheet.ShownText(sheet.Addr{Col: col, Row: r.From.Row}); h != "" {130+ title += " " + h131+ }132+ m.openValuesPicker(title, m.colStart(col), m.sheet.FilterValues(col), m.sheet.Filter().Cols[col].Cond,133+ func(m *Model, cr sheet.Criteria) { m.filterColumn(col, cr) })134+}135+136+// openValuesPicker opens a filter picker titled title at screen column x137+// over values, with cond as the condition, calling apply with the138+// criteria chosen.139+func (m *Model) openValuesPicker(title string, x int, values []sheet.FilterValue, cond sheet.Condition, apply func(*Model, sheet.Criteria)) *filterPicker {140+ p := &filterPicker{title: title, x: x, onApply: apply, values: values, checked: map[string]bool{}, cond: cond}123141 for _, v := range p.values {124142 p.checked[v.Text] = v.Shown125143 }@@ -127,6 +145,15 @@ func (m *Model) openFilterPicker(col int) {
127145 m.openOverlay(p)128146 m.line.clear()129147 p.search()148+ return p149+}150+151+// close closes the picker without applying it.152+func (p *filterPicker) close(m *Model) {153+ m.closeOverlay()154+ if p.onCancel != nil {155+ p.onCancel(m)156+ }130157 }131158 132159 func (p *filterPicker) indicator() string { return "FILTER" }@@ -218,12 +245,17 @@ func (p *filterPicker) apply(m *Model) {
218245 cr.Cond = sheet.Condition{}219246 }220247 m.closeOverlay()248+ p.onApply(m, cr)249+}250+251+// filterColumn sets the criteria of column col of the sheet's filter.252+func (m *Model) filterColumn(col int, cr sheet.Criteria) {221253 span := telemetry.Start("filter")222- m.sheet.FilterColumn(p.col, cr)254+ m.sheet.FilterColumn(col, cr)223255 span.End(slog.Int("hidden", m.sheet.HiddenRows()))224256 m.changed = true225257 if n := m.sheet.HiddenRows(); n > 0 {226- m.note = "Filtered column " + sheet.ColName(p.col) + ": " + rowCount(n) + " hidden"258+ m.note = "Filtered column " + sheet.ColName(col) + ": " + rowCount(n) + " hidden"227259 } else {228260 m.note = "The filter shows every row"229261 }@@ -240,7 +272,7 @@ func (p *filterPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
240272 n := len(p.shown) + 1241273 switch key := k.String(); {242274 case key == "esc":243- m.closeOverlay()275+ p.close(m)244276 case key == "enter":245277 p.apply(m)246278 case key == "tab" || key == "shift+tab":@@ -274,7 +306,7 @@ func (p *filterPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
274306 func (p *filterPicker) mouse(m *Model, e mouseEvent) tea.Cmd {275307 if e.box != filterID {276308 if e.kind == mousePress {277- m.closeOverlay()309+ p.close(m)278310 }279311 return nil280312 }@@ -343,7 +375,7 @@ func (p *filterPicker) box(m *Model) (x, y, inner int) {
343375 inner = clamp(w+10, 36, 48)344376 inner = min(inner, m.width-2)345377 h := p.rows(m) + filterFirstRow + 1346- x, y = m.clampBox(m.colStart(p.col), gridTop, inner+2, h)378+ x, y = m.clampBox(p.x, gridTop, inner+2, h)347379 return x, min(y, max(m.height-1-h, 0)), inner348380 }349381 @@ -419,12 +451,7 @@ func (p *filterPicker) layout(m *Model) []box {
419451 text += muted.Render(theme.PadLeft(count, len(count)+1)) + base.Render(" ")420452 lines = append(lines, ansi.Truncate(text, inner, ""))421453 }422- title := "Filter " + sheet.ColName(p.col)423- if r, ok := m.sheet.FilterRange(); ok {424- if h := m.sheet.ShownText(sheet.Addr{Col: p.col, Row: r.From.Row}); h != "" {425- title += " " + h426- }427- }454+ title := p.title428455 footer := strconv.Itoa(len(p.shown)) + " of " + strconv.Itoa(len(p.values))429456 return []box{{id: filterID, x: x, y: y, lines: m.th.Frame(inner, title, footer, lines)}}430457 }@@ -58,7 +58,7 @@ var alignments = []struct {
5858 func init() {5959 for _, nf := range numberFormats {6060 f, title := nf.f, nf.title61- register(&command{id: nf.id, title: title, desc: nf.desc, run: func(m *Model) tea.Cmd {61+ register(&command{id: nf.id, title: title, desc: nf.desc, edits: (*Model).selection, run: func(m *Model) tea.Cmd {6262 r := m.selection()6363 m.sheet.SetFormat(r, f)6464 m.formatted(title + " format")@@ -67,7 +67,7 @@ func init() {
6767 }6868 for _, ts := range textStyles {6969 get, title := ts.get, ts.title70- register(&command{id: ts.id, title: title, desc: ts.desc, run: func(m *Model) tea.Cmd {70+ register(&command{id: ts.id, title: title, desc: ts.desc, edits: (*Model).selection, run: func(m *Model) tea.Cmd {7171 var cur sheet.Style7272 if c := m.sheet.Cell(m.cur); c != nil {7373 cur = c.Style@@ -88,7 +88,7 @@ func init() {
8888 }8989 for _, al := range alignments {9090 a, title := al.a, al.title91- register(&command{id: al.id, title: title, desc: al.desc, run: func(m *Model) tea.Cmd {91+ register(&command{id: al.id, title: title, desc: al.desc, edits: (*Model).selection, run: func(m *Model) tea.Cmd {9292 r := m.selection()9393 m.sheet.Batch(sheet.Change{Label: "align " + r.String() + " " + strings.ToLower(title), Focus: r}, func() error {9494 m.sheet.SetStyle(r, func(s *sheet.Style) { s.Align = a })@@ -99,17 +99,17 @@ func init() {
9999 }})100100 }101101 register(102- &command{id: "format.decimals_more", title: "Increase decimal places", desc: "Show one more decimal place", run: func(m *Model) tea.Cmd {102+ &command{id: "format.decimals_more", title: "Increase decimal places", desc: "Show one more decimal place", edits: (*Model).selection, run: func(m *Model) tea.Cmd {103103 m.sheet.AdjustDecimals(m.selection(), 1)104104 m.formatted("One more decimal place")105105 return nil106106 }},107- &command{id: "format.decimals_less", title: "Decrease decimal places", desc: "Show one less decimal place", run: func(m *Model) tea.Cmd {107+ &command{id: "format.decimals_less", title: "Decrease decimal places", desc: "Show one less decimal place", edits: (*Model).selection, run: func(m *Model) tea.Cmd {108108 m.sheet.AdjustDecimals(m.selection(), -1)109109 m.formatted("One less decimal place")110110 return nil111111 }},112- &command{id: "format.clear", title: "Clear formatting", desc: "Reset number formats and text styles, keeping contents", run: func(m *Model) tea.Cmd {112+ &command{id: "format.clear", title: "Clear formatting", desc: "Reset number formats and text styles, keeping contents", edits: (*Model).selection, run: func(m *Model) tea.Cmd {113113 m.sheet.ClearFormatting(m.selection())114114 m.formatted("Formatting cleared")115115 return nil@@ -93,6 +93,7 @@ var menuBar = []menuDef{
9393 {cmd: "data.sort_range", title: "Advanced range sorting options"},9494 }}, sep,9595 {cmd: "data.filter"}, {cmd: "data.filter_column"}, {cmd: "data.filter_remove"}, sep,96+ {cmd: "data.pivot"}, {cmd: "data.pivot_edit"}, {cmd: "data.frequency"}, sep,9697 {cmd: "data.named_ranges"}, {cmd: "data.define_name"}, sep,9798 {cmd: "data.precedents"}, {cmd: "data.dependents"}, sep,9899 {cmd: "jev.refresh"},@@ -0,0 +1,146 @@
1+package ui2+3+import (4+ "log/slog"5+ "strconv"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/telemetry"11+)12+13+// Pivot tables follow Sheets: Data > Pivot table summarizes the14+// selection, or the table around the active cell (its first row naming15+// the fields), on a new sheet named Pivot Table 1, and opens the pivot16+// editor (pivoteditor.go) to pick rows, columns, values and filters. The17+// engine keeps the results live; editing them is refused with a note on18+// the context line, as Sheets refuses. Data > Frequency table counts the19+// values of the active column the same way, as VisiData's Shift+F: a20+// frequency table is a pivot like any other.21+22+func init() {23+ register(24+ &command{id: "data.pivot", title: "Pivot table", desc: "Summarize the selection, or the table around the active cell, on a new sheet",25+ run: (*Model).createPivot},26+ &command{id: "data.pivot_edit", title: "Edit pivot table", desc: "Change the pivot table's rows, columns, values and filters",27+ enabled: func(m *Model) bool { _, ok := m.sheet.Pivot(); return ok },28+ run: func(m *Model) tea.Cmd {29+ m.openPivotEditor(m.sheet.StateID(), nil)30+ return nil31+ }},32+ &command{id: "data.frequency", title: "Frequency table (column stats)", desc: "Count each value of the active column, most frequent first, on a new sheet, as Sheets' Column stats do",33+ run: (*Model).frequency},34+ )35+ // VisiData's Shift+F; plain Shift+F types an F, as in Sheets.36+ keymap["alt+shift+f"] = "data.frequency"37+ keyAliases["alt+F"] = "alt+shift+f"38+}39+40+// refusePivot reports whether r overlaps a pivot table's results, which41+// can't be edited, saying so on the context line.42+func (m *Model) refusePivot(r sheet.Rect) bool {43+ if !m.sheet.InPivot(r) {44+ return false45+ }46+ m.note = sheet.ErrPivotEdit.Error()47+ return true48+}49+50+// Targets of editing commands, for command.edits.51+52+func cellTarget(m *Model) sheet.Rect { return sheet.Rect{From: m.cur, To: m.cur} }53+54+// rowsTarget is the whole rows of the selection and everything below,55+// which inserting or deleting rows moves.56+func rowsTarget(m *Model) sheet.Rect {57+ r := m.selection()58+ return sheet.Rect{From: sheet.Addr{Row: r.From.Row}, To: sheet.Addr{Col: sheet.MaxCols - 1, Row: sheet.MaxRows - 1}}59+}60+61+func rowsBelowTarget(m *Model) sheet.Rect {62+ r := rowsTarget(m)63+ r.From.Row = min(m.selection().To.Row+1, sheet.MaxRows-1)64+ return r65+}66+67+// colsTarget is the whole columns of the selection and everything right68+// of them.69+func colsTarget(m *Model) sheet.Rect {70+ r := m.selection()71+ return sheet.Rect{From: sheet.Addr{Col: r.From.Col}, To: sheet.Addr{Col: sheet.MaxCols - 1, Row: sheet.MaxRows - 1}}72+}73+74+func colsRightTarget(m *Model) sheet.Rect {75+ r := colsTarget(m)76+ r.From.Col = min(m.selection().To.Col+1, sheet.MaxCols-1)77+ return r78+}79+80+// pasteTarget is where Ctrl+V writes: the selection, and at least the81+// clip's size from the active cell.82+func (m *Model) pasteTarget() sheet.Rect {83+ r := m.selection()84+ if c := m.copied.clip; c != nil {85+ cols, rows := c.Size()86+ r.To.Col = min(max(r.To.Col, r.From.Col+cols-1), sheet.MaxCols-1)87+ r.To.Row = min(max(r.To.Row, r.From.Row+rows-1), sheet.MaxRows-1)88+ }89+ return r90+}91+92+// pivotData is the range a new pivot or frequency table reads: the data93+// commands' range, cut to the used cells, and whether it has a header row94+// and at least one row below it.95+func (m *Model) pivotData() (sheet.Rect, bool) {96+ r := m.dataRange()97+ used, ok := m.sheet.UsedRange()98+ if !ok {99+ return r, false100+ }101+ r.To.Row, r.To.Col = min(r.To.Row, used.To.Row), min(r.To.Col, used.To.Col)102+ return r, r.To.Row > r.From.Row && r.To.Col >= r.From.Col103+}104+105+// createPivot starts a pivot table on a new sheet and opens its editor.106+func (m *Model) createPivot() tea.Cmd {107+ r, ok := m.pivotData()108+ if !ok {109+ m.note = "Select the data to summarize first: a row of headers and the rows below it"110+ return nil111+ }112+ src, start := m.sheet, m.sheet.StateID()113+ s, err := m.book().CreatePivot(src, r, "", sheet.NewPivot(src, r))114+ if err != nil {115+ m.fail(err.Error())116+ return nil117+ }118+ m.showSheet(s)119+ m.openPivotEditor(start, src)120+ return nil121+}122+123+// frequency makes a frequency table of the active column on a new sheet.124+func (m *Model) frequency() tea.Cmd {125+ r, ok := m.pivotData()126+ col := m.cur.Col127+ if !ok || col < r.From.Col || col > r.To.Col {128+ m.note = "Move to a column of data first: a header and the values below it"129+ return nil130+ }131+ span := telemetry.Start("frequency", slog.Int("rows", r.To.Row-r.From.Row))132+ defer span.End()133+ p := sheet.FrequencyPivot(m.sheet, r, col)134+ field := m.book().FieldName(p, col)135+ s, err := m.book().CreatePivot(m.sheet, r, "Frequency of "+field, p)136+ if err != nil {137+ m.fail(err.Error())138+ return nil139+ }140+ m.showSheet(s)141+ out, _ := s.PivotRange()142+ distinct := max(out.To.Row-1, 0) // less the header and Grand Total rows143+ m.note = "Counted " + field + ": " + strconv.Itoa(distinct) + plural(distinct, " distinct value", " distinct values") +144+ " " + m.th.KeyHints("Ctrl+Z", "undo")145+ return nil146+}@@ -0,0 +1,189 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// salesTable types a table of sales and leaves A1 active.11+func salesTable(t *testing.T, m *Model) {12+ t.Helper()13+ press(t, m, "Region", "<tab>", "Product", "<tab>", "Units", "<enter>")14+ press(t, m, "East", "<tab>", "Pens", "<tab>", "10", "<enter>")15+ press(t, m, "West", "<tab>", "Ink", "<tab>", "4", "<enter>")16+ press(t, m, "East", "<tab>", "Ink", "<tab>", "6", "<enter>")17+ press(t, m, "<ctrl+home>")18+}19+20+// shows is what the cell at a shows on the sheet shown.21+func shows(m *Model, a string) string { return m.sheet.ShownText(addr(a)) }22+23+func TestPivotCreateAndEdit(t *testing.T) {24+ m := wideModel()25+ salesTable(t, m)26+ m.runCommand("data.pivot")27+ if m.sheet.Name() != "Pivot Table 1" || m.indicator() != "PIVOT" {28+ t.Fatalf("on %q in %s", m.sheet.Name(), m.indicator())29+ }30+ if s := screen(m); !strings.Contains(s, "Pivot table") || !strings.Contains(s, "Data Sheet1!A1:C4") {31+ t.Fatalf("editor not shown:\n%s", s)32+ }33+ // Space on Rows picks a field to add.34+ press(t, m, "<space>")35+ if !strings.Contains(screen(m), "Add to Rows") {36+ t.Fatalf("no field picker:\n%s", screen(m))37+ }38+ press(t, m, "reg", "<enter>")39+ if m.indicator() != "PIVOT" || shows(m, "A1") != "Region" || shows(m, "A2") != "East" {40+ t.Fatalf("after adding Region: %s, A1 %q A2 %q", m.indicator(), shows(m, "A1"), shows(m, "A2"))41+ }42+ // Down to Values, add Units: summed, as it holds numbers.43+ press(t, m, "<down>", "<down>", "<space>", "units", "<enter>")44+ if shows(m, "B1") != "SUM of Units" || shows(m, "B2") != "16" || shows(m, "B4") != "20" {45+ t.Fatalf("values: %q %q %q", shows(m, "B1"), shows(m, "B2"), shows(m, "B4"))46+ }47+ if !strings.Contains(screen(m), "‹ SUM ›") || !strings.Contains(line(m, m.height-1), "summarize by") {48+ t.Errorf("value line or hints missing:\n%s", screen(m))49+ }50+ press(t, m, "<left>")51+ if shows(m, "B1") != "MIN of Units" || shows(m, "B2") != "6" {52+ t.Errorf("after Left: %q %q", shows(m, "B1"), shows(m, "B2"))53+ }54+ press(t, m, "<right>", "s", "s", "s")55+ if shows(m, "B2") != "80.00%" {56+ t.Errorf("show as %% of grand total: %q", shows(m, "B2"))57+ }58+ // Enter keeps it all; each change is a step of its own.59+ press(t, m, "<enter>")60+ if m.mode != modeReady {61+ t.Fatalf("mode %v after Enter", m.mode)62+ }63+ press(t, m, "<ctrl+z>", "<ctrl+z>", "<ctrl+z>")64+ if shows(m, "B2") != "16" || !strings.Contains(line(m, contextLine), "Undid: show as") {65+ t.Errorf("undo: %q, %q", shows(m, "B2"), line(m, contextLine))66+ }67+ // Data > Edit pivot table opens it again.68+ m.runCommand("data.pivot_edit")69+ if m.indicator() != "PIVOT" {70+ t.Errorf("edit reopened %s", m.indicator())71+ }72+}73+74+func TestPivotEditorEscCancelsNew(t *testing.T) {75+ m := wideModel()76+ salesTable(t, m)77+ m.runCommand("data.pivot")78+ press(t, m, "<space>", "prod", "<enter>")79+ press(t, m, "<esc>")80+ if m.book().Len() != 1 || m.sheet.Name() != "Sheet1" || m.mode != modeReady {81+ t.Errorf("after Esc: %d sheets, on %s, mode %v", m.book().Len(), m.sheet.Name(), m.mode)82+ }83+ // Esc in the field picker goes back to the editor.84+ m.runCommand("data.pivot")85+ press(t, m, "<space>", "<esc>")86+ if m.indicator() != "PIVOT" {87+ t.Errorf("Esc in the picker left %s", m.indicator())88+ }89+ // Nothing to summarize far from the data.90+ press(t, m, "<esc>", "<f5>", "H20", "<enter>")91+ m.runCommand("data.pivot")92+ if m.book().Len() != 1 || !strings.Contains(line(m, contextLine), "Select the data") {93+ t.Errorf("pivoted nothing: %q", line(m, contextLine))94+ }95+}96+97+func TestPivotRefusesEditing(t *testing.T) {98+ m := wideModel()99+ salesTable(t, m)100+ m.runCommand("data.pivot")101+ press(t, m, "<space>", "reg", "<enter>", "<enter>")102+ press(t, m, "<down>")103+ for _, keys := range [][]string{{"x"}, {"<delete>"}, {"<enter>"}, {"<ctrl+alt+=>"}, {"<ctrl+b>"}, {"<ctrl+d>"}} {104+ press(t, m, keys...)105+ if m.mode != modeReady || !strings.Contains(line(m, contextLine), "Pivot table results can't be edited") {106+ t.Errorf("%v: mode %v, %q", keys, m.mode, line(m, contextLine))107+ }108+ press(t, m, "<esc>")109+ }110+ if shows(m, "A2") != "East" || m.sheet.Cell(addr("A2")).Style.Bold {111+ t.Errorf("A2 changed: %q", shows(m, "A2"))112+ }113+ // Copying works, and pastes values; beside the results is editable.114+ press(t, m, "<ctrl+c>", "<right>", "<right>", "<ctrl+v>")115+ if input(m, "C2") != "East" {116+ t.Errorf("pasted %q", input(m, "C2"))117+ }118+ press(t, m, "note", "<enter>")119+ if input(m, "C2") != "note" {120+ t.Errorf("typed beside: %q", input(m, "C2"))121+ }122+}123+124+func TestFrequencyTable(t *testing.T) {125+ m := wideModel()126+ salesTable(t, m)127+ press(t, m, "<right>", "<down>", "<alt+F>")128+ if m.sheet.Name() != "Frequency of Product" {129+ t.Fatalf("on %q, %q", m.sheet.Name(), line(m, contextLine))130+ }131+ want := [][]string{{"Product", "Count", "Percent"}, {"Ink", "2", "66.67%"}, {"Pens", "1", "33.33%"}, {"Grand Total", "3", "100.00%"}}132+ for r, row := range want {133+ for c, w := range row {134+ if got := m.sheet.ShownText(sheet.Addr{Col: c, Row: r}); got != w {135+ t.Errorf("%s = %q, want %q", sheet.Addr{Col: c, Row: r}, got, w)136+ }137+ }138+ }139+ if !strings.Contains(line(m, contextLine), "Counted Product: 2 distinct values") {140+ t.Errorf("note %q", line(m, contextLine))141+ }142+ // Live: a change to the data recounts.143+ src := m.book().Sheet(0)144+ src.Set(addr("B4"), "Pens")145+ if got := shows(m, "A2"); got != "Pens" {146+ t.Errorf("after an edit, A2 = %q", got)147+ }148+}149+150+func TestPivotFilterAndSource(t *testing.T) {151+ m := wideModel()152+ salesTable(t, m)153+ m.runCommand("data.pivot")154+ press(t, m, "<space>", "prod", "<enter>")155+ press(t, m, "<down>", "<down>", "<space>", "units", "<enter>")156+ // Filters: add Region, then uncheck West in its values.157+ press(t, m, "<down>", "<space>", "reg", "<enter>", "<space>")158+ if _, ok := m.overlay.(*filterPicker); !ok || !strings.Contains(screen(m), "Filter Region") {159+ t.Fatalf("no values picker:\n%s", screen(m))160+ }161+ press(t, m, "<down>", "<down>", "<space>", "<enter>")162+ if m.indicator() != "PIVOT" || shows(m, "B2") != "6" || shows(m, "A4") != "Grand Total" {163+ t.Errorf("filtered: %s, B2 %q, A4 %q", m.indicator(), shows(m, "B2"), shows(m, "A4"))164+ }165+ if !strings.Contains(screen(m), "1 hidden") {166+ t.Errorf("filter line:\n%s", screen(m))167+ }168+ // The data range is pointed at on its sheet.169+ press(t, m, "<home>", "<space>")170+ if m.mode != modePrompt || m.sheet.Name() != "Sheet1" {171+ t.Fatalf("mode %v on %s", m.mode, m.sheet.Name())172+ }173+ press(t, m, "<shift+up>", "<enter>")174+ p, _ := m.sheet.Pivot()175+ if m.sheet.Name() != "Pivot Table 1" || m.indicator() != "PIVOT" || p.Range.String() != "A1:C3" {176+ t.Errorf("after pointing: %s %s %s", m.sheet.Name(), m.indicator(), p.Range)177+ }178+}179+180+func TestPivotMenus(t *testing.T) {181+ m := wideModel()182+ press(t, m, "<alt+d>")183+ s := screen(m)184+ for _, want := range []string{"Pivot table", "Edit pivot table", "Frequency table (column stats)"} {185+ if !strings.Contains(s, want) {186+ t.Errorf("Data menu lacks %q:\n%s", want, s)187+ }188+ }189+}@@ -0,0 +1,334 @@
1+package ui2+3+import (4+ "slices"5+ "strconv"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// What the pivot editor's keys do to the pivot, and how its fields read.13+14+// fieldName is the source field a field line is about.15+func (e *pivotEditor) fieldName(m *Model, p sheet.Pivot, it pivotItem) string {16+ return m.book().FieldName(p, fieldCol(p, it))17+}18+19+func fieldCol(p sheet.Pivot, it pivotItem) int {20+ switch it.sect {21+ case sectRows:22+ return p.Rows[it.i].Col23+ case sectColumns:24+ return p.Columns[it.i].Col25+ case sectValues:26+ return p.Values[it.i].Col27+ }28+ return p.Filters[it.i].Col29+}30+31+// groups returns the rows or the columns of p, for editing.32+func groups(p *sheet.Pivot, sect int) *[]sheet.PivotGroup {33+ if sect == sectColumns {34+ return &p.Columns35+ }36+ return &p.Rows37+}38+39+// fieldDetail is the right side of a field line: a group's order, a40+// value's summary as a chip to change, a filter's criteria.41+func (e *pivotEditor) fieldDetail(m *Model, p sheet.Pivot, it pivotItem, sel bool) string {42+ dim, chip := m.th.Muted, m.th.KeyChip43+ if sel {44+ dim, chip = m.th.MenuSelected, m.th.MenuSelected45+ }46+ switch it.sect {47+ case sectValues:48+ v := p.Values[it.i]49+ text := chip.Render("‹ " + v.Summarize.Title() + " ›")50+ if v.ShowAs != sheet.ShowValue {51+ text += dim.Render(" " + v.ShowAs.Title())52+ }53+ return text54+ case sectFilters:55+ return dim.Render(criteriaText(p.Filters[it.i].Criteria))56+ }57+ g := (*groups(&p, it.sect))[it.i]58+ return dim.Render(orderText(m, p, g))59+}60+61+// orderText says how a group is ordered: A→Z, or by a value's total.62+func orderText(m *Model, p sheet.Pivot, g sheet.PivotGroup) string {63+ if i := g.SortBy - 1; i >= 0 && i < len(p.Values) {64+ arrow := "↑"65+ if g.Desc {66+ arrow = "↓"67+ }68+ return m.book().ValueTitle(p, p.Values[i]) + " " + arrow69+ }70+ return orderName(g.Desc)71+}72+73+// criteriaText sums up a filter: the values it hides, or its condition.74+func criteriaText(cr sheet.Criteria) string {75+ switch {76+ case cr.Cond.Op != sheet.CondNone && len(cr.Hidden) > 0:77+ return cr.Cond.Op.Title() + ", " + strconv.Itoa(len(cr.Hidden)) + " hidden"78+ case cr.Cond.Op != sheet.CondNone:79+ return cr.Cond.Op.Title() + " " + cr.Cond.Arg80+ case len(cr.Hidden) > 0:81+ return strconv.Itoa(len(cr.Hidden)) + " hidden"82+ }83+ return "showing all"84+}85+86+// act does what Space does on a line.87+func (e *pivotEditor) act(m *Model, it pivotItem) tea.Cmd {88+ switch {89+ case it.kind == itemSource:90+ e.editSource(m)91+ case it.kind == itemSection:92+ e.addField(m, it.sect)93+ case it.kind == itemToggle:94+ e.set(m, "change grand totals", func(p *sheet.Pivot) {95+ if it.i == 0 {96+ p.RowTotals = !p.RowTotals97+ } else {98+ p.ColumnTotals = !p.ColumnTotals99+ }100+ })101+ case it.kind == itemField && it.sect == sectFilters:102+ e.editFilter(m, it.i)103+ case it.kind == itemField:104+ e.adjust(m, it, 1)105+ }106+ return nil107+}108+109+// adjust steps a field's setting d places: a group's order (A→Z, Z→A,110+// then by each value, smallest and largest first), or a value's summary.111+func (e *pivotEditor) adjust(m *Model, it pivotItem, d int) {112+ switch {113+ case it.kind == itemToggle:114+ e.act(m, it)115+ case it.kind != itemField || it.sect == sectFilters:116+ case it.sect == sectValues:117+ e.set(m, "summarize by", func(p *sheet.Pivot) {118+ fs := sheet.Summaries()119+ v := &p.Values[it.i]120+ v.Summarize = fs[wrap(slices.Index(fs, v.Summarize)+d, len(fs))]121+ v.Name = ""122+ })123+ default:124+ e.set(m, "order "+sectNames[it.sect], func(p *sheet.Pivot) {125+ g := &(*groups(p, it.sect))[it.i]126+ n := 2 * (len(p.Values) + 1)127+ k := wrap(2*g.SortBy+boolInt(g.Desc)+d, n)128+ if g.SortBy > len(p.Values) {129+ k = 0130+ }131+ g.SortBy, g.Desc = k/2, k%2 == 1132+ })133+ }134+}135+136+func wrap(i, n int) int { return ((i % n) + n) % n }137+138+func boolInt(b bool) int {139+ if b {140+ return 1141+ }142+ return 0143+}144+145+// cycleShowAs steps a value through Sheets' "Show as" choices.146+func (e *pivotEditor) cycleShowAs(m *Model, it pivotItem) {147+ if it.kind != itemField || it.sect != sectValues {148+ return149+ }150+ e.set(m, "show as", func(p *sheet.Pivot) {151+ all := sheet.ShowAsList()152+ v := &p.Values[it.i]153+ v.ShowAs = all[wrap(slices.Index(all, v.ShowAs)+1, len(all))]154+ v.Name = ""155+ })156+}157+158+// remove takes a field out of its section.159+func (e *pivotEditor) remove(m *Model, it pivotItem) {160+ if it.kind != itemField {161+ return162+ }163+ e.set(m, "remove "+e.fieldName(m, e.pivot(m), it)+" from "+sectNames[it.sect], func(p *sheet.Pivot) {164+ switch it.sect {165+ case sectValues:166+ p.Values = slices.Delete(p.Values, it.i, it.i+1)167+ for _, gs := range [][]sheet.PivotGroup{p.Rows, p.Columns} {168+ for k := range gs {169+ switch {170+ case gs[k].SortBy == it.i+1:171+ gs[k].SortBy, gs[k].Desc = 0, false172+ case gs[k].SortBy > it.i+1:173+ gs[k].SortBy--174+ }175+ }176+ }177+ case sectFilters:178+ p.Filters = slices.Delete(p.Filters, it.i, it.i+1)179+ default:180+ gs := groups(p, it.sect)181+ *gs = slices.Delete(*gs, it.i, it.i+1)182+ }183+ })184+ e.current(m)185+}186+187+// reorder moves a row or column field up or down within its section,188+// which nests the groups differently.189+func (e *pivotEditor) reorder(m *Model, it pivotItem, down bool) {190+ if it.kind != itemField || it.sect == sectFilters {191+ return192+ }193+ p := e.pivot(m)194+ j := it.i - 1195+ if down {196+ j = it.i + 1197+ }198+ if j < 0 || j >= sectLen(p, it.sect) {199+ return200+ }201+ e.set(m, "move "+e.fieldName(m, p, it), func(p *sheet.Pivot) {202+ swap := func(s []sheet.PivotGroup) { s[it.i], s[j] = s[j], s[it.i] }203+ if it.sect == sectValues {204+ p.Values[it.i], p.Values[j] = p.Values[j], p.Values[it.i]205+ return206+ }207+ swap(*groups(p, it.sect))208+ })209+ e.selectItem(m, pivotItem{kind: itemField, sect: it.sect, i: j})210+}211+212+// addField picks a field of the source to add to a section.213+func (e *pivotEditor) addField(m *Model, sect int) {214+ p := e.pivot(m)215+ used := map[int]bool{}216+ switch sect {217+ case sectRows, sectColumns:218+ for _, g := range slices.Concat(p.Rows, p.Columns) {219+ used[g.Col] = true220+ }221+ case sectFilters:222+ for _, f := range p.Filters {223+ used[f.Col] = true224+ }225+ }226+ var items []pickItem227+ for col := p.Range.From.Col; col <= p.Range.To.Col && !p.Lost; col++ {228+ name := m.book().FieldName(p, col)229+ items = append(items, pickItem{230+ title: name, name: len(name), detail: "column " + sheet.ColName(col), off: used[col],231+ desc: "Add " + name + " to " + sectNames[sect],232+ pick: func(m *Model) tea.Cmd {233+ m.closeOverlay()234+ e.add(m, sect, col)235+ e.reopen(m)236+ return nil237+ },238+ })239+ }240+ fp := newPicker(m, "Add to "+sectNames[sect], "Type a field name", 50, items)241+ fp.action = "add"242+ m.openOverlay(&fieldPicker{picker: fp, back: e})243+}244+245+// add puts the field in column col at the end of a section.246+func (e *pivotEditor) add(m *Model, sect, col int) {247+ name := m.book().FieldName(e.pivot(m), col)248+ e.set(m, "add "+name+" to "+sectNames[sect], func(p *sheet.Pivot) {249+ switch sect {250+ case sectValues:251+ p.Values = append(p.Values, sheet.PivotValue{Col: col, Summarize: m.book().DefaultSummarize(*p, col)})252+ case sectFilters:253+ p.Filters = append(p.Filters, sheet.PivotFilter{Col: col})254+ default:255+ gs := groups(p, sect)256+ *gs = append(*gs, sheet.PivotGroup{Col: col})257+ }258+ })259+ e.selectItem(m, pivotItem{kind: itemField, sect: sect, i: sectLen(e.pivot(m), sect) - 1})260+}261+262+// fieldPicker is the picker of fields to add; Esc returns to the editor.263+type fieldPicker struct {264+ *picker265+ back *pivotEditor266+}267+268+func (f *fieldPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {269+ if k.String() == "esc" {270+ m.closeOverlay()271+ f.back.reopen(m)272+ return nil273+ }274+ return f.picker.key(m, k)275+}276+277+func (f *fieldPicker) mouse(m *Model, e mouseEvent) tea.Cmd {278+ if e.box != pickerID && e.kind == mousePress {279+ m.closeOverlay()280+ f.back.reopen(m)281+ return nil282+ }283+ return f.picker.mouse(m, e)284+}285+286+// editFilter opens the values list of filter i, in the manner of a287+// filter's column.288+func (e *pivotEditor) editFilter(m *Model, i int) {289+ p := e.pivot(m)290+ f := p.Filters[i]291+ name := m.book().FieldName(p, f.Col)292+ x, _, _ := e.box(m)293+ fp := m.openValuesPicker("Filter "+name, x, m.book().PivotFilterValues(p, f.Col), f.Criteria.Cond,294+ func(m *Model, cr sheet.Criteria) {295+ e.set(m, "filter "+name, func(p *sheet.Pivot) { p.Filters[i].Criteria = cr })296+ e.reopen(m)297+ })298+ fp.onCancel = e.reopen299+}300+301+// editSource points at the data on its sheet, starting from the current302+// range; fields outside the new range are dropped.303+func (e *pivotEditor) editSource(m *Model) {304+ p := e.pivot(m)305+ src, home := m.book().Lookup(p.Source), m.sheet306+ if src == nil || src == home {307+ e.msg = "The source sheet " + p.Source + " doesn't exist"308+ return309+ }310+ m.closeOverlay()311+ m.showSheet(src)312+ if !p.Lost {313+ m.selectRect(p.Range)314+ }315+ back := func(m *Model) {316+ m.clearSelection()317+ m.showSheet(home)318+ e.reopen(m)319+ }320+ m.openRange("Pivot data:", func(m *Model, r sheet.Rect) tea.Cmd {321+ back(m)322+ e.set(m, "change the pivot's data to "+r.String(), func(p *sheet.Pivot) {323+ p.Source, p.Range, p.Lost = src.Name(), r, false324+ in := func(c int) bool { return c >= r.From.Col && c <= r.To.Col }325+ keep := func(g sheet.PivotGroup) bool { return !in(g.Col) }326+ p.Rows = slices.DeleteFunc(p.Rows, keep)327+ p.Columns = slices.DeleteFunc(p.Columns, keep)328+ p.Values = slices.DeleteFunc(p.Values, func(v sheet.PivotValue) bool { return !in(v.Col) })329+ p.Filters = slices.DeleteFunc(p.Filters, func(f sheet.PivotFilter) bool { return !in(f.Col) })330+ })331+ return nil332+ })333+ m.prompt.onCancel = back334+}@@ -0,0 +1,362 @@
1+package ui2+3+import (4+ "strings"5+6+ tea "charm.land/bubbletea/v2"7+ "charm.land/lipgloss/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/theme"12+)13+14+// pivotEditor is the pivot editor: a panel docked at the right of the15+// grid, in the manner of lazygit's side panels rather than Sheets' GUI16+// sidebar, listing the data range, the Rows, Columns, Values and Filters17+// with their fields, and the grand totals. The results update behind it18+// as it changes; each change is an undo step. Up/Down pick a line, Space19+// acts on it (adds a field to a section, opens a filter's values, flips20+// a toggle), Left/Right change a field's order or summary, Del removes21+// it. Enter keeps the changes; Esc undoes them, removing a pivot just22+// created.23+type pivotEditor struct {24+ start int // the workbook's state before editing, to undo back to25+ back *sheet.Sheet // for a new pivot, the sheet it summarizes, shown again on Esc26+ msg string // why the last change was refused, for the status line27+ list28+}29+30+// Sections of the editor, in order.31+const (32+ sectRows = iota33+ sectColumns34+ sectValues35+ sectFilters36+ numSects37+)38+39+var sectNames = [numSects]string{"Rows", "Columns", "Values", "Filters"}40+41+var sectDescs = [numSects]string{42+ "Group the rows by the values of a field",43+ "Spread the groups across columns by the values of a field",44+ "Summarize a field for each group: sum, count, average...",45+ "Leave out rows by the values of a field",46+}47+48+type pivotItemKind uint849+50+const (51+ itemSource pivotItemKind = iota52+ itemError53+ itemSection54+ itemField55+ itemToggle56+ itemSep57+)58+59+// pivotItem is a line of the editor: a field is item i of section sect,60+// a toggle is i (0 the total row, 1 the total column).61+type pivotItem struct {62+ kind pivotItemKind63+ sect, i int64+}65+66+const pivotEditorID = "pivot"67+68+// openPivotEditor edits the shown sheet's pivot. start is the state Esc69+// returns to; back, for a pivot just created, the sheet to show then.70+func (m *Model) openPivotEditor(start int, back *sheet.Sheet) {71+ e := &pivotEditor{start: start, back: back}72+ e.sel = 2 // the Rows section73+ m.clearSelection()74+ m.openOverlay(e)75+}76+77+func (e *pivotEditor) indicator() string { return "PIVOT" }78+79+func (e *pivotEditor) pivot(m *Model) sheet.Pivot {80+ p, _ := m.sheet.Pivot()81+ return p82+}83+84+// items lists the editor's lines for the pivot as it is now.85+func (e *pivotEditor) items(m *Model) []pivotItem {86+ p := e.pivot(m)87+ items := []pivotItem{{kind: itemSource}}88+ if m.sheet.PivotError() != "" {89+ items = append(items, pivotItem{kind: itemError})90+ }91+ items = append(items, pivotItem{kind: itemSep})92+ for sect := range numSects {93+ items = append(items, pivotItem{kind: itemSection, sect: sect})94+ for i := range sectLen(p, sect) {95+ items = append(items, pivotItem{kind: itemField, sect: sect, i: i})96+ }97+ }98+ return append(items, pivotItem{kind: itemSep}, pivotItem{kind: itemToggle}, pivotItem{kind: itemToggle, i: 1})99+}100+101+func sectLen(p sheet.Pivot, sect int) int {102+ return [numSects]int{len(p.Rows), len(p.Columns), len(p.Values), len(p.Filters)}[sect]103+}104+105+func selectable(it pivotItem) bool { return it.kind != itemSep && it.kind != itemError }106+107+// step moves the highlight d selectable lines, stopping at the ends.108+func (e *pivotEditor) step(items []pivotItem, d int) {109+ for i := e.sel + d; i >= 0 && i < len(items); i += d {110+ if selectable(items[i]) {111+ e.sel = i112+ return113+ }114+ }115+}116+117+// current is the highlighted line, kept on a selectable one.118+func (e *pivotEditor) current(m *Model) pivotItem {119+ items := e.items(m)120+ e.sel = clamp(e.sel, 0, len(items)-1)121+ if !selectable(items[e.sel]) {122+ e.step(items, -1)123+ }124+ return items[e.sel]125+}126+127+// selectItem highlights the line showing it, if any.128+func (e *pivotEditor) selectItem(m *Model, it pivotItem) {129+ for i, x := range e.items(m) {130+ if x == it {131+ e.sel = i132+ }133+ }134+}135+136+// set applies a change to the pivot as its own undo step.137+func (e *pivotEditor) set(m *Model, label string, fn func(p *sheet.Pivot)) {138+ p := e.pivot(m)139+ fn(&p)140+ e.msg = ""141+ if err := m.sheet.SetPivot(p, label); err != nil {142+ e.msg = err.Error()143+ }144+ m.changed = m.sheet.StateID() != m.saved145+}146+147+func (e *pivotEditor) key(m *Model, k tea.KeyPressMsg) tea.Cmd {148+ if _, ok := m.sheet.Pivot(); !ok {149+ m.closeOverlay()150+ return nil151+ }152+ it := e.current(m)153+ switch key := k.String(); key {154+ case "enter":155+ m.closeOverlay()156+ case "esc":157+ e.cancel(m)158+ case "up", "ctrl+p", "shift+tab":159+ e.step(e.items(m), -1)160+ case "down", "ctrl+n", "tab":161+ e.step(e.items(m), 1)162+ case "home", "pgup":163+ e.sel = 0164+ case "end", "pgdown":165+ e.sel = len(e.items(m)) - 1166+ case "shift+up", "shift+down":167+ e.reorder(m, it, key == "shift+down")168+ case "left", "right":169+ e.adjust(m, it, map[bool]int{false: -1, true: 1}[key == "right"])170+ case "space":171+ return e.act(m, it)172+ case "a", "+":173+ if it.kind == itemSection || it.kind == itemField {174+ e.addField(m, it.sect)175+ }176+ case "s":177+ e.cycleShowAs(m, it)178+ case "delete", "backspace", "x", "-":179+ e.remove(m, it)180+ }181+ return nil182+}183+184+// cancel undoes the editor's changes and closes it; a pivot just created185+// goes with its sheet.186+func (e *pivotEditor) cancel(m *Model) {187+ for m.sheet.StateID() != e.start && m.sheet.CanUndo() {188+ m.sheet.Undo()189+ }190+ m.closeOverlay()191+ m.afterSheetsChange(e.back, m.book().Active())192+ m.changed = m.sheet.StateID() != m.saved193+}194+195+// reopen returns to the editor after a picker or prompt.196+func (e *pivotEditor) reopen(m *Model) { m.openOverlay(e) }197+198+func (e *pivotEditor) mouse(m *Model, ev mouseEvent) tea.Cmd {199+ if ev.box != pivotEditorID {200+ if ev.kind == mousePress {201+ m.closeOverlay() // a click elsewhere keeps the changes202+ }203+ return nil204+ }205+ items := e.items(m)206+ i := e.top + ev.row - 1207+ switch {208+ case ev.kind == mouseWheel && ev.button == tea.MouseWheelUp:209+ e.step(items, -1)210+ case ev.kind == mouseWheel && ev.button == tea.MouseWheelDown:211+ e.step(items, 1)212+ case ev.kind != mousePress || ev.button != tea.MouseLeft || i < 0 || i >= len(items) || !selectable(items[i]):213+ case i == e.sel || items[i].kind == itemToggle:214+ e.sel = i215+ return e.act(m, items[i])216+ default:217+ e.sel = i218+ }219+ return nil220+}221+222+// rows is how many lines of the list show.223+func (e *pivotEditor) rows(m *Model, n int) int {224+ return max(min(n, m.height-1-gridTop-2), 1)225+}226+227+// box places the editor at the right of the grid.228+func (e *pivotEditor) box(m *Model) (x, y, inner int) {229+ inner = min(44, m.width-2)230+ return max(m.width-inner-2, 0), gridTop, inner231+}232+233+func (e *pivotEditor) layout(m *Model) []box {234+ x, y, inner := e.box(m)235+ items := e.items(m)236+ e.current(m)237+ rows := e.rows(m, len(items))238+ e.show(rows)239+ p := e.pivot(m)240+ var lines []string241+ for r := range rows {242+ i := e.top + r243+ if i >= len(items) {244+ break245+ }246+ lines = append(lines, e.line(m, p, items[i], i == e.sel, inner))247+ }248+ footer := ""249+ if out, ok := m.sheet.PivotRange(); ok && m.sheet.PivotError() == "" {250+ footer = "results " + out.String()251+ }252+ return []box{{id: pivotEditorID, x: x, y: y, lines: m.th.Frame(inner, "Pivot table", footer, lines)}}253+}254+255+// line draws one line of the editor, inner columns wide.256+func (e *pivotEditor) line(m *Model, p sheet.Pivot, it pivotItem, sel bool, inner int) string {257+ base, dim := m.th.MenuBar, m.th.Muted258+ if sel {259+ base, dim = m.th.MenuSelected, m.th.MenuSelected260+ }261+ var left, right string262+ switch it.kind {263+ case itemSep:264+ return theme.SepRow265+ case itemError:266+ return theme.Cells(m.th.Warning, " "+m.sheet.PivotError(), inner)267+ case itemSource:268+ left = base.Render(" Data ") + e.sourceText(m, p, sel)269+ case itemSection:270+ title := m.th.Title271+ if sel {272+ title = base273+ }274+ left = title.Render(" " + sectNames[it.sect])275+ if sel || sectLen(p, it.sect) == 0 {276+ right = dim.Render("Space add ")277+ }278+ case itemField:279+ left = base.Render(" " + e.fieldName(m, p, it))280+ right = e.fieldDetail(m, p, it, sel) + base.Render(" ")281+ case itemToggle:282+ on := p.RowTotals283+ label := "Grand total row"284+ if it.i == 1 {285+ on, label = p.ColumnTotals, "Grand total column"286+ }287+ check := "[ ] "288+ if on {289+ check = "[x] "290+ }291+ left = base.Render(" " + check + label)292+ }293+ return spreadIn(base, left, right, inner)294+}295+296+// spreadIn lays left and right out in w columns on base, cutting left to297+// make room for right.298+func spreadIn(base lipgloss.Style, left, right string, w int) string {299+ rw := ansi.StringWidth(right)300+ left = ansi.Truncate(left, max(w-rw-1, 1), "…")301+ gap := max(w-ansi.StringWidth(left)-rw, 0)302+ return ansi.Truncate(left+base.Render(strings.Repeat(" ", gap))+right, w, "")303+}304+305+func (e *pivotEditor) sourceText(m *Model, p sheet.Pivot, sel bool) string {306+ text := sheet.QuoteSheet(p.Source) + "!" + p.Range.String()307+ if p.Lost {308+ text = sheet.QuoteSheet(p.Source) + "!#REF!"309+ }310+ if sel {311+ return m.th.MenuSelected.Render(text)312+ }313+ return m.th.Key.Render(text)314+}315+316+func (e *pivotEditor) status(m *Model) (string, string) {317+ it := e.current(m)318+ desc, pairs := e.help(m, it)319+ if e.msg != "" {320+ desc = m.th.Warning.Render(e.msg)321+ }322+ pairs = append(pairs, "Enter", "done", "Esc", "cancel")323+ for {324+ keys := m.th.KeyHints(pairs...)325+ switch {326+ case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:327+ return desc, keys328+ case desc != "":329+ desc = ""330+ case len(pairs) > 6:331+ // Drop the line's last key but one, keeping its first, the332+ // one that matters most, and Enter and Esc.333+ pairs = append(pairs[:len(pairs)-6], pairs[len(pairs)-4:]...)334+ case len(pairs) > 4:335+ pairs = pairs[2:]336+ default:337+ return "", keys338+ }339+ }340+}341+342+// help is what the highlighted line is and the keys that act on it.343+func (e *pivotEditor) help(m *Model, it pivotItem) (string, []string) {344+ p := e.pivot(m)345+ switch it.kind {346+ case itemSource:347+ return "The data summarized; its first row names the fields", []string{"Space", "change"}348+ case itemSection:349+ return sectDescs[it.sect], []string{"Space", "add a field"}350+ case itemToggle:351+ return "Show totals of every group", []string{"Space", "on/off"}352+ }353+ remove := []string{"Del", "remove"}354+ switch it.sect {355+ case sectValues:356+ v := p.Values[it.i]357+ return v.Summarize.Desc(), append([]string{"←/→", "summarize by", "S", "show as"}, remove...)358+ case sectFilters:359+ return "Choose which values count, or a condition", append([]string{"Space", "values"}, remove...)360+ }361+ return "Order the groups by label or by a value", append([]string{"←/→", "order", "Shift+↑/↓", "move"}, remove...)362+}@@ -62,6 +62,9 @@ func (m *Model) sort(r sheet.Rect, keys []sheet.SortKey) tea.Cmd {
6262 m.note = "Nothing to sort"6363 return nil6464 }65+ if m.refusePivot(r) {66+ return nil67+ }6568 span := telemetry.Start("sort", slog.Int("rows", r.To.Row-r.From.Row+1), slog.Int("cols", r.To.Col-r.From.Col+1), slog.Int("keys", len(keys)))6669 m.sheet.SortRange(r, keys)6770 span.End()