The pivot editor is a panel docked at the right of the grid: Space adds fields to Rows, Columns, Values and Filters, Left/Right change a field's order or summary, S its show as, Del removes it, and filters reuse the filter's values picker. Enter keeps the changes, Esc undoes them. Column stats (Alt+Shift+F) makes a frequency table of the active column. Commands that would change a pivot's results are refused with a note on the context line.
- commit
- d7dad192ad4c07fb5fb4ae8051c017c7b8c4d359
- parent
- a40bf99103deef78ff6da878ff8b4b7b16a4e177
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 9 | +- |
| M | internal/ | 12 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 16 | +- |
| M | internal/ | 5 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 71 | ++++-- |
| M | internal/ | 12 | +- |
| M | internal/ | 3 | +- |
| A | internal/ | 146 | ++++++++++++ |
| A | internal/ | 189 | ++++++++++++++++ |
| A | internal/ | 334 | ++++++++++++++++++++++++++++ |
| A | internal/ | 358 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 3 | + |
@@ -27,9 +27,9 @@ type clipboard struct {
2727 func init() {2828 register(2929 &command{id: "edit.copy", title: "Copy", desc: "Copy the selection", run: func(m *Model) tea.Cmd { return m.copy(false) }},30- &command{id: "edit.cut", title: "Cut", desc: "Cut the selection, to move it where you paste", run: func(m *Model) tea.Cmd { return m.copy(true) }},31- &command{id: "edit.paste", title: "Paste", desc: "Paste into the selection, adjusting references", run: func(m *Model) tea.Cmd { return m.paste(false) }},32- &command{id: "edit.paste_values", title: "Paste values only", desc: "Paste the copied values without formulas", run: func(m *Model) tea.Cmd { return m.paste(true) }},30+ &command{id: "edit.cut", title: "Cut", desc: "Cut the selection, to move it where you paste", edits: (*Model).selection, run: func(m *Model) tea.Cmd { return m.copy(true) }},31+ &command{id: "edit.paste", title: "Paste", desc: "Paste into the selection, adjusting references", edits: (*Model).pasteTarget, run: func(m *Model) tea.Cmd { return m.paste(false) }},32+ &command{id: "edit.paste_values", title: "Paste values only", desc: "Paste the copied values without formulas", edits: (*Model).pasteTarget, run: func(m *Model) tea.Cmd { return m.paste(true) }},3333 )3434 keymap["ctrl+c"] = "edit.copy"3535 keymap["ctrl+x"] = "edit.cut"@@ -138,6 +138,9 @@ func (m *Model) pasteText(content string) bool {
138138 m.fail(sheet.ErrPasteEdge.Error())139139 return true140140 }141+ if m.refusePivot(r) {142+ return true143+ }141144 m.sheet.Batch(sheet.Change{Label: "paste into " + r.String(), Focus: r}, func() error {142145 for i, row := range rows {143146 for j := range width {@@ -29,6 +29,11 @@ type command struct {
2929 // checked, when set, makes the command a setting: menus show a ✓3030 // while it is on.3131 checked func(m *Model) bool32+33+ // edits, when set, is the range of the sheet shown that the command34+ // changes. A command that would change a pivot table's results is35+ // refused, as Sheets refuses (see pivot.go).36+ edits func(m *Model) sheet.Rect3237 }3338 3439 // available reports whether the command can run in m's current state.@@ -118,17 +123,20 @@ func (m *Model) runCommand(id string) tea.Cmd {
118123 // one span times every one of them.119124 span := telemetry.Start("command", slog.String("id", id))120125 defer span.End()126+ if c.edits != nil && m.refusePivot(c.edits(m)) {127+ return nil128+ }121129 return c.run(m)122130 }123131 124132 func init() {125133 register(126- &command{id: "edit", title: "Edit cell", desc: "Edit the active cell's contents", run: (*Model).startEdit},134+ &command{id: "edit", title: "Edit cell", desc: "Edit the active cell's contents", run: (*Model).startEdit, edits: cellTarget},127135 &command{id: "goto", title: "Go to", desc: "Move to a cell address", run: func(m *Model) tea.Cmd {128136 m.openGoto()129137 return nil130138 }},131- &command{id: "clear", title: "Clear", desc: "Clear the contents of the selected cells", run: func(m *Model) tea.Cmd {139+ &command{id: "clear", title: "Clear", desc: "Clear the contents of the selected cells", edits: (*Model).selection, run: func(m *Model) tea.Cmd {132140 m.sheet.EraseRange(m.selection())133141 m.changed = true134142 return nil@@ -219,7 +219,7 @@ func TestFilterButtonClick(t *testing.T) {
219219 t.Fatalf("button at %d in %q", x, line(m, headerLine))220220 }221221 click(m, x, headerLine, 0)222- if p, ok := m.overlay.(*filterPicker); !ok || p.col != 1 {222+ if p, ok := m.overlay.(*filterPicker); !ok || !strings.HasPrefix(p.title, "Filter B") {223223 t.Fatalf("overlay %T", m.overlay)224224 }225225 // Clicking a value toggles it; clicking outside cancels.@@ -20,40 +20,40 @@ func init() {
2020 register(2121 &command{id: "edit.undo", title: "Undo", desc: "Undo the last change", run: (*Model).undo},2222 &command{id: "edit.redo", title: "Redo", desc: "Redo the last undone change", run: (*Model).redo},23- &command{id: "edit.fill_down", title: "Fill down", desc: "Copy the top row of the selection into the rows below it", run: func(m *Model) tea.Cmd {23+ &command{id: "edit.fill_down", title: "Fill down", desc: "Copy the top row of the selection into the rows below it", edits: (*Model).selection, run: func(m *Model) tea.Cmd {2424 return m.fill(m.sheet.FillDown)2525 }},26- &command{id: "edit.fill_right", title: "Fill right", desc: "Copy the left column of the selection into the columns right of it", run: func(m *Model) tea.Cmd {26+ &command{id: "edit.fill_right", title: "Fill right", desc: "Copy the left column of the selection into the columns right of it", edits: (*Model).selection, run: func(m *Model) tea.Cmd {2727 return m.fill(m.sheet.FillRight)2828 }},29- &command{id: "insert.row_above", title: "Insert rows above", desc: "Insert as many rows above the selection as it spans", run: func(m *Model) tea.Cmd {29+ &command{id: "insert.row_above", title: "Insert rows above", desc: "Insert as many rows above the selection as it spans", edits: rowsTarget, run: func(m *Model) tea.Cmd {3030 r := m.selection()3131 return m.structural(m.sheet.InsertRows(r.From.Row, r.To.Row-r.From.Row+1))3232 }},33- &command{id: "insert.row_below", title: "Insert rows below", desc: "Insert as many rows below the selection as it spans", run: func(m *Model) tea.Cmd {33+ &command{id: "insert.row_below", title: "Insert rows below", desc: "Insert as many rows below the selection as it spans", edits: rowsBelowTarget, run: func(m *Model) tea.Cmd {3434 r := m.selection()3535 if r.To.Row == sheet.MaxRows-1 {3636 return m.structural(sheet.ErrPushedOff)3737 }3838 return m.structural(m.sheet.InsertRows(r.To.Row+1, r.To.Row-r.From.Row+1))3939 }},40- &command{id: "insert.col_left", title: "Insert columns left", desc: "Insert as many columns left of the selection as it spans", run: func(m *Model) tea.Cmd {40+ &command{id: "insert.col_left", title: "Insert columns left", desc: "Insert as many columns left of the selection as it spans", edits: colsTarget, run: func(m *Model) tea.Cmd {4141 r := m.selection()4242 return m.structural(m.sheet.InsertCols(r.From.Col, r.To.Col-r.From.Col+1))4343 }},44- &command{id: "insert.col_right", title: "Insert columns right", desc: "Insert as many columns right of the selection as it spans", run: func(m *Model) tea.Cmd {44+ &command{id: "insert.col_right", title: "Insert columns right", desc: "Insert as many columns right of the selection as it spans", edits: colsRightTarget, run: func(m *Model) tea.Cmd {4545 r := m.selection()4646 if r.To.Col == sheet.MaxCols-1 {4747 return m.structural(sheet.ErrPushedOff)4848 }4949 return m.structural(m.sheet.InsertCols(r.To.Col+1, r.To.Col-r.From.Col+1))5050 }},51- &command{id: "delete.row", title: "Delete rows", desc: "Delete the selected rows; references to them become #REF!", run: func(m *Model) tea.Cmd {51+ &command{id: "delete.row", title: "Delete rows", desc: "Delete the selected rows; references to them become #REF!", edits: rowsTarget, run: func(m *Model) tea.Cmd {5252 r := m.selection()5353 m.sheet.DeleteRows(r.From.Row, r.To.Row-r.From.Row+1)5454 return nil5555 }},56- &command{id: "delete.col", title: "Delete columns", desc: "Delete the selected columns; references to them become #REF!", run: func(m *Model) tea.Cmd {56+ &command{id: "delete.col", title: "Delete columns", desc: "Delete the selected columns; references to them become #REF!", edits: colsTarget, run: func(m *Model) tea.Cmd {5757 r := m.selection()5858 m.sheet.DeleteCols(r.From.Col, r.To.Col-r.From.Col+1)5959 return nil@@ -37,6 +37,9 @@ type entry struct {
3737 }3838 3939 func (m *Model) startEntry(md mode, text string) {40+ if m.refusePivot(sheet.Rect{From: m.cur, To: m.cur}) {41+ return42+ }4043 m.mode = md4144 m.entry.home = nil4245 m.line.clear()@@ -47,7 +50,7 @@ func (m *Model) startEntry(md mode, text string) {
4750 // startEdit edits the active cell's contents with the caret at the end.4851 func (m *Model) startEdit() tea.Cmd {4952 m.startEntry(modeEdit, "")50- if c := m.sheet.Cell(m.cur); c != nil {53+ if c := m.sheet.Cell(m.cur); c != nil && m.mode == modeEdit {5154 m.line.set(c.Input)5255 }5356 return nil@@ -72,7 +72,7 @@ func (m *Model) dragFillTo(a sheet.Addr) {
7272 func (m *Model) finishFill() {7373 m.mouse.drag = dragNone7474 src, dst := m.selection(), m.mouse.fillTo75- if dst == src {75+ if dst == src || m.refusePivot(dst) {7676 return7777 }7878 span := telemetry.Start("fill", slog.Int("cells", (dst.To.Row-dst.From.Row+1)*(dst.To.Col-dst.From.Col+1)))@@ -94,16 +94,23 @@ func (g *grid) filterButtonX(c int) int {
9494 return g.colStart(c) + (w-lw)/2 + lw - 19595 }9696 97-// filterPicker is the values list and condition for one column.97+// filterPicker is the values list and condition for one column: of the98+// sheet's filter, or of a pivot table's (pivoteditor.go), which set what99+// applying and cancelling do.98100 type filterPicker struct {99- col int100- values []sheet.FilterValue101- checked map[string]bool102- cond sheet.Condition103- field int // 0 is the search, 1 the condition's value104- fields [2]string // the text of each field105- shown []int // values matching the search, best first106- list // over the rows: "Select all", then shown101+ title string102+ x int // where the box goes, e.g. over its column103+ onApply func(m *Model, cr sheet.Criteria)104+ // onCancel, when set, runs after Esc or a click outside closes the105+ // picker.106+ onCancel func(m *Model)107+ values []sheet.FilterValue108+ checked map[string]bool109+ cond sheet.Condition110+ field int // 0 is the search, 1 the condition's value111+ fields [2]string // the text of each field112+ shown []int // values matching the search, best first113+ list // over the rows: "Select all", then shown107114 }108115 109116 const filterID = "filter"@@ -118,8 +125,19 @@ func (m *Model) openFilterPicker(col int) {
118125 if !on || col < r.From.Col || col > r.To.Col {119126 return120127 }121- f := m.sheet.Filter()122- p := &filterPicker{col: col, values: m.sheet.FilterValues(col), checked: map[string]bool{}, cond: f.Cols[col].Cond}128+ title := "Filter " + sheet.ColName(col)129+ if h := m.sheet.ShownText(sheet.Addr{Col: col, Row: r.From.Row}); h != "" {130+ title += " " + h131+ }132+ m.openValuesPicker(title, m.colStart(col), m.sheet.FilterValues(col), m.sheet.Filter().Cols[col].Cond,133+ func(m *Model, cr sheet.Criteria) { m.filterColumn(col, cr) })134+}135+136+// openValuesPicker opens a filter picker titled title at screen column x137+// over values, with cond as the condition, calling apply with the138+// criteria chosen.139+func (m *Model) openValuesPicker(title string, x int, values []sheet.FilterValue, cond sheet.Condition, apply func(*Model, sheet.Criteria)) *filterPicker {140+ p := &filterPicker{title: title, x: x, onApply: apply, values: values, checked: map[string]bool{}, cond: cond}123141 for _, v := range p.values {124142 p.checked[v.Text] = v.Shown125143 }@@ -127,6 +145,15 @@ func (m *Model) openFilterPicker(col int) {
127145 m.openOverlay(p)128146 m.line.clear()129147 p.search()148+ return p149+}150+151+// close closes the picker without applying it.152+func (p *filterPicker) close(m *Model) {153+ m.closeOverlay()154+ if p.onCancel != nil {155+ p.onCancel(m)156+ }130157 }131158 132159 func (p *filterPicker) indicator() string { return "FILTER" }@@ -218,12 +245,17 @@ func (p *filterPicker) apply(m *Model) {
218245 cr.Cond = sheet.Condition{}219246 }220247 m.closeOverlay()248+ p.onApply(m, cr)249+}250+251+// filterColumn sets the criteria of column col of the sheet's filter.252+func (m *Model) filterColumn(col int, cr sheet.Criteria) {221253 span := telemetry.Start("filter")222- m.sheet.FilterColumn(p.col, cr)254+ m.sheet.FilterColumn(col, cr)223255 span.End(slog.Int("hidden", m.sheet.HiddenRows()))224256 m.changed = true225257 if n := m.sheet.HiddenRows(); n > 0 {226- m.note = "Filtered column " + sheet.ColName(p.col) + ": " + rowCount(n) + " hidden"258+ m.note = "Filtered column " + sheet.ColName(col) + ": " + rowCount(n) + " hidden"227259 } else {228260 m.note = "The filter shows every row"229261 }@@ -240,7 +272,7 @@ func (p *filterPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
240272 n := len(p.shown) + 1241273 switch key := k.String(); {242274 case key == "esc":243- m.closeOverlay()275+ p.close(m)244276 case key == "enter":245277 p.apply(m)246278 case key == "tab" || key == "shift+tab":@@ -274,7 +306,7 @@ func (p *filterPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
274306 func (p *filterPicker) mouse(m *Model, e mouseEvent) tea.Cmd {275307 if e.box != filterID {276308 if e.kind == mousePress {277- m.closeOverlay()309+ p.close(m)278310 }279311 return nil280312 }@@ -343,7 +375,7 @@ func (p *filterPicker) box(m *Model) (x, y, inner int) {
343375 inner = clamp(w+10, 36, 48)344376 inner = min(inner, m.width-2)345377 h := p.rows(m) + filterFirstRow + 1346- x, y = m.clampBox(m.colStart(p.col), gridTop, inner+2, h)378+ x, y = m.clampBox(p.x, gridTop, inner+2, h)347379 return x, min(y, max(m.height-1-h, 0)), inner348380 }349381 @@ -419,12 +451,7 @@ func (p *filterPicker) layout(m *Model) []box {
419451 text += muted.Render(theme.PadLeft(count, len(count)+1)) + base.Render(" ")420452 lines = append(lines, ansi.Truncate(text, inner, ""))421453 }422- title := "Filter " + sheet.ColName(p.col)423- if r, ok := m.sheet.FilterRange(); ok {424- if h := m.sheet.ShownText(sheet.Addr{Col: p.col, Row: r.From.Row}); h != "" {425- title += " " + h426- }427- }454+ title := p.title428455 footer := strconv.Itoa(len(p.shown)) + " of " + strconv.Itoa(len(p.values))429456 return []box{{id: filterID, x: x, y: y, lines: m.th.Frame(inner, title, footer, lines)}}430457 }@@ -58,7 +58,7 @@ var alignments = []struct {
5858 func init() {5959 for _, nf := range numberFormats {6060 f, title := nf.f, nf.title61- register(&command{id: nf.id, title: title, desc: nf.desc, run: func(m *Model) tea.Cmd {61+ register(&command{id: nf.id, title: title, desc: nf.desc, edits: (*Model).selection, run: func(m *Model) tea.Cmd {6262 r := m.selection()6363 m.sheet.SetFormat(r, f)6464 m.formatted(title + " format")@@ -67,7 +67,7 @@ func init() {
6767 }6868 for _, ts := range textStyles {6969 get, title := ts.get, ts.title70- register(&command{id: ts.id, title: title, desc: ts.desc, run: func(m *Model) tea.Cmd {70+ register(&command{id: ts.id, title: title, desc: ts.desc, edits: (*Model).selection, run: func(m *Model) tea.Cmd {7171 var cur sheet.Style7272 if c := m.sheet.Cell(m.cur); c != nil {7373 cur = c.Style@@ -88,7 +88,7 @@ func init() {
8888 }8989 for _, al := range alignments {9090 a, title := al.a, al.title91- register(&command{id: al.id, title: title, desc: al.desc, run: func(m *Model) tea.Cmd {91+ register(&command{id: al.id, title: title, desc: al.desc, edits: (*Model).selection, run: func(m *Model) tea.Cmd {9292 r := m.selection()9393 m.sheet.Batch(sheet.Change{Label: "align " + r.String() + " " + strings.ToLower(title), Focus: r}, func() error {9494 m.sheet.SetStyle(r, func(s *sheet.Style) { s.Align = a })@@ -99,17 +99,17 @@ func init() {
9999 }})100100 }101101 register(102- &command{id: "format.decimals_more", title: "Increase decimal places", desc: "Show one more decimal place", run: func(m *Model) tea.Cmd {102+ &command{id: "format.decimals_more", title: "Increase decimal places", desc: "Show one more decimal place", edits: (*Model).selection, run: func(m *Model) tea.Cmd {103103 m.sheet.AdjustDecimals(m.selection(), 1)104104 m.formatted("One more decimal place")105105 return nil106106 }},107- &command{id: "format.decimals_less", title: "Decrease decimal places", desc: "Show one less decimal place", run: func(m *Model) tea.Cmd {107+ &command{id: "format.decimals_less", title: "Decrease decimal places", desc: "Show one less decimal place", edits: (*Model).selection, run: func(m *Model) tea.Cmd {108108 m.sheet.AdjustDecimals(m.selection(), -1)109109 m.formatted("One less decimal place")110110 return nil111111 }},112- &command{id: "format.clear", title: "Clear formatting", desc: "Reset number formats and text styles, keeping contents", run: func(m *Model) tea.Cmd {112+ &command{id: "format.clear", title: "Clear formatting", desc: "Reset number formats and text styles, keeping contents", edits: (*Model).selection, run: func(m *Model) tea.Cmd {113113 m.sheet.ClearFormatting(m.selection())114114 m.formatted("Formatting cleared")115115 return nil@@ -64,7 +64,7 @@ var menuBar = []menuDef{
6464 {cmd: "insert.col_left", title: "Column left"}, {cmd: "insert.col_right", title: "Column right"}, sep,6565 {cmd: "insert.selection"}, sep,6666 {cmd: "sheet.new", title: "Sheet"}, sep,67- {cmd: "insert.chart"},67+ {cmd: "insert.chart"}, {cmd: "data.pivot"},6868 }},6969 {title: "Format", accel: 'o', items: []menuItem{7070 {title: "Number", items: []menuItem{@@ -89,6 +89,7 @@ var menuBar = []menuDef{
8989 {cmd: "data.sort_range", title: "Advanced range sorting options"},9090 }}, sep,9191 {cmd: "data.filter"}, {cmd: "data.filter_column"}, {cmd: "data.filter_remove"}, sep,92+ {cmd: "data.pivot"}, {cmd: "data.pivot_edit"}, {cmd: "data.frequency"}, sep,9293 {cmd: "data.named_ranges"}, {cmd: "data.define_name"}, sep,9394 {cmd: "data.precedents"}, {cmd: "data.dependents"}, sep,9495 {cmd: "jev.refresh"},@@ -0,0 +1,146 @@
1+package ui2+3+import (4+ "log/slog"5+ "strconv"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/telemetry"11+)12+13+// Pivot tables follow Sheets: Data > Pivot table summarizes the14+// selection, or the table around the active cell (its first row naming15+// the fields), on a new sheet named Pivot Table 1, and opens the pivot16+// editor (pivoteditor.go) to pick rows, columns, values and filters. The17+// engine keeps the results live; editing them is refused with a note on18+// the context line, as Sheets refuses. Data > Column stats counts the19+// values of the active column the same way, as VisiData's Shift+F: a20+// frequency table is a pivot like any other.21+22+func init() {23+ register(24+ &command{id: "data.pivot", title: "Pivot table", desc: "Summarize the selection, or the table around the active cell, on a new sheet",25+ run: (*Model).createPivot},26+ &command{id: "data.pivot_edit", title: "Edit pivot table", desc: "Change the pivot table's rows, columns, values and filters",27+ enabled: func(m *Model) bool { _, ok := m.sheet.Pivot(); return ok },28+ run: func(m *Model) tea.Cmd {29+ m.openPivotEditor(m.sheet.StateID(), nil)30+ return nil31+ }},32+ &command{id: "data.frequency", title: "Column stats (frequency)", desc: "Count each value of the active column, most frequent first, on a new sheet",33+ run: (*Model).frequency},34+ )35+ // VisiData's Shift+F; plain Shift+F types an F, as in Sheets.36+ keymap["alt+shift+f"] = "data.frequency"37+ keyAliases["alt+F"] = "alt+shift+f"38+}39+40+// refusePivot reports whether r overlaps a pivot table's results, which41+// can't be edited, saying so on the context line.42+func (m *Model) refusePivot(r sheet.Rect) bool {43+ if !m.sheet.InPivot(r) {44+ return false45+ }46+ m.note = sheet.ErrPivotEdit.Error()47+ return true48+}49+50+// Targets of editing commands, for command.edits.51+52+func cellTarget(m *Model) sheet.Rect { return sheet.Rect{From: m.cur, To: m.cur} }53+54+// rowsTarget is the whole rows of the selection and everything below,55+// which inserting or deleting rows moves.56+func rowsTarget(m *Model) sheet.Rect {57+ r := m.selection()58+ return sheet.Rect{From: sheet.Addr{Row: r.From.Row}, To: sheet.Addr{Col: sheet.MaxCols - 1, Row: sheet.MaxRows - 1}}59+}60+61+func rowsBelowTarget(m *Model) sheet.Rect {62+ r := rowsTarget(m)63+ r.From.Row = min(m.selection().To.Row+1, sheet.MaxRows-1)64+ return r65+}66+67+// colsTarget is the whole columns of the selection and everything right68+// of them.69+func colsTarget(m *Model) sheet.Rect {70+ r := m.selection()71+ return sheet.Rect{From: sheet.Addr{Col: r.From.Col}, To: sheet.Addr{Col: sheet.MaxCols - 1, Row: sheet.MaxRows - 1}}72+}73+74+func colsRightTarget(m *Model) sheet.Rect {75+ r := colsTarget(m)76+ r.From.Col = min(m.selection().To.Col+1, sheet.MaxCols-1)77+ return r78+}79+80+// pasteTarget is where Ctrl+V writes: the selection, and at least the81+// clip's size from the active cell.82+func (m *Model) pasteTarget() sheet.Rect {83+ r := m.selection()84+ if c := m.copied.clip; c != nil {85+ cols, rows := c.Size()86+ r.To.Col = min(max(r.To.Col, r.From.Col+cols-1), sheet.MaxCols-1)87+ r.To.Row = min(max(r.To.Row, r.From.Row+rows-1), sheet.MaxRows-1)88+ }89+ return r90+}91+92+// pivotData is the range a new pivot or frequency table reads: the data93+// commands' range, cut to the used cells, and whether it has a header row94+// and at least one row below it.95+func (m *Model) pivotData() (sheet.Rect, bool) {96+ r := m.dataRange()97+ used, ok := m.sheet.UsedRange()98+ if !ok {99+ return r, false100+ }101+ r.To.Row, r.To.Col = min(r.To.Row, used.To.Row), min(r.To.Col, used.To.Col)102+ return r, r.To.Row > r.From.Row && r.To.Col >= r.From.Col103+}104+105+// createPivot starts a pivot table on a new sheet and opens its editor.106+func (m *Model) createPivot() tea.Cmd {107+ r, ok := m.pivotData()108+ if !ok {109+ m.note = "Select the data to summarize first: a row of headers and the rows below it"110+ return nil111+ }112+ src, start := m.sheet, m.sheet.StateID()113+ s, err := m.book().CreatePivot(src, r, "", sheet.NewPivot(src, r))114+ if err != nil {115+ m.fail(err.Error())116+ return nil117+ }118+ m.showSheet(s)119+ m.openPivotEditor(start, src)120+ return nil121+}122+123+// frequency makes a frequency table of the active column on a new sheet.124+func (m *Model) frequency() tea.Cmd {125+ r, ok := m.pivotData()126+ col := m.cur.Col127+ if !ok || col < r.From.Col || col > r.To.Col {128+ m.note = "Move to a column of data first: a header and the values below it"129+ return nil130+ }131+ span := telemetry.Start("frequency", slog.Int("rows", r.To.Row-r.From.Row))132+ defer span.End()133+ p := sheet.FrequencyPivot(m.sheet, r, col)134+ field := m.book().FieldName(p, col)135+ s, err := m.book().CreatePivot(m.sheet, r, "Frequency of "+field, p)136+ if err != nil {137+ m.fail(err.Error())138+ return nil139+ }140+ m.showSheet(s)141+ out, _ := s.PivotRange()142+ distinct := max(out.To.Row-1, 0) // less the header and Grand Total rows143+ m.note = "Counted " + field + ": " + strconv.Itoa(distinct) + plural(distinct, " distinct value", " distinct values") +144+ " " + m.th.KeyHints("Ctrl+Z", "undo")145+ return nil146+}@@ -0,0 +1,189 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// salesTable types a table of sales and leaves A1 active.11+func salesTable(t *testing.T, m *Model) {12+ t.Helper()13+ press(t, m, "Region", "<tab>", "Product", "<tab>", "Units", "<enter>")14+ press(t, m, "East", "<tab>", "Pens", "<tab>", "10", "<enter>")15+ press(t, m, "West", "<tab>", "Ink", "<tab>", "4", "<enter>")16+ press(t, m, "East", "<tab>", "Ink", "<tab>", "6", "<enter>")17+ press(t, m, "<ctrl+home>")18+}19+20+// shows is what the cell at a shows on the sheet shown.21+func shows(m *Model, a string) string { return m.sheet.ShownText(addr(a)) }22+23+func TestPivotCreateAndEdit(t *testing.T) {24+ m := wideModel()25+ salesTable(t, m)26+ m.runCommand("data.pivot")27+ if m.sheet.Name() != "Pivot Table 1" || m.indicator() != "PIVOT" {28+ t.Fatalf("on %q in %s", m.sheet.Name(), m.indicator())29+ }30+ if s := screen(m); !strings.Contains(s, "Pivot table") || !strings.Contains(s, "Data Sheet1!A1:C4") {31+ t.Fatalf("editor not shown:\n%s", s)32+ }33+ // Space on Rows picks a field to add.34+ press(t, m, "<space>")35+ if !strings.Contains(screen(m), "Add to Rows") {36+ t.Fatalf("no field picker:\n%s", screen(m))37+ }38+ press(t, m, "reg", "<enter>")39+ if m.indicator() != "PIVOT" || shows(m, "A1") != "Region" || shows(m, "A2") != "East" {40+ t.Fatalf("after adding Region: %s, A1 %q A2 %q", m.indicator(), shows(m, "A1"), shows(m, "A2"))41+ }42+ // Down to Values, add Units: summed, as it holds numbers.43+ press(t, m, "<down>", "<down>", "<space>", "units", "<enter>")44+ if shows(m, "B1") != "SUM of Units" || shows(m, "B2") != "16" || shows(m, "B4") != "20" {45+ t.Fatalf("values: %q %q %q", shows(m, "B1"), shows(m, "B2"), shows(m, "B4"))46+ }47+ if !strings.Contains(screen(m), "‹ SUM ›") || !strings.Contains(line(m, m.height-1), "summarize by") {48+ t.Errorf("value line or hints missing:\n%s", screen(m))49+ }50+ press(t, m, "<left>")51+ if shows(m, "B1") != "MIN of Units" || shows(m, "B2") != "6" {52+ t.Errorf("after Left: %q %q", shows(m, "B1"), shows(m, "B2"))53+ }54+ press(t, m, "<right>", "s", "s", "s")55+ if shows(m, "B2") != "80.00%" {56+ t.Errorf("show as %% of grand total: %q", shows(m, "B2"))57+ }58+ // Enter keeps it all; each change is a step of its own.59+ press(t, m, "<enter>")60+ if m.mode != modeReady {61+ t.Fatalf("mode %v after Enter", m.mode)62+ }63+ press(t, m, "<ctrl+z>", "<ctrl+z>", "<ctrl+z>")64+ if shows(m, "B2") != "16" || !strings.Contains(line(m, contextLine), "Undid: show as") {65+ t.Errorf("undo: %q, %q", shows(m, "B2"), line(m, contextLine))66+ }67+ // Data > Edit pivot table opens it again.68+ m.runCommand("data.pivot_edit")69+ if m.indicator() != "PIVOT" {70+ t.Errorf("edit reopened %s", m.indicator())71+ }72+}73+74+func TestPivotEditorEscCancelsNew(t *testing.T) {75+ m := wideModel()76+ salesTable(t, m)77+ m.runCommand("data.pivot")78+ press(t, m, "<space>", "prod", "<enter>")79+ press(t, m, "<esc>")80+ if m.book().Len() != 1 || m.sheet.Name() != "Sheet1" || m.mode != modeReady {81+ t.Errorf("after Esc: %d sheets, on %s, mode %v", m.book().Len(), m.sheet.Name(), m.mode)82+ }83+ // Esc in the field picker goes back to the editor.84+ m.runCommand("data.pivot")85+ press(t, m, "<space>", "<esc>")86+ if m.indicator() != "PIVOT" {87+ t.Errorf("Esc in the picker left %s", m.indicator())88+ }89+ // Nothing to summarize far from the data.90+ press(t, m, "<esc>", "<f5>", "H20", "<enter>")91+ m.runCommand("data.pivot")92+ if m.book().Len() != 1 || !strings.Contains(line(m, contextLine), "Select the data") {93+ t.Errorf("pivoted nothing: %q", line(m, contextLine))94+ }95+}96+97+func TestPivotRefusesEditing(t *testing.T) {98+ m := wideModel()99+ salesTable(t, m)100+ m.runCommand("data.pivot")101+ press(t, m, "<space>", "reg", "<enter>", "<enter>")102+ press(t, m, "<down>")103+ for _, keys := range [][]string{{"x"}, {"<delete>"}, {"<enter>"}, {"<ctrl+alt+=>"}, {"<ctrl+b>"}, {"<ctrl+d>"}} {104+ press(t, m, keys...)105+ if m.mode != modeReady || !strings.Contains(line(m, contextLine), "Pivot table results can't be edited") {106+ t.Errorf("%v: mode %v, %q", keys, m.mode, line(m, contextLine))107+ }108+ press(t, m, "<esc>")109+ }110+ if shows(m, "A2") != "East" || m.sheet.Cell(addr("A2")).Style.Bold {111+ t.Errorf("A2 changed: %q", shows(m, "A2"))112+ }113+ // Copying works, and pastes values; beside the results is editable.114+ press(t, m, "<ctrl+c>", "<right>", "<right>", "<ctrl+v>")115+ if input(m, "C2") != "East" {116+ t.Errorf("pasted %q", input(m, "C2"))117+ }118+ press(t, m, "note", "<enter>")119+ if input(m, "C2") != "note" {120+ t.Errorf("typed beside: %q", input(m, "C2"))121+ }122+}123+124+func TestFrequencyTable(t *testing.T) {125+ m := wideModel()126+ salesTable(t, m)127+ press(t, m, "<right>", "<down>", "<alt+F>")128+ if m.sheet.Name() != "Frequency of Product" {129+ t.Fatalf("on %q, %q", m.sheet.Name(), line(m, contextLine))130+ }131+ want := [][]string{{"Product", "Count", "Percent"}, {"Ink", "2", "66.67%"}, {"Pens", "1", "33.33%"}, {"Grand Total", "3", "100.00%"}}132+ for r, row := range want {133+ for c, w := range row {134+ if got := m.sheet.ShownText(sheet.Addr{Col: c, Row: r}); got != w {135+ t.Errorf("%s = %q, want %q", sheet.Addr{Col: c, Row: r}, got, w)136+ }137+ }138+ }139+ if !strings.Contains(line(m, contextLine), "Counted Product: 2 distinct values") {140+ t.Errorf("note %q", line(m, contextLine))141+ }142+ // Live: a change to the data recounts.143+ src := m.book().Sheet(0)144+ src.Set(addr("B4"), "Pens")145+ if got := shows(m, "A2"); got != "Pens" {146+ t.Errorf("after an edit, A2 = %q", got)147+ }148+}149+150+func TestPivotFilterAndSource(t *testing.T) {151+ m := wideModel()152+ salesTable(t, m)153+ m.runCommand("data.pivot")154+ press(t, m, "<space>", "prod", "<enter>")155+ press(t, m, "<down>", "<down>", "<space>", "units", "<enter>")156+ // Filters: add Region, then uncheck West in its values.157+ press(t, m, "<down>", "<space>", "reg", "<enter>", "<space>")158+ if _, ok := m.overlay.(*filterPicker); !ok || !strings.Contains(screen(m), "Filter Region") {159+ t.Fatalf("no values picker:\n%s", screen(m))160+ }161+ press(t, m, "<down>", "<down>", "<space>", "<enter>")162+ if m.indicator() != "PIVOT" || shows(m, "B2") != "6" || shows(m, "A4") != "Grand Total" {163+ t.Errorf("filtered: %s, B2 %q, A4 %q", m.indicator(), shows(m, "B2"), shows(m, "A4"))164+ }165+ if !strings.Contains(screen(m), "1 hidden") {166+ t.Errorf("filter line:\n%s", screen(m))167+ }168+ // The data range is pointed at on its sheet.169+ press(t, m, "<home>", "<space>")170+ if m.mode != modePrompt || m.sheet.Name() != "Sheet1" {171+ t.Fatalf("mode %v on %s", m.mode, m.sheet.Name())172+ }173+ press(t, m, "<shift+up>", "<enter>")174+ p, _ := m.sheet.Pivot()175+ if m.sheet.Name() != "Pivot Table 1" || m.indicator() != "PIVOT" || p.Range.String() != "A1:C3" {176+ t.Errorf("after pointing: %s %s %s", m.sheet.Name(), m.indicator(), p.Range)177+ }178+}179+180+func TestPivotMenus(t *testing.T) {181+ m := wideModel()182+ press(t, m, "<alt+d>")183+ s := screen(m)184+ for _, want := range []string{"Pivot table", "Edit pivot table", "Column stats (frequency)"} {185+ if !strings.Contains(s, want) {186+ t.Errorf("Data menu lacks %q:\n%s", want, s)187+ }188+ }189+}@@ -0,0 +1,334 @@
1+package ui2+3+import (4+ "slices"5+ "strconv"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// What the pivot editor's keys do to the pivot, and how its fields read.13+14+// fieldName is the source field a field line is about.15+func (e *pivotEditor) fieldName(m *Model, p sheet.Pivot, it pivotItem) string {16+ return m.book().FieldName(p, fieldCol(p, it))17+}18+19+func fieldCol(p sheet.Pivot, it pivotItem) int {20+ switch it.sect {21+ case sectRows:22+ return p.Rows[it.i].Col23+ case sectColumns:24+ return p.Columns[it.i].Col25+ case sectValues:26+ return p.Values[it.i].Col27+ }28+ return p.Filters[it.i].Col29+}30+31+// groups returns the rows or the columns of p, for editing.32+func groups(p *sheet.Pivot, sect int) *[]sheet.PivotGroup {33+ if sect == sectColumns {34+ return &p.Columns35+ }36+ return &p.Rows37+}38+39+// fieldDetail is the right side of a field line: a group's order, a40+// value's summary as a chip to change, a filter's criteria.41+func (e *pivotEditor) fieldDetail(m *Model, p sheet.Pivot, it pivotItem, sel bool) string {42+ dim, chip := m.th.Muted, m.th.KeyChip43+ if sel {44+ dim, chip = m.th.MenuSelected, m.th.MenuSelected45+ }46+ switch it.sect {47+ case sectValues:48+ v := p.Values[it.i]49+ text := chip.Render("‹ " + v.Summarize.Title() + " ›")50+ if v.ShowAs != sheet.ShowValue {51+ text += dim.Render(" " + v.ShowAs.Title())52+ }53+ return text54+ case sectFilters:55+ return dim.Render(criteriaText(p.Filters[it.i].Criteria))56+ }57+ g := (*groups(&p, it.sect))[it.i]58+ return dim.Render(orderText(m, p, g))59+}60+61+// orderText says how a group is ordered: A→Z, or by a value's total.62+func orderText(m *Model, p sheet.Pivot, g sheet.PivotGroup) string {63+ if i := g.SortBy - 1; i >= 0 && i < len(p.Values) {64+ arrow := "↑"65+ if g.Desc {66+ arrow = "↓"67+ }68+ return m.book().ValueTitle(p, p.Values[i]) + " " + arrow69+ }70+ return orderName(g.Desc)71+}72+73+// criteriaText sums up a filter: the values it hides, or its condition.74+func criteriaText(cr sheet.Criteria) string {75+ switch {76+ case cr.Cond.Op != sheet.CondNone && len(cr.Hidden) > 0:77+ return cr.Cond.Op.Title() + ", " + strconv.Itoa(len(cr.Hidden)) + " hidden"78+ case cr.Cond.Op != sheet.CondNone:79+ return cr.Cond.Op.Title() + " " + cr.Cond.Arg80+ case len(cr.Hidden) > 0:81+ return strconv.Itoa(len(cr.Hidden)) + " hidden"82+ }83+ return "showing all"84+}85+86+// act does what Space does on a line.87+func (e *pivotEditor) act(m *Model, it pivotItem) tea.Cmd {88+ switch {89+ case it.kind == itemSource:90+ e.editSource(m)91+ case it.kind == itemSection:92+ e.addField(m, it.sect)93+ case it.kind == itemToggle:94+ e.set(m, "change grand totals", func(p *sheet.Pivot) {95+ if it.i == 0 {96+ p.RowTotals = !p.RowTotals97+ } else {98+ p.ColumnTotals = !p.ColumnTotals99+ }100+ })101+ case it.kind == itemField && it.sect == sectFilters:102+ e.editFilter(m, it.i)103+ case it.kind == itemField:104+ e.adjust(m, it, 1)105+ }106+ return nil107+}108+109+// adjust steps a field's setting d places: a group's order (A→Z, Z→A,110+// then by each value, smallest and largest first), or a value's summary.111+func (e *pivotEditor) adjust(m *Model, it pivotItem, d int) {112+ switch {113+ case it.kind == itemToggle:114+ e.act(m, it)115+ case it.kind != itemField || it.sect == sectFilters:116+ case it.sect == sectValues:117+ e.set(m, "summarize by", func(p *sheet.Pivot) {118+ fs := sheet.Summaries()119+ v := &p.Values[it.i]120+ v.Summarize = fs[wrap(slices.Index(fs, v.Summarize)+d, len(fs))]121+ v.Name = ""122+ })123+ default:124+ e.set(m, "order "+sectNames[it.sect], func(p *sheet.Pivot) {125+ g := &(*groups(p, it.sect))[it.i]126+ n := 2 * (len(p.Values) + 1)127+ k := wrap(2*g.SortBy+boolInt(g.Desc)+d, n)128+ if g.SortBy > len(p.Values) {129+ k = 0130+ }131+ g.SortBy, g.Desc = k/2, k%2 == 1132+ })133+ }134+}135+136+func wrap(i, n int) int { return ((i % n) + n) % n }137+138+func boolInt(b bool) int {139+ if b {140+ return 1141+ }142+ return 0143+}144+145+// cycleShowAs steps a value through Sheets' "Show as" choices.146+func (e *pivotEditor) cycleShowAs(m *Model, it pivotItem) {147+ if it.kind != itemField || it.sect != sectValues {148+ return149+ }150+ e.set(m, "show as", func(p *sheet.Pivot) {151+ all := sheet.ShowAsList()152+ v := &p.Values[it.i]153+ v.ShowAs = all[wrap(slices.Index(all, v.ShowAs)+1, len(all))]154+ v.Name = ""155+ })156+}157+158+// remove takes a field out of its section.159+func (e *pivotEditor) remove(m *Model, it pivotItem) {160+ if it.kind != itemField {161+ return162+ }163+ e.set(m, "remove "+e.fieldName(m, e.pivot(m), it)+" from "+sectNames[it.sect], func(p *sheet.Pivot) {164+ switch it.sect {165+ case sectValues:166+ p.Values = slices.Delete(p.Values, it.i, it.i+1)167+ for _, gs := range [][]sheet.PivotGroup{p.Rows, p.Columns} {168+ for k := range gs {169+ switch {170+ case gs[k].SortBy == it.i+1:171+ gs[k].SortBy, gs[k].Desc = 0, false172+ case gs[k].SortBy > it.i+1:173+ gs[k].SortBy--174+ }175+ }176+ }177+ case sectFilters:178+ p.Filters = slices.Delete(p.Filters, it.i, it.i+1)179+ default:180+ gs := groups(p, it.sect)181+ *gs = slices.Delete(*gs, it.i, it.i+1)182+ }183+ })184+ e.current(m)185+}186+187+// reorder moves a row or column field up or down within its section,188+// which nests the groups differently.189+func (e *pivotEditor) reorder(m *Model, it pivotItem, down bool) {190+ if it.kind != itemField || it.sect == sectFilters {191+ return192+ }193+ p := e.pivot(m)194+ j := it.i - 1195+ if down {196+ j = it.i + 1197+ }198+ if j < 0 || j >= sectLen(p, it.sect) {199+ return200+ }201+ e.set(m, "move "+e.fieldName(m, p, it), func(p *sheet.Pivot) {202+ swap := func(s []sheet.PivotGroup) { s[it.i], s[j] = s[j], s[it.i] }203+ if it.sect == sectValues {204+ p.Values[it.i], p.Values[j] = p.Values[j], p.Values[it.i]205+ return206+ }207+ swap(*groups(p, it.sect))208+ })209+ e.selectItem(m, pivotItem{kind: itemField, sect: it.sect, i: j})210+}211+212+// addField picks a field of the source to add to a section.213+func (e *pivotEditor) addField(m *Model, sect int) {214+ p := e.pivot(m)215+ used := map[int]bool{}216+ switch sect {217+ case sectRows, sectColumns:218+ for _, g := range slices.Concat(p.Rows, p.Columns) {219+ used[g.Col] = true220+ }221+ case sectFilters:222+ for _, f := range p.Filters {223+ used[f.Col] = true224+ }225+ }226+ var items []pickItem227+ for col := p.Range.From.Col; col <= p.Range.To.Col && !p.Lost; col++ {228+ name := m.book().FieldName(p, col)229+ items = append(items, pickItem{230+ title: name, name: len(name), detail: "column " + sheet.ColName(col), off: used[col],231+ desc: "Add " + name + " to " + sectNames[sect],232+ pick: func(m *Model) tea.Cmd {233+ m.closeOverlay()234+ e.add(m, sect, col)235+ e.reopen(m)236+ return nil237+ },238+ })239+ }240+ fp := newPicker(m, "Add to "+sectNames[sect], "Type a field name", 50, items)241+ fp.action = "add"242+ m.openOverlay(&fieldPicker{picker: fp, back: e})243+}244+245+// add puts the field in column col at the end of a section.246+func (e *pivotEditor) add(m *Model, sect, col int) {247+ name := m.book().FieldName(e.pivot(m), col)248+ e.set(m, "add "+name+" to "+sectNames[sect], func(p *sheet.Pivot) {249+ switch sect {250+ case sectValues:251+ p.Values = append(p.Values, sheet.PivotValue{Col: col, Summarize: m.book().DefaultSummarize(*p, col)})252+ case sectFilters:253+ p.Filters = append(p.Filters, sheet.PivotFilter{Col: col})254+ default:255+ gs := groups(p, sect)256+ *gs = append(*gs, sheet.PivotGroup{Col: col})257+ }258+ })259+ e.selectItem(m, pivotItem{kind: itemField, sect: sect, i: sectLen(e.pivot(m), sect) - 1})260+}261+262+// fieldPicker is the picker of fields to add; Esc returns to the editor.263+type fieldPicker struct {264+ *picker265+ back *pivotEditor266+}267+268+func (f *fieldPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {269+ if k.String() == "esc" {270+ m.closeOverlay()271+ f.back.reopen(m)272+ return nil273+ }274+ return f.picker.key(m, k)275+}276+277+func (f *fieldPicker) mouse(m *Model, e mouseEvent) tea.Cmd {278+ if e.box != pickerID && e.kind == mousePress {279+ m.closeOverlay()280+ f.back.reopen(m)281+ return nil282+ }283+ return f.picker.mouse(m, e)284+}285+286+// editFilter opens the values list of filter i, in the manner of a287+// filter's column.288+func (e *pivotEditor) editFilter(m *Model, i int) {289+ p := e.pivot(m)290+ f := p.Filters[i]291+ name := m.book().FieldName(p, f.Col)292+ x, _, _ := e.box(m)293+ fp := m.openValuesPicker("Filter "+name, x, m.book().PivotFilterValues(p, f.Col), f.Criteria.Cond,294+ func(m *Model, cr sheet.Criteria) {295+ e.set(m, "filter "+name, func(p *sheet.Pivot) { p.Filters[i].Criteria = cr })296+ e.reopen(m)297+ })298+ fp.onCancel = e.reopen299+}300+301+// editSource points at the data on its sheet, starting from the current302+// range; fields outside the new range are dropped.303+func (e *pivotEditor) editSource(m *Model) {304+ p := e.pivot(m)305+ src, home := m.book().Lookup(p.Source), m.sheet306+ if src == nil || src == home {307+ e.msg = "The source sheet " + p.Source + " doesn't exist"308+ return309+ }310+ m.closeOverlay()311+ m.showSheet(src)312+ if !p.Lost {313+ m.selectRect(p.Range)314+ }315+ back := func(m *Model) {316+ m.clearSelection()317+ m.showSheet(home)318+ e.reopen(m)319+ }320+ m.openRange("Pivot data:", func(m *Model, r sheet.Rect) tea.Cmd {321+ back(m)322+ e.set(m, "change the pivot's data to "+r.String(), func(p *sheet.Pivot) {323+ p.Source, p.Range, p.Lost = src.Name(), r, false324+ in := func(c int) bool { return c >= r.From.Col && c <= r.To.Col }325+ keep := func(g sheet.PivotGroup) bool { return !in(g.Col) }326+ p.Rows = slices.DeleteFunc(p.Rows, keep)327+ p.Columns = slices.DeleteFunc(p.Columns, keep)328+ p.Values = slices.DeleteFunc(p.Values, func(v sheet.PivotValue) bool { return !in(v.Col) })329+ p.Filters = slices.DeleteFunc(p.Filters, func(f sheet.PivotFilter) bool { return !in(f.Col) })330+ })331+ return nil332+ })333+ m.prompt.onCancel = back334+}@@ -0,0 +1,358 @@
1+package ui2+3+import (4+ "strings"5+6+ tea "charm.land/bubbletea/v2"7+ "charm.land/lipgloss/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/theme"12+)13+14+// pivotEditor is the pivot editor: a panel docked at the right of the15+// grid, in the manner of lazygit's side panels rather than Sheets' GUI16+// sidebar, listing the data range, the Rows, Columns, Values and Filters17+// with their fields, and the grand totals. The results update behind it18+// as it changes; each change is an undo step. Up/Down pick a line, Space19+// acts on it (adds a field to a section, opens a filter's values, flips20+// a toggle), Left/Right change a field's order or summary, Del removes21+// it. Enter keeps the changes; Esc undoes them, removing a pivot just22+// created.23+type pivotEditor struct {24+ start int // the workbook's state before editing, to undo back to25+ back *sheet.Sheet // for a new pivot, the sheet it summarizes, shown again on Esc26+ msg string // why the last change was refused, for the status line27+ list28+}29+30+// Sections of the editor, in order.31+const (32+ sectRows = iota33+ sectColumns34+ sectValues35+ sectFilters36+ numSects37+)38+39+var sectNames = [numSects]string{"Rows", "Columns", "Values", "Filters"}40+41+var sectDescs = [numSects]string{42+ "Group the rows by the values of a field",43+ "Spread the groups across columns by the values of a field",44+ "Summarize a field for each group: sum, count, average...",45+ "Leave out rows by the values of a field",46+}47+48+type pivotItemKind uint849+50+const (51+ itemSource pivotItemKind = iota52+ itemError53+ itemSection54+ itemField55+ itemToggle56+ itemSep57+)58+59+// pivotItem is a line of the editor: a field is item i of section sect,60+// a toggle is i (0 the total row, 1 the total column).61+type pivotItem struct {62+ kind pivotItemKind63+ sect, i int64+}65+66+const pivotEditorID = "pivot"67+68+// openPivotEditor edits the shown sheet's pivot. start is the state Esc69+// returns to; back, for a pivot just created, the sheet to show then.70+func (m *Model) openPivotEditor(start int, back *sheet.Sheet) {71+ e := &pivotEditor{start: start, back: back}72+ e.sel = 2 // the Rows section73+ m.clearSelection()74+ m.openOverlay(e)75+}76+77+func (e *pivotEditor) indicator() string { return "PIVOT" }78+79+func (e *pivotEditor) pivot(m *Model) sheet.Pivot {80+ p, _ := m.sheet.Pivot()81+ return p82+}83+84+// items lists the editor's lines for the pivot as it is now.85+func (e *pivotEditor) items(m *Model) []pivotItem {86+ p := e.pivot(m)87+ items := []pivotItem{{kind: itemSource}}88+ if m.sheet.PivotError() != "" {89+ items = append(items, pivotItem{kind: itemError})90+ }91+ items = append(items, pivotItem{kind: itemSep})92+ for sect := range numSects {93+ items = append(items, pivotItem{kind: itemSection, sect: sect})94+ for i := range sectLen(p, sect) {95+ items = append(items, pivotItem{kind: itemField, sect: sect, i: i})96+ }97+ }98+ return append(items, pivotItem{kind: itemSep}, pivotItem{kind: itemToggle}, pivotItem{kind: itemToggle, i: 1})99+}100+101+func sectLen(p sheet.Pivot, sect int) int {102+ return [numSects]int{len(p.Rows), len(p.Columns), len(p.Values), len(p.Filters)}[sect]103+}104+105+func selectable(it pivotItem) bool { return it.kind != itemSep && it.kind != itemError }106+107+// step moves the highlight d selectable lines, stopping at the ends.108+func (e *pivotEditor) step(items []pivotItem, d int) {109+ for i := e.sel + d; i >= 0 && i < len(items); i += d {110+ if selectable(items[i]) {111+ e.sel = i112+ return113+ }114+ }115+}116+117+// current is the highlighted line, kept on a selectable one.118+func (e *pivotEditor) current(m *Model) pivotItem {119+ items := e.items(m)120+ e.sel = clamp(e.sel, 0, len(items)-1)121+ if !selectable(items[e.sel]) {122+ e.step(items, -1)123+ }124+ return items[e.sel]125+}126+127+// selectItem highlights the line showing it, if any.128+func (e *pivotEditor) selectItem(m *Model, it pivotItem) {129+ for i, x := range e.items(m) {130+ if x == it {131+ e.sel = i132+ }133+ }134+}135+136+// set applies a change to the pivot as its own undo step.137+func (e *pivotEditor) set(m *Model, label string, fn func(p *sheet.Pivot)) {138+ p := e.pivot(m)139+ fn(&p)140+ e.msg = ""141+ if err := m.sheet.SetPivot(p, label); err != nil {142+ e.msg = err.Error()143+ }144+ m.changed = m.sheet.StateID() != m.saved145+}146+147+func (e *pivotEditor) key(m *Model, k tea.KeyPressMsg) tea.Cmd {148+ if _, ok := m.sheet.Pivot(); !ok {149+ m.closeOverlay()150+ return nil151+ }152+ it := e.current(m)153+ switch key := k.String(); key {154+ case "enter":155+ m.closeOverlay()156+ case "esc":157+ e.cancel(m)158+ case "up", "ctrl+p", "shift+tab":159+ e.step(e.items(m), -1)160+ case "down", "ctrl+n", "tab":161+ e.step(e.items(m), 1)162+ case "home", "pgup":163+ e.sel = 0164+ case "end", "pgdown":165+ e.sel = len(e.items(m)) - 1166+ case "shift+up", "shift+down":167+ e.reorder(m, it, key == "shift+down")168+ case "left", "right":169+ e.adjust(m, it, map[bool]int{false: -1, true: 1}[key == "right"])170+ case "space":171+ return e.act(m, it)172+ case "a", "+":173+ if it.kind == itemSection || it.kind == itemField {174+ e.addField(m, it.sect)175+ }176+ case "s":177+ e.cycleShowAs(m, it)178+ case "delete", "backspace", "x", "-":179+ e.remove(m, it)180+ }181+ return nil182+}183+184+// cancel undoes the editor's changes and closes it; a pivot just created185+// goes with its sheet.186+func (e *pivotEditor) cancel(m *Model) {187+ for m.sheet.StateID() != e.start && m.sheet.CanUndo() {188+ m.sheet.Undo()189+ }190+ m.closeOverlay()191+ m.afterSheetsChange(e.back, m.book().Active())192+ m.changed = m.sheet.StateID() != m.saved193+}194+195+// reopen returns to the editor after a picker or prompt.196+func (e *pivotEditor) reopen(m *Model) { m.openOverlay(e) }197+198+func (e *pivotEditor) mouse(m *Model, ev mouseEvent) tea.Cmd {199+ if ev.box != pivotEditorID {200+ if ev.kind == mousePress {201+ m.closeOverlay() // a click elsewhere keeps the changes202+ }203+ return nil204+ }205+ items := e.items(m)206+ i := e.top + ev.row - 1207+ switch {208+ case ev.kind == mouseWheel && ev.button == tea.MouseWheelUp:209+ e.step(items, -1)210+ case ev.kind == mouseWheel && ev.button == tea.MouseWheelDown:211+ e.step(items, 1)212+ case ev.kind != mousePress || ev.button != tea.MouseLeft || i < 0 || i >= len(items) || !selectable(items[i]):213+ case i == e.sel || items[i].kind == itemToggle:214+ e.sel = i215+ return e.act(m, items[i])216+ default:217+ e.sel = i218+ }219+ return nil220+}221+222+// rows is how many lines of the list show.223+func (e *pivotEditor) rows(m *Model, n int) int {224+ return max(min(n, m.height-1-gridTop-2), 1)225+}226+227+// box places the editor at the right of the grid.228+func (e *pivotEditor) box(m *Model) (x, y, inner int) {229+ inner = min(44, m.width-2)230+ return max(m.width-inner-2, 0), gridTop, inner231+}232+233+func (e *pivotEditor) layout(m *Model) []box {234+ x, y, inner := e.box(m)235+ items := e.items(m)236+ e.current(m)237+ rows := e.rows(m, len(items))238+ e.show(rows)239+ p := e.pivot(m)240+ var lines []string241+ for r := range rows {242+ i := e.top + r243+ if i >= len(items) {244+ break245+ }246+ lines = append(lines, e.line(m, p, items[i], i == e.sel, inner))247+ }248+ footer := ""249+ if out, ok := m.sheet.PivotRange(); ok && m.sheet.PivotError() == "" {250+ footer = "results " + out.String()251+ }252+ return []box{{id: pivotEditorID, x: x, y: y, lines: m.th.Frame(inner, "Pivot table", footer, lines)}}253+}254+255+// line draws one line of the editor, inner columns wide.256+func (e *pivotEditor) line(m *Model, p sheet.Pivot, it pivotItem, sel bool, inner int) string {257+ base, dim := m.th.MenuBar, m.th.Muted258+ if sel {259+ base, dim = m.th.MenuSelected, m.th.MenuSelected260+ }261+ var left, right string262+ switch it.kind {263+ case itemSep:264+ return theme.SepRow265+ case itemError:266+ return theme.Cells(m.th.Warning, " "+m.sheet.PivotError(), inner)267+ case itemSource:268+ left = base.Render(" Data ") + e.sourceText(m, p, sel)269+ case itemSection:270+ title := m.th.Title271+ if sel {272+ title = base273+ }274+ left = title.Render(" " + sectNames[it.sect])275+ if sel || sectLen(p, it.sect) == 0 {276+ right = dim.Render("Space add ")277+ }278+ case itemField:279+ left = base.Render(" " + e.fieldName(m, p, it))280+ right = e.fieldDetail(m, p, it, sel) + base.Render(" ")281+ case itemToggle:282+ on := p.RowTotals283+ label := "Grand total row"284+ if it.i == 1 {285+ on, label = p.ColumnTotals, "Grand total column"286+ }287+ check := "[ ] "288+ if on {289+ check = "[x] "290+ }291+ left = base.Render(" " + check + label)292+ }293+ return spreadIn(base, left, right, inner)294+}295+296+// spreadIn lays left and right out in w columns on base, cutting left to297+// make room for right.298+func spreadIn(base lipgloss.Style, left, right string, w int) string {299+ rw := ansi.StringWidth(right)300+ left = ansi.Truncate(left, max(w-rw-1, 1), "…")301+ gap := max(w-ansi.StringWidth(left)-rw, 0)302+ return ansi.Truncate(left+base.Render(strings.Repeat(" ", gap))+right, w, "")303+}304+305+func (e *pivotEditor) sourceText(m *Model, p sheet.Pivot, sel bool) string {306+ text := sheet.QuoteSheet(p.Source) + "!" + p.Range.String()307+ if p.Lost {308+ text = sheet.QuoteSheet(p.Source) + "!#REF!"309+ }310+ if sel {311+ return m.th.MenuSelected.Render(text)312+ }313+ return m.th.Key.Render(text)314+}315+316+func (e *pivotEditor) status(m *Model) (string, string) {317+ it := e.current(m)318+ desc, pairs := e.help(m, it)319+ if e.msg != "" {320+ desc = m.th.Warning.Render(e.msg)321+ }322+ pairs = append(pairs, "Enter", "done", "Esc", "cancel")323+ for {324+ keys := m.th.KeyHints(pairs...)325+ switch {326+ case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:327+ return desc, keys328+ case desc != "":329+ desc = ""330+ case len(pairs) > 4:331+ pairs = pairs[2:]332+ default:333+ return "", keys334+ }335+ }336+}337+338+// help is what the highlighted line is and the keys that act on it.339+func (e *pivotEditor) help(m *Model, it pivotItem) (string, []string) {340+ p := e.pivot(m)341+ switch it.kind {342+ case itemSource:343+ return "The data summarized; its first row names the fields", []string{"Space", "change"}344+ case itemSection:345+ return sectDescs[it.sect], []string{"Space", "add a field"}346+ case itemToggle:347+ return "Show totals of every group", []string{"Space", "on/off"}348+ }349+ remove := []string{"Del", "remove"}350+ switch it.sect {351+ case sectValues:352+ v := p.Values[it.i]353+ return v.Summarize.Desc(), append([]string{"←/→", "summarize by", "S", "show as"}, remove...)354+ case sectFilters:355+ return "Choose which values count, or a condition", append([]string{"Space", "values"}, remove...)356+ }357+ return "Order the groups by label or by a value", append([]string{"←/→", "order", "Shift+↑/↓", "move"}, remove...)358+}@@ -62,6 +62,9 @@ func (m *Model) sort(r sheet.Rect, keys []sheet.SortKey) tea.Cmd {
6262 m.note = "Nothing to sort"6363 return nil6464 }65+ if m.refusePivot(r) {66+ return nil67+ }6568 span := telemetry.Start("sort", slog.Int("rows", r.To.Row-r.From.Row+1), slog.Int("cols", r.To.Col-r.From.Col+1), slog.Int("keys", len(keys)))6669 m.sheet.SortRange(r, keys)6770 span.End()