With two or more column fields and column totals on, each outer column
group gets a subtotal column ("East Total"), as Sheets shows the totals
of an outer column field. Outer column groups are gathered always, so
sorting an outer field by a value's total orders whole groups and keeps
their columns together.
R (or F2) on a value in the pivot editor renames it on the context line;
the name heads its columns and survives changing its summary. An empty
name goes back to Sheets' "SUM of Units".
Groups of blank cells get a blank label, as Sheets shows them, and
their subtotal is "Total".
Esc on a prompt opened from an overlay now returns to that overlay's
mode, so its keys work again.- commit
- aebff00858f611693da906c096142913e8f3dd3c
- parent
- fb9d110c486f6ce4a9cc78d2cf661559fe39da83
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 18 | +++- |
| M | docs/ | 2 | +- |
| M | e2e/ | 24 | ++++++ |
| M | e2e/ | 23 | +++++- |
| M | internal/ | 3 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 104 | ++++++------------------- |
| A | internal/ | 126 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 76 | ++++++++++++++++++ |
| M | internal/ | 46 | ++++++----- |
| M | internal/ | 10 | ++ |
| M | internal/ | 37 | +++++++++ |
| M | internal/ | 32 | +++++++- |
| M | internal/ | 12 | ++- |
| M | internal/ | 3 | + |
@@ -84,15 +84,18 @@ opens docked at the right of the grid, with the results updating behind it:
8484 8585 - **Rows** group the data by a field's values, one row per value; with two8686 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.87+- **Columns** spread the groups across columns by a field's values; with88+ two or more, each outer group gets a subtotal column (`East Total`)89+ after its last column.8890 - **Values** summarize a field for each group: SUM, COUNTA, COUNT,8991 COUNTUNIQUE, AVERAGE, MAX or MIN (Sheets' "Summarize by"), shown as they9092 are or as a share of their row, column or grand total ("Show as").9193 A new value is summed when its field holds numbers, and counted92- otherwise.94+ otherwise. Its header is Sheets' `SUM of Units` until you rename it.9395 - **Filters** leave out rows by a field's values or a condition, in the9496 same picker as a filter's column.95-- **Grand total row** and **Grand total column** add Sheets' totals.97+- **Grand total row** and **Grand total column** add Sheets' totals, and98+ turn the subtotal rows and columns on and off with them.9699 97100 Up and Down pick a line. Space adds a field to the section it's on (pick98101 it from the fields of the data, type to narrow the list), opens a filter's@@ -101,7 +104,9 @@ its sheet. On a row or column field, Left and Right order its groups A to
101104 Z, Z to A, or by a value's total, smallest or largest first, and102105 Shift+Up and Shift+Down move it before or after the others, which changes103106 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 an107+summarized, S how it's shown, and R (or F2) renames it on the context108+line, the name heading its columns; an empty name goes back to Sheets'.109+A value keeps its name when its summary changes. Del removes a field. Every change is an105110 undo step; Enter keeps them, Esc undoes them, and removes a pivot just106111 created. Data > Edit pivot table opens the editor again.107112 @@ -109,7 +114,10 @@ Groups follow Sheets: text ignoring case (`east` joins `East`), numbers
109114 and dates by value, with the source's format, so dates show as dates.110115 Numbers and text never share a group: `1` and the text `'1` are two.111116 Groups sort numbers first, then text, booleans and errors, and blank112-cells, shown as `(blank)`, last. Rows blank across the whole range are117+cells last; as in Sheets, the group of blank cells has a blank label,118+and its subtotal is just `Total`. Sorting an outer column field by a119+value's total orders whole outer groups, so their columns stay120+together. Rows blank across the whole range are113121 left out, so a range can reach past the data. SUM, AVERAGE, MIN and MAX114122 keep the column's number format; an error in a summed column shows as that115123 error, as SUM would.@@ -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 |@@ -98,3 +98,27 @@ func TestFrequencyTable(t *testing.T) {
9898 s.keys("<ctrl+z>")9999 s.eventually("undone", func() bool { return !strings.Contains(s.line(int(s.rows)-1), "Frequency of") })100100 }101+102+func TestPivotRenameAndColumnSubtotals(t *testing.T) {103+ s := start(t, "")104+ pivotByCategory(s)105+ // R renames the value on the context line.106+ s.keys("r")107+ s.waitFor("Name: SUM of Qty")108+ s.keys("Units", "<enter>")109+ s.waitFor("PIVOT")110+ s.eventually("renamed header", func() bool { return strings.Contains(s.line(gridRow1), "Units") })111+ // Category moves from Rows to Columns, after Reorder: a subtotal112+ // column follows each Reorder value.113+ s.keys("<up>", "<up>", "<up>", "<delete>")114+ s.eventually("category removed", func() bool { return !strings.Contains(s.line(gridRow1), "Category") })115+ s.keys("<space>", "reord", "<enter>")116+ s.waitFor(" Reorder")117+ s.keys("a", "categ", "<enter>")118+ s.waitFor(" Category")119+ s.keys("<enter>")120+ s.waitFor("READY")121+ s.eventually("subtotal columns", func() bool {122+ return strings.Contains(s.line(gridRow1), "no Total") && strings.Contains(s.line(gridRow1), "yes Total")123+ })124+}@@ -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("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("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"@@ -30,7 +30,8 @@ type Pivot struct {
3030 Filters []PivotFilter3131 // RowTotals adds a Grand Total row, and a subtotal row after each3232 // outer group when there are several row groups. ColumnTotals adds a33- // Grand Total column when there are column groups.33+ // Grand Total column when there are column groups, and a subtotal34+ // column after each outer group when there are several.3435 RowTotals, ColumnTotals bool3536 // Lost is set when the source range was deleted.3637 Lost bool@@ -181,7 +181,7 @@ func TestPivotMixedKeysAndBlanks(t *testing.T) {
181181 "b|#DIV/0!|1",182182 "TRUE|4|1",183183 "#DIV/0!|32|1",184- "(blank)|8|1",184+ "|8|1",185185 "Grand Total|#DIV/0!|6")186186 }187187 @@ -244,7 +244,7 @@ func TestPivotFrequency(t *testing.T) {
244244 "Product|Count|Percent",245245 "Ink|2|40.00%",246246 "Pens|2|40.00%",247- "(blank)|1|20.00%",247+ "|1|20.00%",248248 "Grand Total|5|100.00%")249249 }250250 @@ -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 {@@ -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+}@@ -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+}@@ -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 }@@ -65,6 +65,16 @@ func (m *Model) pointRange(label string, done func(sheet.Rect), cancel func()) {
6565 m.prompt.onCancel = func(*Model) { cancel() }6666 }6767 68+// askText asks for text on the context line, starting from initial; done69+// gets the answer, and cancel runs after Esc.70+func (m *Model) askText(label, initial string, done func(string), cancel func()) {71+ m.openText(label, initial, func(_ *Model, text string) tea.Cmd {72+ done(text)73+ return nil74+ })75+ m.prompt.onCancel = func(*Model) { cancel() }76+}77+6878 // What every component with a text field needs.6979 7080 func (h host) Theme() *theme.Theme { return &h.m.th }@@ -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>")@@ -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("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 {@@ -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{"←/→", "summarize by", "S", "show as", "R", "rename"}, remove...)393401 case sectFilters:394402 return "Choose which values count, or a condition", append([]string{"Space", "values"}, remove...)395403 }