cat / 012
clone

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

Merge pivot extras and section 1 follow-ups

Dami committed 9d14c38
commit
9d14c38f764ca621c7011087618bc8bd99e5ffb0
parent
38c0949474b221ecfc09c317a63ff9f29bb69119
parent
feb4e835bb45878cda19e4800e0c9025dd922a3b
author
Dami <dami.etoile@gmail.com>
33 files changed, +716 -182
MROADMAP.md9+-
Mdocs/data.md23++++-
Mdocs/files.md2+-
Mdocs/keys.md4+-
Me2e/import_test.go12+++-
Me2e/pivot_test.go24++++++
Me2e/screenlist_test.go23+++++-
Me2e/testdata/screens/pivot-editor-light.html2+-
Me2e/testdata/screens/pivot-editor-narrow.html2+-
Me2e/testdata/screens/pivot-editor.html2+-
Ae2e/testdata/screens/pivot-rename-light.html30+++++++
Ae2e/testdata/screens/pivot-rename.html30+++++++
Ae2e/testdata/screens/pivot-subtotals-light.html30+++++++
Ae2e/testdata/screens/pivot-subtotals.html30+++++++
Minternal/sheet/clipfmt.go39+++++++++-
Minternal/sheet/insertbook.go50+++++++-----
Minternal/sheet/insertbook_test.go18+++--
Minternal/sheet/linecopy_test.go33+++++++-
Minternal/sheet/pivot.go3+-
Minternal/sheet/pivot_test.go4+-
Minternal/sheet/pivotcalc.go104++++++-------------------
Ainternal/sheet/pivotcols.go126++++++++++++++++++++++++++++++
Ainternal/sheet/pivotcols_test.go76++++++++++++++++++
Minternal/sheet/pivotlayout.go46++++++-----
Minternal/ui/hosts.go10++
Minternal/ui/importplace.go28++++---
Minternal/ui/importplace_test.go17++--
Minternal/ui/linecopy_test.go26++++++
Minternal/ui/pivot_test.go39+++++++++-
Minternal/ui/pivotactions.go32+++++++-
Minternal/ui/pivoteditor.go12++-
Minternal/ui/prompt.go3+
Minternal/ui/rows.go9+-
M ROADMAP.md +4 -5
@@ -110,10 +110,9 @@ work left open. Sizes: S (a day or two), M (about a week), L (weeks).
110110 | Precedent tracing explains when every precedent is on a hidden sheet. Done, for dependents too | A generic "no formula" hides why nothing shows | S |111111 | Settings > JEV API key checks the key with a test call. Done: one fixed question, "Key saved and checked" or why the check failed ([jev.md](docs/jev.md#setup)) | A mistyped key otherwise shows only as `#ERROR!` in cells | S |112112 | `012 serve`: open a file from the ssh command line, and save unsaved work to a recovery file on shutdown or idle timeout. Done: `ssh -t host file.012` (a one-word exec request taken as a file name inside the served directory, never run) and `.012-recovery/`, offered back on the next open ([ssh.md](docs/ssh.md#unsaved-work)) | Sessions reach a file in one step, and stopping the server or idling out keeps work | M |113-114-| Vim `p`/`P` after `yy` carries the rows' line formats, as a whole-row paste does | Only cell formats go with it | S |115-| Cutting a block clears the source cells to plain, as Sheets does | They keep showing their column or row format | S |116-| Replace current sheet removes charts whose range no longer fits the new data, or asks | They stay, pointing at empty cells, with a note | S |113+| Vim `p`/`P` after `yy` carries the rows' line formats, as a whole-row paste does. Done: `yy` and `dd` copy whole rows ([data.md](docs/data.md#copy-paste-and-fill)) | Only cell formats go with it | S |114+| Cutting a block clears the source cells to plain, as Sheets does. Done, over formatted columns, rows and the sheet ([data.md](docs/data.md#copy-paste-and-fill)) | They keep showing their column or row format | S |115+| Replace current sheet removes charts whose range no longer fits the new data, or asks. Done: removed, listed first in the note, and back on undo ([files.md](docs/files.md)) | They stay, pointing at empty cells, with a note | S |117116 118117 ### 2. Spreadsheet features Sheets users reach for119118
@@ -127,7 +126,7 @@ work left open. Sizes: S (a day or two), M (about a week), L (weeks).
127126 | Notes on cells (shown on hover and in the context line). Done: Insert > Note (Shift+F2), a corner mark, the context line and a hover box, saved and in XLSX as Excel's notes; see [data.md](docs/data.md#notes) | | S |128127 | Locale: decimal comma, date order, list separator in formulas | Sheets' File > Settings > Locale | M |129128 | Charts: scatter, area, stacked columns and bars, axis options, a legend position | Registry entries in `internal/chart` | M |130-| Pivot tables: column subtotals, renaming value columns, check the "(blank)" label against Sheets | Left open by pivots | S |129+| Pivot tables: column subtotals, renaming value columns, check the "(blank)" label against Sheets. Done: a subtotal column per outer column group, R renames a value in the editor, and blank groups have a blank label as in Sheets ([data.md](docs/data.md#pivot-tables)) | Left open by pivots | S |131130 | Protected ranges and sheets (warn on edit). Done: Data > Protect sheets and ranges, edits ask on the context line; an XLSX sheet's protection is noted on import; see [data.md](docs/data.md#protected-sheets-and-ranges) | | S |132131 133132 ### 3. Scale (see "Next: vast data" above and docs/limits.md)
M docs/data.md +17 -6
@@ -24,7 +24,10 @@ Shift+Space) copied and pasted at the top of a column, or the start of a
2424 row, take their column or row formats along, so a pasted column is2525 currency all the way down, not only where it had data; cut and pasted,2626 they move them, leaving the source columns plain. Pasted anywhere else,27-they paste as a block of the cells that hold something. Formulas reading27+they paste as a block of the cells that hold something. A block cut and28+pasted leaves the cells it came from plain, as Sheets does, even where29+a formatted column or row crosses them. Vim's `yy` and `dd` copy whole30+rows, so `p` and `P` bring their row formats along. Formulas reading2831 a column whose format changes, blank cells included, show the new format2932 at once (`=B5*2` shows currency when column B becomes currency). Copies also go to the3033 system clipboard as tab-separated text (OSC 52, so it works over SSH), and
@@ -84,15 +87,18 @@ opens docked at the right of the grid, with the results updating behind it:
8487 8588 - **Rows** group the data by a field's values, one row per value; with two8689 or more, the groups nest and each outer group gets a subtotal row.87-- **Columns** spread the groups across columns by a field's values.90+- **Columns** spread the groups across columns by a field's values; with91+ two or more, each outer group gets a subtotal column (`East Total`)92+ after its last column.8893 - **Values** summarize a field for each group: SUM, COUNTA, COUNT,8994 COUNTUNIQUE, AVERAGE, MAX or MIN (Sheets' "Summarize by"), shown as they9095 are or as a share of their row, column or grand total ("Show as").9196 A new value is summed when its field holds numbers, and counted92- otherwise.97+ otherwise. Its header is Sheets' `SUM of Units` until you rename it.9398 - **Filters** leave out rows by a field's values or a condition, in the9499 same picker as a filter's column.95-- **Grand total row** and **Grand total column** add Sheets' totals.100+- **Grand total row** and **Grand total column** add Sheets' totals, and101+ turn the subtotal rows and columns on and off with them.96102 97103 Up and Down pick a line. Space adds a field to the section it's on (pick98104 it from the fields of the data, type to narrow the list), opens a filter's
@@ -101,7 +107,9 @@ its sheet. On a row or column field, Left and Right order its groups A to
101107 Z, Z to A, or by a value's total, smallest or largest first, and102108 Shift+Up and Shift+Down move it before or after the others, which changes103109 how the groups nest. On a value, Left and Right change how it's104-summarized and S how it's shown. Del removes a field. Every change is an110+summarized, S how it's shown, and R (or F2) renames it on the context111+line, the name heading its columns; an empty name goes back to Sheets'.112+A value keeps its name when its summary changes. Del removes a field. Every change is an105113 undo step; Enter keeps them, Esc undoes them, and removes a pivot just106114 created. Data > Edit pivot table opens the editor again.107115
@@ -109,7 +117,10 @@ Groups follow Sheets: text ignoring case (`east` joins `East`), numbers
109117 and dates by value, with the source's format, so dates show as dates.110118 Numbers and text never share a group: `1` and the text `'1` are two.111119 Groups sort numbers first, then text, booleans and errors, and blank112-cells, shown as `(blank)`, last. Rows blank across the whole range are120+cells last; as in Sheets, the group of blank cells has a blank label,121+and its subtotal is just `Total`. Sorting an outer column field by a122+value's total orders whole outer groups, so their columns stay123+together. Rows blank across the whole range are113124 left out, so a range can reach past the data. SUM, AVERAGE, MIN and MAX114125 keep the column's number format; an error in a summed column shows as that115126 error, as SUM would.
M docs/files.md +1 -1
@@ -13,7 +13,7 @@ File > Import asks where the data goes, as Sheets' Import location does
1313 | Location | Does |1414 |---|---|1515 | Insert new sheet(s) | Adds the file's sheets after the sheet shown: every sheet of an `.xlsx`, named after the file for other formats. A name already taken gets a number (`Sales 2`) and the file's formulas follow it; named ranges come along unless their name is taken. One undo step, and the spreadsheet stays the file you're editing |16-| Replace current sheet | Puts the data in place of the sheet shown, keeping its name and position, so formulas and named ranges that read it read the new data. Its charts stay: a chart that drew a whole table is re-pointed to the table the file has at the same corner when it has as many columns (rows for a chart by row), so last month's chart draws this month's rows; any other chart keeps its range. The context line says which chart was re-pointed, which kept its range, and which range is empty now. One undo step. Not offered for `.xlsx`, which holds several sheets |16+| Replace current sheet | Puts the data in place of the sheet shown, keeping its name and position, so formulas and named ranges that read it read the new data. Its charts that fit the new data stay: a chart that drew a whole table is re-pointed to the table the file has at the same corner when it has as many columns (rows for a chart by row), so last month's chart draws this month's rows, and one of part of a table, or of whole columns or rows, keeps its range. A chart whose table now has other columns, or whose range is empty now, is removed. The context line lists the removed charts first, then which were re-pointed and which kept their range. One undo step, which brings the removed charts back. Not offered for `.xlsx`, which holds several sheets |1717 | Replace spreadsheet | Opens the file instead, as File > Open and the command line do, asking first when there are unsaved changes |1818 1919 | Format | Import | Download |
M docs/keys.md +2 -2
@@ -26,7 +26,7 @@ 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 |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", R or F2 renames a value, Shift+Up/Down move it, Del removes it, Enter keeps, Esc undoes. Data > Edit pivot table reopens it |3030 | Alt+Shift+F | Frequency table of the active column on a new sheet (Data > Frequency table), as VisiData's Shift+F |3131 | Ctrl+Alt+= / Ctrl+Alt+- | Insert rows above / delete the selected rows (columns when whole columns are selected) |3232 | Ctrl+Enter while typing | Enter the same entry in every selected cell |
@@ -85,7 +85,7 @@ are taken.
8585 | `o` `O` | Insert a row below or above and start typing in it |8686 | `x` | Clear the cell (with a count, that many to the right) |8787 | `dd` `yy` | Cut or copy the row (with a count, that many rows); `p` pastes them back |88-| `p` `P` | Paste rows cut or copied with `dd` or `yy` as new rows below or above; other copied cells paste at the active cell |88+| `p` `P` | Paste rows cut or copied with `dd` or `yy` as new rows below or above, with their row formats; other copied cells paste at the active cell |8989 | `u`, Ctrl+R | Undo, redo |9090 | `v` `V` | Select cells or whole rows (VISUAL); motions stretch the selection |9191 | `/`, `n` `N` | Find (Enter stays on the match), next and previous match |
M e2e/import_test.go +11 -1
M e2e/pivot_test.go +24 -0
M e2e/screenlist_test.go +22 -1
@@ -433,6 +433,27 @@ var screens = []screen{
433433 s.keys("<enter>", "<down>", "<down>", "<right>", "<right>", "5")434434 s.waitFor("Pivot table results can't be edited")435435 }},436+ {name: "pivot-rename", setup: func(s *session) {437+ pivotEditor(s)438+ s.keys("r")439+ s.waitFor("Value name: AVERAGE of Price")440+ s.keys("Avg price")441+ }},442+ {name: "pivot-subtotals", setup: func(s *session) {443+ // Reorder, then Category, in Columns: a subtotal column after444+ // each Reorder value, and the average renamed.445+ pivotEditor(s)446+ s.keys("r")447+ s.waitFor("Value name: AVERAGE of Price")448+ s.keys("Avg price", "<enter>")449+ s.waitFor(" Avg price")450+ s.keys("<up>", "<up>", "<up>", "<up>", "<up>", "<delete>")451+ s.waitFor("Columns")452+ s.keys("<down>", "a", "categ", "<enter>")453+ s.waitFor(" Category")454+ s.keys("<enter>")455+ s.waitFor("no Total")456+ }},436457 {name: "pivot-filter", setup: func(s *session) {437458 pivotEditor(s)438459 s.keys("<down>", "<space>", "item", "<enter>", "<space>", "bolts")
@@ -456,7 +477,7 @@ var screens = []screen{
456477 // Key screens are also recorded on a light terminal, where the app picks457478 // its light theme from the reported background color.458479 func init() {459- 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", "far-corner"} {480+ 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", "pivot-rename", "pivot-subtotals", "frequency", "far-corner"} {460481 for _, sc := range screens {461482 if sc.name == name {462483 sc.name += "-light"
M e2e/testdata/screens/pivot-editor-light.html +1 -1
@@ -27,4 +27,4 @@
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>2828 <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>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>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 file30+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)">summary</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)">R</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">name</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
M e2e/testdata/screens/pivot-editor-narrow.html +1 -1
@@ -13,4 +13,4 @@
1313 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> 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>1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span><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>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────</span> <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 file16+ <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)">summary</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)">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
M e2e/testdata/screens/pivot-editor.html +1 -1
@@ -27,4 +27,4 @@
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>2828 <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>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>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 file30+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)">summary</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)">R</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">name</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
A e2e/testdata/screens/pivot-rename-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">EDIT</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+Value name: Avg price <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)">apply</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">cancel</span>4+<span style="background:var(--vt-palette-7)"> </span><span style="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="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-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 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
A e2e/testdata/screens/pivot-rename.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">EDIT</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+Value name: Avg price <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>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="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-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 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
A e2e/testdata/screens/pivot-subtotals-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">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><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</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">no</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">Fasteners</span> <span style="font-weight:bold">Hardware</span>7+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span> <span style="font-weight:bold">SUM</span> <span style="font-weight:bold">of</span> <span style="font-weight:bold">Qty</span> <span style="font-weight:bold">Avg</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">Avg</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">Avg</span> <span style="font-weight:bold">price</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> <span style="font-weight:bold">Grand</span> <span style="font-weight:bold">Total</span> <span style="font-weight:bold">796</span> <span style="font-weight:bold">8.879411765</span> <span style="font-weight:bold">489</span> <span style="font-weight:bold">14.055</span> <span style="font-weight:bold">1285</span> <span style="font-weight:bold">10.7962963</span>9+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span>10+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span>11+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span>12+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30+ 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
A e2e/testdata/screens/pivot-subtotals.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">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><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</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">no</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">Fasteners</span> <span style="font-weight:bold">Hardware</span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> <span style="font-weight:bold">SUM</span> <span style="font-weight:bold">of</span> <span style="font-weight:bold">Qty</span> <span style="font-weight:bold">Avg</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">Avg</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">Avg</span> <span style="font-weight:bold">price</span>8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> <span style="font-weight:bold">Grand</span> <span style="font-weight:bold">Total</span> <span style="font-weight:bold">796</span> <span style="font-weight:bold">8.879411765</span> <span style="font-weight:bold">489</span> <span style="font-weight:bold">14.055</span> <span style="font-weight:bold">1285</span> <span style="font-weight:bold">10.7962963</span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span>11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+ 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
M internal/sheet/clipfmt.go +38 -1
@@ -12,7 +12,8 @@ import (
1212 // already show. Copying whole columns or rows and pasting them as whole1313 // lines (at the top of a column, the start of a row, or A1 for the whole1414 // sheet) carries the line formats themselves, and cutting them moves the15-// line formats, leaving the source lines plain.15+// line formats, leaving the source lines plain. Cutting a block leaves16+// its cells plain, over whatever lines cross it.1617 1718 // clipFormats is the formatting of a copied range, taken at copy time.1819 type clipFormats struct {
@@ -256,10 +257,46 @@ func (s *Sheet) moveFormats(from *Sheet, f *clipFormats, src, dst Rect) {
256257 for n := range f.rowFmt {257258 from.setLine(true, src.From.Row+n, lineFmt{})258259 }260+ case lineBlock:261+ from.plainSource(src, dst, from == s)259262 }260263 s.pasteFormats(f, p)261264 }262265 266+// plainSource leaves the cells a block was cut from showing plain, as267+// Sheets clears them: where a formatted row, column or the sheet crosses268+// src, its blank cells get plain formatting of their own, up to269+// materializeLimit cells looked at, except those in dst when the block270+// lands on the same sheet.271+func (s *Sheet) plainSource(src, dst Rect, same bool) {272+ var areas []Rect273+ if !s.lines.sheet.IsZero() {274+ areas = []Rect{src}275+ } else {276+ for n := range linesIn(s.lines.rows, src.From.Row, src.To.Row) {277+ row := src.From.Row + n278+ areas = append(areas, Rect{From: Addr{Col: src.From.Col, Row: row}, To: Addr{Col: src.To.Col, Row: row}})279+ }280+ for n := range linesIn(s.lines.cols, src.From.Col, src.To.Col) {281+ col := src.From.Col + n282+ areas = append(areas, Rect{From: Addr{Col: col, Row: src.From.Row}, To: Addr{Col: col, Row: src.To.Row}})283+ }284+ }285+ seen := 0286+ for _, r := range areas {287+ for row := r.From.Row; row <= r.To.Row; row++ {288+ for c := r.From.Col; c <= r.To.Col; c++ {289+ if seen++; seen > materializeLimit {290+ return291+ }292+ if a := (Addr{Col: c, Row: row}); !(same && dst.Contains(a)) && s.cells.get(a) == nil {293+ s.placeFormat(a, lineFmt{})294+ }295+ }296+ }297+ }298+}299+263300 // unionKeys are the keys of a and b, in order.264301 func unionKeys(a, b map[int]lineFmt) []int {265302 keys := slices.Collect(maps.Keys(a))
M internal/sheet/insertbook.go +31 -19
@@ -75,8 +75,9 @@ func (w *Workbook) InsertBook(src *Workbook, at int, label string) (ImportResult
7575 // ReplaceSheet puts s, the sheet of an imported workbook, in the place of7676 // dst, as one undo step labelled label: it takes dst's name and position,7777 // so formulas reading dst read it, and named ranges on dst move to it.78-// dst's charts stay, drawing s's data (see keepCharts), and it returns79-// what became of each. dst's cells and the rest go; undo brings them back.78+// dst's charts that fit s's data stay, drawing it, and the rest go (see79+// keepCharts); it returns what became of each. dst's cells and the rest80+// go; undo brings them back, charts too.8081 func (w *Workbook) ReplaceSheet(dst, s *Sheet, label string) ([]ChartFate, error) {8182 switch {8283 case !dst.live || dst.wb != w:
@@ -108,15 +109,20 @@ func (w *Workbook) ReplaceSheet(dst, s *Sheet, label string) ([]ChartFate, error
108109 type ChartFate struct {109110 Name string // the chart's title, or Chart 1, Chart 2 by position110111 Was, Now Rect // the range it drew, and draws111- Empty bool // the new data has nothing in Now112+ // Removed is set when the range no longer fits the new data: it113+ // holds nothing now, or it was a whole table and the new sheet's114+ // table there has other series.115+ Removed bool112116 }113117 114-// keepCharts gives s, replacing old, old's charts. A chart that drew a115-// whole block of old's data is re-pointed to the block s has at the same116-// corner when it has as many series (columns, or rows for a chart by117-// row), so a chart of last month's table draws this month's, however118-// many rows it has. Any other chart keeps its range, which may now hold119-// something else or nothing; ChartFate says which.118+// keepCharts gives s, replacing old, old's charts that fit its data. A119+// chart that drew a whole block of old's data is re-pointed to the block120+// s has at the same corner when it has as many series (columns, or rows121+// for a chart by row), so a chart of last month's table draws this122+// month's, however many rows it has; when s's block there has other123+// series the chart goes. A chart of whole columns or rows, or of part of124+// a block, keeps its range. A chart whose range holds nothing now goes125+// too. ChartFate says which.120126 func (s *Sheet) keepCharts(old *Sheet) []ChartFate {121127 fates := make([]ChartFate, 0, len(old.charts))122128 for i, c := range old.charts {
@@ -125,30 +131,36 @@ func (s *Sheet) keepCharts(old *Sheet) []ChartFate {
125131 name = fmt.Sprintf("Chart %d", i+1)126132 }127133 was := c.Data128- c.Data = s.repoint(old, was, c.ByRow)129- _, filled := s.FilledBounds(c.Data)130- fates = append(fates, ChartFate{Name: name, Was: was, Now: c.Data, Empty: !filled})131- s.charts = append(s.charts, c)134+ now, fits := s.repoint(old, was, c.ByRow)135+ _, filled := s.FilledBounds(now)136+ f := ChartFate{Name: name, Was: was, Now: now, Removed: !fits || !filled}137+ fates = append(fates, f)138+ if !f.Removed {139+ c.Data = now140+ s.charts = append(s.charts, c)141+ }132142 }133143 return fates134144 }135145 136146 // repoint is the range on s a chart drawing d on old draws: s's block of137147 // data at d's corner when d was old's whole block there and the two have138-// as many series, else d. Whole columns or rows read any data as is.139-func (s *Sheet) repoint(old *Sheet, d Rect, byRow bool) Rect {148+// as many series, else d. Whole columns or rows, and part of a block,149+// read any data as is. It reports false when d was a whole block and s150+// has none like it there.151+func (s *Sheet) repoint(old *Sheet, d Rect, byRow bool) (Rect, bool) {140152 if d.AllRows() || d.AllCols() || old.Region(d.From) != d {141- return d153+ return d, true142154 }143155 now := s.Region(d.From)144156 if now.From != d.From || s.cells.get(now.From).Blank() && now.From == now.To {145- return d157+ return d, false146158 }147159 if byRow && now.To.Row-now.From.Row != d.To.Row-d.From.Row ||148160 !byRow && now.To.Col-now.From.Col != d.To.Col-d.From.Col {149- return d161+ return d, false150162 }151- return now163+ return now, true152164 }153165 154166 // freeIn returns base, or base with a number added ("Sales 2"), so that
M internal/sheet/insertbook_test.go +11 -7
@@ -112,9 +112,11 @@ func namesOf(w *Workbook) string {
112112 // hidePlain hides s outside the history, as a loader would.113113 func (w *Workbook) hidePlain(s *Sheet) { s.tabHidden = true }114114 115-// Replacing a sheet keeps its charts: one that drew the whole table is116-// re-pointed to the new table when it has as many columns, and the rest117-// keep their ranges, whatever they now hold.115+// Replacing a sheet keeps the charts that fit the new data: one that116+// drew the whole table is re-pointed to the new table when it has as117+// many columns and removed when it hasn't; one of part of a table keeps118+// its range; one whose range is empty now is removed. Undo brings the119+// removed ones back.118120 func TestReplaceSheetKeepsCharts(t *testing.T) {119121 w := bookOf(t, page{"Sales", map[string]string{120122 "A1": "Month", "B1": "Total", "A2": "Jan", "B2": "1", "A3": "Feb", "B3": "2",
@@ -124,6 +126,7 @@ func TestReplaceSheetKeepsCharts(t *testing.T) {
124126 old.AddChart(Chart{Data: NewRect(at("A1"), at("B3")), Title: "Totals"})125127 old.AddChart(Chart{Data: NewRect(at("E1"), at("E2"))})126128 old.AddChart(Chart{Data: NewRect(at("H1"), at("H1"))})129+ old.AddChart(Chart{Data: NewRect(at("B2"), at("B3")), Title: "Part"})127130 w.ClearHistory()128131 src := bookOf(t, page{"sales", map[string]string{129132 "A1": "Month", "B1": "Total", "A2": "Jan", "B2": "4", "A3": "Feb", "B3": "5", "A4": "Mar", "B4": "6",
@@ -135,8 +138,9 @@ func TestReplaceSheetKeepsCharts(t *testing.T) {
135138 }136139 want := []ChartFate{137140 {Name: "Totals", Was: NewRect(at("A1"), at("B3")), Now: NewRect(at("A1"), at("B4"))},138- {Name: "Chart 2", Was: NewRect(at("E1"), at("E2")), Now: NewRect(at("E1"), at("E2"))}, // a column more: kept139- {Name: "Chart 3", Was: NewRect(at("H1"), at("H1")), Now: NewRect(at("H1"), at("H1")), Empty: true},141+ {Name: "Chart 2", Was: NewRect(at("E1"), at("E2")), Now: NewRect(at("E1"), at("E2")), Removed: true}, // a column more142+ {Name: "Chart 3", Was: NewRect(at("H1"), at("H1")), Now: NewRect(at("H1"), at("H1")), Removed: true}, // empty143+ {Name: "Part", Was: NewRect(at("B2"), at("B3")), Now: NewRect(at("B2"), at("B3"))},140144 }141145 if len(fates) != len(want) {142146 t.Fatalf("fates %+v", fates)
@@ -147,11 +151,11 @@ func TestReplaceSheetKeepsCharts(t *testing.T) {
147151 }148152 }149153 charts := w.Sheet(0).Charts()150- if len(charts) != 3 || charts[0].Data != want[0].Now || charts[0].Title != "Totals" {154+ if len(charts) != 2 || charts[0].Data != want[0].Now || charts[0].Title != "Totals" || charts[1].Title != "Part" {151155 t.Errorf("charts on the new sheet: %+v", charts)152156 }153157 w.Undo()154- if w.Sheet(0) != old || len(old.Charts()) != 3 || old.Charts()[0].Data != want[0].Was {158+ if w.Sheet(0) != old || len(old.Charts()) != 4 || old.Charts()[0].Data != want[0].Was {155159 t.Errorf("undo: charts %+v", old.Charts())156160 }157161 }
M internal/sheet/linecopy_test.go +30 -3
@@ -160,13 +160,40 @@ func TestMoveCarriesFormats(t *testing.T) {
160160 t.Errorf("Other!C1 shows %v %v; B1 %v", o.DisplayFormat(at("C1")).Kind, o.Value(at("C1")), s.DisplayFormat(at("B1")).Kind)161161 }162162 163- // A block keeps its formats; the source falls back on its lines.163+ // A block keeps its formats; the source is left plain, as Sheets164+ // clears cut cells, though its column stays currency.164165 o.Set(at("C2"), "7")165166 if _, err := o.MoveTo(s, Rect{From: at("C1"), To: at("C2")}, at("E4")); err != nil {166167 t.Fatal(err)167168 }168- if s.DisplayFormat(at("E5")).Kind != FmtCurrency || o.DisplayFormat(at("C1")).Kind != FmtCurrency {169- t.Errorf("E5 shows %v, Other!C1 %v", s.DisplayFormat(at("E5")).Kind, o.DisplayFormat(at("C1")).Kind)169+ if s.DisplayFormat(at("E5")).Kind != FmtCurrency || !o.DisplayFormat(at("C1")).IsZero() || o.DisplayFormat(at("C3")).Kind != FmtCurrency {170+ t.Errorf("E5 shows %v, Other!C1 %v, Other!C3 %v", s.DisplayFormat(at("E5")).Kind, o.DisplayFormat(at("C1")).Kind, o.DisplayFormat(at("C3")).Kind)171+ }172+}173+174+// Cutting a block on its sheet leaves the cells it left plain, over175+// formatted columns and rows, except where it lands; undo restores them.176+func TestMoveBlockLeavesSourcePlain(t *testing.T) {177+ s := New()178+ s.SetFormat(col(1), Preset(FmtCurrency))179+ s.SetStyle(rowRect(1, 1), bold) // row 2180+ s.Set(at("B1"), "5")181+ s.Set(at("B2"), "6")182+ s.Set(at("C3"), "7")183+ if _, err := s.Move(Rect{From: at("B1"), To: at("C3")}, at("C2")); err != nil {184+ t.Fatal(err)185+ }186+ for _, a := range []string{"B1", "B2", "B3", "C1"} {187+ if !s.DisplayFormat(at(a)).IsZero() || s.CellStyle(at(a)).Bold {188+ t.Errorf("%s shows %v, bold %v, want plain", a, s.DisplayFormat(at(a)).Kind, s.CellStyle(at(a)).Bold)189+ }190+ }191+ if s.DisplayFormat(at("C2")).Kind != FmtCurrency || s.DisplayFormat(at("B4")).Kind != FmtCurrency || !s.CellStyle(at("A2")).Bold {192+ t.Errorf("C2 %v (moved from B1), B4 %v, A2 bold %v", s.DisplayFormat(at("C2")).Kind, s.DisplayFormat(at("B4")).Kind, s.CellStyle(at("A2")).Bold)193+ }194+ s.Undo()195+ if s.DisplayFormat(at("B3")).Kind != FmtCurrency || !s.CellStyle(at("B2")).Bold || s.cells.get(at("B3")) != nil {196+ t.Errorf("undo: B3 %v, B2 bold %v", s.DisplayFormat(at("B3")).Kind, s.CellStyle(at("B2")).Bold)170197 }171198 }172199
M internal/sheet/pivot.go +2 -1
M internal/sheet/pivot_test.go +2 -2
M internal/sheet/pivotcalc.go +24 -80
@@ -154,27 +154,16 @@ type pivotNode struct {
154154 acc map[int][]accum155155 }156156 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-170157 // pivotCalc is a pivot being computed.171158 type pivotCalc struct {172159 w *Workbook173160 p *Pivot174161 src *Sheet175162 root *pivotNode176- cols []*colGroup // sorted once gathered163+ cols []*colGroup // the column groups, sorted once gathered177164 colRoot colNode165+ nextCol int // the next column group's id166+ colIDs []int // colGroupsOf's answer, reused178167 unique []bool // by value: whether to gather distinct values179168 formats []Format // by value: the format results show in180169 records int // source rows summarized
@@ -215,12 +204,12 @@ func (c *pivotCalc) gather() {
215204 continue216205 }217206 c.records++218- col := c.colGroupOf(row)207+ ids := c.colGroupsOf(row)219208 n := c.root220- c.add(n, col, row)209+ c.add(n, ids, row)221210 for _, g := range c.p.Rows {222211 n = n.child(c.src, Addr{Col: g.Col, Row: row})223- c.add(n, col, row)212+ c.add(n, ids, row)224213 }225214 }226215 c.sortRows(c.root, 0)
@@ -273,58 +262,30 @@ func (n *pivotNode) child(src *Sheet, a Addr) *pivotNode {
273262 return kid274263 }275264 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) {265+// add gathers row's values into n: for all column groups, and for each266+// of ids, the column groups the row counts in.267+func (c *pivotCalc) add(n *pivotNode, ids []int, row int) {310268 if len(c.p.Values) == 0 {311269 return312270 }313271 if n.acc == nil {314272 n.acc = map[int][]accum{}315273 }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- }274+ c.addTo(n, -1, row)275+ for _, id := range ids {276+ c.addTo(n, id, row)277+ }278+}279+280+// addTo gathers row's values into n's accumulators for column group id.281+func (c *pivotCalc) addTo(n *pivotNode, id, row int) {282+ accs := n.acc[id]283+ if accs == nil {284+ accs = make([]accum, len(c.p.Values))285+ n.acc[id] = accs286+ }287+ for i, v := range c.p.Values {288+ accs[i].add(c.src.Value(Addr{Col: v.Col, Row: row}), c.unique[i])328289 }329290 }330291
@@ -356,23 +317,6 @@ func (c *pivotCalc) sortRows(n *pivotNode, depth int) {
356317 }357318 }358319 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-376320 // order compares two groups by a field's order: by label, as Sheets sorts377321 // (blanks last either way), or by a value's total, labels breaking ties.378322 func (c *pivotCalc) order(g PivotGroup, a, b []Value, totals func(i int) (Value, Value)) int {
A internal/sheet/pivotcols.go +126 -0
@@ -0,0 +1,126 @@
1+package sheet2+3+import "slices"4+5+// Column groups of a pivot: each combination of the column fields' values6+// is a group, and so is each combination of the first few fields' values,7+// an outer group. Outer groups order the columns when a field is sorted8+// by a value's total, and show as subtotal columns after their last9+// combination when column totals are on, as Sheets' totals of an outer10+// column field.11+12+// colGroup is a combination of the first len(labels) column fields'13+// values: all of them for a column of results, fewer for an outer group.14+type colGroup struct {15+ id int16+ labels []Value17+ formats []Format18+}19+20+// colNode finds column groups by each column field's value in turn.21+type colNode struct {22+ kids map[groupKey]*colNode23+ group *colGroup24+}25+26+// colGroupsOf returns the ids of the column groups row counts in, outer27+// groups first, or none without column fields.28+func (c *pivotCalc) colGroupsOf(row int) []int {29+ if len(c.p.Columns) == 0 || len(c.p.Values) == 0 {30+ return nil31+ }32+ c.colIDs = c.colIDs[:0]33+ n := &c.colRoot34+ for k, g := range c.p.Columns {35+ key := keyOf(c.src.Value(Addr{Col: g.Col, Row: row}), true)36+ kid := n.kids[key]37+ if kid == nil {38+ if n.kids == nil {39+ n.kids = map[groupKey]*colNode{}40+ }41+ kid = &colNode{group: c.newColGroup(row, k+1)}42+ n.kids[key] = kid43+ if k == len(c.p.Columns)-1 {44+ c.cols = append(c.cols, kid.group)45+ }46+ }47+ n = kid48+ c.colIDs = append(c.colIDs, n.group.id)49+ }50+ return c.colIDs51+}52+53+// newColGroup is the group of row's values in the first depth column54+// fields.55+func (c *pivotCalc) newColGroup(row, depth int) *colGroup {56+ cg := &colGroup{id: c.nextCol}57+ c.nextCol++58+ for _, g := range c.p.Columns[:depth] {59+ a := Addr{Col: g.Col, Row: row}60+ cg.labels = append(cg.labels, c.src.Value(a))61+ cg.formats = append(cg.formats, c.src.DisplayFormat(a))62+ }63+ return cg64+}65+66+// outer returns the group of the first len(labels) column fields' values.67+func (c *pivotCalc) outer(labels []Value) *colGroup {68+ n := &c.colRoot69+ for _, v := range labels {70+ n = n.kids[keyOf(v, true)]71+ }72+ return n.group73+}74+75+// sortCols orders the column groups by each column field in turn; a field76+// sorted by a value's total compares the totals of its outer groups, so77+// each outer group's columns stay together.78+func (c *pivotCalc) sortCols() {79+ slices.SortStableFunc(c.cols, func(a, b *colGroup) int {80+ for k, g := range c.p.Columns {81+ d := c.order(g, a.labels[k:k+1], b.labels[k:k+1], func(i int) (Value, Value) {82+ va, _ := c.result(c.root, c.outer(a.labels[:k+1]).id, i)83+ vb, _ := c.result(c.root, c.outer(b.labels[:k+1]).id, i)84+ return va, vb85+ })86+ if d != 0 {87+ return d88+ }89+ }90+ return 091+ })92+}93+94+// subtotals reports whether outer column groups show as subtotal columns:95+// with column totals on and several column fields.96+func (c *pivotCalc) subtotals() bool {97+ return c.p.ColumnTotals && len(c.p.Columns) > 198+}99+100+// columnOrder lists the column groups as the results show them: each101+// combination in order, after each outer group's last one its subtotal102+// when subtotals show, innermost first.103+func (c *pivotCalc) columnOrder() []*colGroup {104+ if !c.subtotals() {105+ return slices.Clone(c.cols)106+ }107+ var out []*colGroup108+ for i, cg := range c.cols {109+ out = append(out, cg)110+ for d := len(c.p.Columns) - 1; d >= 1; d-- {111+ if i+1 < len(c.cols) && sameLabels(cg.labels[:d], c.cols[i+1].labels[:d]) {112+ break113+ }114+ out = append(out, c.outer(cg.labels[:d]))115+ }116+ }117+ return out118+}119+120+// totalName labels the total of a group, "East Total", as Sheets does.121+func totalName(v Value, f Format) string {122+ if v.Kind == Empty {123+ return "Total"124+ }125+ return FormatText(v, f) + " Total"126+}
A internal/sheet/pivotcols_test.go +76 -0
@@ -0,0 +1,76 @@
1+package sheet2+3+import (4+ "bytes"5+ "testing"6+)7+8+func TestPivotColumnSubtotals(t *testing.T) {9+ w := sales(t)10+ s := newPivot(t, w, func(p *Pivot) {11+ p.Columns = []PivotGroup{{Col: 0}, {Col: 1}}12+ p.Values = []PivotValue{{Col: 3, Summarize: SumBy, Name: "Units sold"}}13+ })14+ // A subtotal column after each region's products, named in the15+ // Region row; the value's own name heads every column.16+ checkGrid(t, s,17+ "Region|East||East Total|North|North Total|West||West Total|Grand Total",18+ "Product|Ink|Pens||Pens||Ink|Pens",19+ "|Units sold|Units sold|Units sold|Units sold|Units sold|Units sold|Units sold|Units sold|Units sold",20+ "Grand Total|6|10|16|0|0|4|3|7|23")21+ if c := s.Cell(at("D1")); c == nil || !c.Style.Bold {22+ t.Error("the subtotal's header isn't bold")23+ }24+ // Without column totals, neither subtotals nor the grand total show.25+ p, _ := s.Pivot()26+ p.ColumnTotals = false27+ s.SetPivot(p, "")28+ checkGrid(t, s,29+ "Region|East||North|West",30+ "Product|Ink|Pens|Pens|Ink|Pens",31+ "|Units sold|Units sold|Units sold|Units sold|Units sold",32+ "Grand Total|6|10|0|4|3")33+ // Sorting the outer field by its total keeps each region together.34+ p.Columns[0] = PivotGroup{Col: 0, Desc: true, SortBy: 1}35+ s.SetPivot(p, "")36+ checkGrid(t, s,37+ "Region|East||West||North",38+ "Product|Ink|Pens|Ink|Pens|Pens",39+ "|Units sold|Units sold|Units sold|Units sold|Units sold",40+ "Grand Total|6|10|4|3|0")41+}42+43+func TestPivotBlankGroupsAndNamesRoundTrip(t *testing.T) {44+ w := sales(t)45+ w.Lookup("Sales").Set(at("A3"), "")46+ s := newPivot(t, w, func(p *Pivot) {47+ p.Rows = []PivotGroup{{Col: 0}, {Col: 1}}48+ p.Columns = []PivotGroup{{Col: 0}, {Col: 1}}49+ p.Values = []PivotValue{{Col: 3, Summarize: SumBy, Name: "Units"}}50+ })51+ // Blank cells group under a blank label, as in Sheets; their total52+ // is just "Total".53+ grid := pivotGrid(s)54+ found := false55+ for _, row := range grid {56+ if len(row) >= 6 && row[:6] == "Total|" {57+ found = true58+ }59+ }60+ if !found {61+ t.Errorf("no Total row for the blank group:\n%v", grid)62+ }63+ var b bytes.Buffer64+ if err := w.Write(&b); err != nil {65+ t.Fatal(err)66+ }67+ back, err := ReadBook(&b)68+ if err != nil {69+ t.Fatal(err)70+ }71+ p, _ := back.Lookup(s.Name()).Pivot()72+ if p.Values[0].Name != "Units" || !p.ColumnTotals {73+ t.Errorf("read back %+v", p)74+ }75+ checkGrid(t, back.Lookup(s.Name()), grid...)76+}
M internal/sheet/pivotlayout.go +26 -20
@@ -12,7 +12,8 @@ import (
1212 // fields, their labels shown where they change, with a subtotal row after1313 // each outer group and a Grand Total row when row totals are on. Columns1414 // are the row labels, then each column group's values, then the Grand15-// Total column's.15+// Total column's, with a subtotal column after each outer column group16+// when column totals are on and there are several column fields.1617 1718 // pivotCell is one cell of a pivot's results.1819 type pivotCell struct {
@@ -32,16 +33,13 @@ type pivotLayout struct {
3233 over bool // something fell off the sheet3334 }3435 35-// blankLabel stands for a group of blank cells, as in Excel.36-const blankLabel = "(blank)"37-3836 func (c *pivotCalc) layout() *pivotLayout {3937 l := &pivotLayout{c: c, lc: max(len(c.p.Rows), 1)}4038 if len(c.p.Values) > 0 {4139 if len(c.p.Columns) == 0 {4240 l.cols = []*colGroup{nil}4341 } else {44- l.cols = slices.Clone(c.cols)42+ l.cols = c.columnOrder()4543 if c.p.ColumnTotals {4644 l.cols = append(l.cols, nil)4745 }
@@ -64,10 +62,10 @@ func (l *pivotLayout) text(col, row int, s string, bold bool) {
6462 l.put(col, row, Value{Kind: Text, Str: s}, Format{}, bold)6563 }6664 67-// label shows a group's value in its own format, or (blank).65+// label shows a group's value in its own format; a group of blank cells66+// has a blank label, as in Sheets.6867 func (l *pivotLayout) label(col, row int, v Value, f Format, bold bool) {6968 if v.Kind == Empty {70- l.text(col, row, blankLabel, bold)7169 return7270 }7371 l.put(col, row, v, f, bold)
@@ -81,14 +79,7 @@ func (l *pivotLayout) header() {
8179 l.text(l.lc-1, l.y, c.w.FieldName(*c.p, g.Col), true)8280 var prev *colGroup8381 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- }82+ l.colHeader(l.lc+j*nv, k, prev, cg)9283 prev = cg9384 }9485 l.y++
@@ -105,6 +96,25 @@ func (l *pivotLayout) header() {
10596 l.y++10697 }10798 99+// colHeader labels column group cg, after prev, in the header row of100+// column field k at column x: the field's value where it changes, a101+// subtotal's name in its outer field's row, Grand Total in the first.102+func (l *pivotLayout) colHeader(x, k int, prev, cg *colGroup) {103+ switch n := len(l.c.p.Columns); {104+ case cg == nil && k == 0:105+ l.text(x, l.y, "Grand Total", true)106+ case cg == nil:107+ case len(cg.labels) < n:108+ if k == len(cg.labels)-1 {109+ l.text(x, l.y, totalName(cg.labels[k], cg.formats[k]), true)110+ }111+ case prev == nil || len(prev.labels) <= k || !sameLabels(prev.labels[:k+1], cg.labels[:k+1]):112+ // The outer group's spelling, the first seen, as rows show.113+ og := l.c.outer(cg.labels[:k+1])114+ l.label(x, l.y, og.labels[k], og.formats[k], true)115+ }116+}117+108118 func sameLabels(a, b []Value) bool {109119 for i := range a {110120 if keyOf(a[i], true) != keyOf(b[i], true) {
@@ -141,11 +151,7 @@ func (l *pivotLayout) rows(n *pivotNode, depth int) {
141151 }142152 l.rows(kid, depth+1)143153 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)154+ l.total(kid, totalName(kid.label, kid.format), depth)149155 }150156 }151157 }
M internal/ui/hosts.go +10 -0
M internal/ui/importplace.go +17 -11
M internal/ui/importplace_test.go +9 -8
@@ -76,24 +76,25 @@ func TestImportLocation(t *testing.T) {
7676 }7777 press(t, m, "<ctrl+z>")7878 79- // Replace current sheet keeps the sheet's name, place and charts,80- // and says what became of the charts.79+ // Replace current sheet keeps the sheet's name, place and the charts80+ // that fit the new data, removes the rest, and says which.8181 press(t, m, "<ctrl+pgup>")8282 m.sheet.AddChart(sheet.Chart{Data: sheet.NewRect(addr("C1"), addr("C2")), Title: "Totals"})83+ m.sheet.AddChart(sheet.Chart{Data: sheet.NewRect(addr("A1"), addr("B2")), Title: "Regions"})8384 writeFile(t, "sales.csv", "Region,Total\nSouth,50\n")8485 importInto(t, m, "sales.csv", "Replace current sheet")8586 if sheetNames(m) != "Sheet1,sales" || m.sheet.Name() != "Sheet1" || input(m, "A2") != "South" || input(m, "A1") != "Region" {8687 t.Fatalf("replaced: %s on %s, A2 %q", sheetNames(m), m.sheet.Name(), input(m, "A2"))8788 }88- if line(m, contextLine) != "Imported sales.csv into Sheet1 (2 rows); charts: Totals kept on C1:C2, empty now" {89- t.Errorf("context %q", line(m, contextLine))89+ if got := line(m, contextLine); !strings.HasPrefix(got, "Imported sales.csv into Sheet1 (2 rows); removed charts: Totals (C1:C2); charts:") {90+ t.Errorf("context %q", got)9091 }91- if len(m.sheet.Charts()) != 1 {92- t.Errorf("charts after replacing: %+v", m.sheet.Charts())92+ if c := m.sheet.Charts(); len(c) != 1 || c[0].Title != "Regions" {93+ t.Errorf("charts after replacing: %+v", c)9394 }9495 press(t, m, "<ctrl+z>")95- if m.sheet.Name() != "Sheet1" || input(m, "A1") != "=sales!B2*2" {96- t.Errorf("undo replace: on %s, A1 %q", m.sheet.Name(), input(m, "A1"))96+ if m.sheet.Name() != "Sheet1" || input(m, "A1") != "=sales!B2*2" || len(m.sheet.Charts()) != 2 {97+ t.Errorf("undo replace: on %s, A1 %q, %d charts", m.sheet.Name(), input(m, "A1"), len(m.sheet.Charts()))9798 }9899 }99100
M internal/ui/linecopy_test.go +26 -0
M internal/ui/pivot_test.go +38 -1
@@ -44,7 +44,7 @@ func TestPivotCreateAndEdit(t *testing.T) {
4444 if shows(m, "B1") != "SUM of Units" || shows(m, "B2") != "16" || shows(m, "B4") != "20" {4545 t.Fatalf("values: %q %q %q", shows(m, "B1"), shows(m, "B2"), shows(m, "B4"))4646 }47- if !strings.Contains(screen(m), "‹ SUM ›") || !strings.Contains(line(m, m.height-1), "summarize by") {47+ if !strings.Contains(screen(m), "‹ SUM ›") || !strings.Contains(line(m, m.height-1), "summary") {4848 t.Errorf("value line or hints missing:\n%s", screen(m))4949 }5050 press(t, m, "<left>")
@@ -177,6 +177,43 @@ func TestPivotFilterAndSource(t *testing.T) {
177177 }178178 }179179 180+func TestPivotRenameValueAndColumnSubtotals(t *testing.T) {181+ m := wideModel()182+ salesTable(t, m)183+ m.runCommand("data.pivot")184+ // Columns: Region, then Product; Values: Units.185+ press(t, m, "<down>", "<space>", "reg", "<enter>")186+ press(t, m, "a", "prod", "<enter>")187+ press(t, m, "<down>", "<space>", "units", "<enter>")188+ if shows(m, "D1") != "East Total" || shows(m, "F1") != "West Total" || shows(m, "G1") != "Grand Total" {189+ t.Fatalf("headers %q %q %q\n%s", shows(m, "D1"), shows(m, "F1"), shows(m, "G1"), screen(m))190+ }191+ // R renames the value, on the context line.192+ press(t, m, "r")193+ if m.mode != modePrompt || !strings.Contains(line(m, contextLine), "SUM of Units") {194+ t.Fatalf("mode %v, %q", m.mode, line(m, contextLine))195+ }196+ press(t, m, "Units sold", "<enter>")197+ if m.indicator() != "PIVOT" || shows(m, "B3") != "Units sold" || !strings.Contains(screen(m), "Units sold") {198+ t.Fatalf("renamed: %s, B3 %q\n%s", m.indicator(), shows(m, "B3"), screen(m))199+ }200+ // A new summary keeps the name; Esc on the prompt changes nothing.201+ press(t, m, "<left>", "r", "<esc>")202+ if m.indicator() != "PIVOT" || shows(m, "B3") != "Units sold" {203+ t.Errorf("after Left and Esc: %s, %q", m.indicator(), shows(m, "B3"))204+ }205+ // An empty name goes back to Sheets' own.206+ press(t, m, "r", " ")207+ press(t, m, "<enter>")208+ if shows(m, "B3") != "MIN of Units" {209+ t.Errorf("cleared name: %q", shows(m, "B3"))210+ }211+ press(t, m, "<enter>", "<ctrl+z>")212+ if shows(m, "B3") != "Units sold" {213+ t.Errorf("undo: %q", shows(m, "B3"))214+ }215+}216+180217 func TestPivotMenus(t *testing.T) {181218 m := wideModel()182219 press(t, m, "<alt+d>")
M internal/ui/pivotactions.go +30 -2
@@ -3,6 +3,7 @@ package ui
33 import (44 "slices"55 "strconv"6+ "strings"67 78 tea "charm.land/bubbletea/v2"89
@@ -120,7 +121,6 @@ func (e *pivotEditor) adjust(m pivotHost, it pivotItem, d int) {
120121 fs := sheet.Summaries()121122 v := &p.Values[it.i]122123 v.Summarize = fs[wrap(slices.Index(fs, v.Summarize)+d, len(fs))]123- v.Name = ""124124 })125125 default:126126 e.set(m, "order "+sectNames[it.sect], func(p *sheet.Pivot) {
@@ -153,10 +153,38 @@ func (e *pivotEditor) cycleShowAs(m pivotHost, it pivotItem) {
153153 all := sheet.ShowAsList()154154 v := &p.Values[it.i]155155 v.ShowAs = all[wrap(slices.Index(all, v.ShowAs)+1, len(all))]156- v.Name = ""157156 })158157 }159158 159+// rename asks for a value's name, the header of its columns; an empty160+// name goes back to Sheets' "SUM of Sales".161+func (e *pivotEditor) rename(m pivotHost, it pivotItem) {162+ if it.kind != itemField || it.sect != sectValues {163+ return164+ }165+ p := e.pivot(m)166+ m.closeOverlay()167+ m.askText("Value name:", m.book().ValueTitle(p, p.Values[it.i]), func(text string) {168+ e.set(m, "rename "+e.fieldName(m, e.pivot(m), it), func(p *sheet.Pivot) {169+ v := &p.Values[it.i]170+ v.Name = strings.TrimSpace(text)171+ if v.Name == m.book().ValueTitle(*p, sheet.PivotValue{Col: v.Col, Summarize: v.Summarize}) {172+ v.Name = "" // the default, which follows the summary173+ }174+ })175+ e.reopen()176+ }, e.reopen)177+}178+179+// lineName is what a field's line calls it: a value's own name, or the180+// source field's.181+func (e *pivotEditor) lineName(m pivotHost, p sheet.Pivot, it pivotItem) string {182+ if it.sect == sectValues && p.Values[it.i].Name != "" {183+ return p.Values[it.i].Name184+ }185+ return e.fieldName(m, p, it)186+}187+160188 // remove takes a field out of its section.161189 func (e *pivotEditor) remove(m pivotHost, it pivotItem) {162190 if it.kind != itemField {
M internal/ui/pivoteditor.go +10 -2
@@ -53,6 +53,8 @@ type pivotHost interface {
5353 pickValues(title string, x int, values []sheet.FilterValue, cond sheet.Condition, apply func(sheet.Criteria), cancel func())5454 // pointRange asks for a range, pointed at or typed.5555 pointRange(label string, done func(sheet.Rect), cancel func())56+ // askText asks for text, such as a value's name.57+ askText(label, initial string, done func(string), cancel func())5658 }5759 5860 // Sections of the editor, in order.
@@ -204,6 +206,8 @@ func (e *pivotEditor) Key(k tea.KeyPressMsg) tea.Cmd {
204206 }205207 case "s":206208 e.cycleShowAs(m, it)209+ case "r", "f2":210+ e.rename(m, it)207211 case "delete", "backspace", "x", "-":208212 e.remove(m, it)209213 }
@@ -309,7 +313,7 @@ func (e *pivotEditor) line(m pivotHost, p sheet.Pivot, it pivotItem, sel bool, i
309313 right = dim.Render("Space add ")310314 }311315 case itemField:312- left = base.Render(" " + e.fieldName(m, p, it))316+ left = base.Render(" " + e.lineName(m, p, it))313317 right = e.fieldDetail(m, p, it, sel) + base.Render(" ")314318 case itemToggle:315319 on := p.RowTotals
@@ -389,7 +393,11 @@ func (e *pivotEditor) help(m pivotHost, it pivotItem) (string, []string) {
389393 switch it.sect {390394 case sectValues:391395 v := p.Values[it.i]392- return v.Summarize.Desc(), append([]string{"←/→", "summarize by", "S", "show as"}, remove...)396+ desc := v.Summarize.Desc()397+ if v.Name != "" {398+ desc = e.fieldName(m, p, it) + ": " + desc399+ }400+ return desc, append([]string{"←/→", "summary", "S", "show as", "R", "name"}, remove...)393401 case sectFilters:394402 return "Choose which values count, or a condition", append([]string{"Space", "values"}, remove...)395403 }
M internal/ui/prompt.go +3 -0
M internal/ui/rows.go +3 -6