1package ui23import (4 "strings"56 tea "charm.land/bubbletea/v2"7 "charm.land/lipgloss/v2"8 "github.com/charmbracelet/x/ansi"910 "src.rest/cat/012/internal/sheet"11 "src.rest/cat/012/internal/ui/overlay"12 "src.rest/cat/012/internal/ui/picker"13 "src.rest/cat/012/internal/ui/theme"14)1516// pivotEditor is the pivot editor: a panel docked at the right of the17// grid, in the manner of lazygit's side panels rather than Sheets' GUI18// sidebar, listing the data range, the Rows, Columns, Values and Filters19// with their fields, and the grand totals. The results update behind it20// as it changes; each change is an undo step. Up/Down pick a line, Space21// acts on it (adds a field to a section, opens a filter's values, flips22// a toggle), Left/Right change a field's order or summary, Del removes23// it. Enter keeps the changes; Esc undoes them, removing a pivot just24// created.25type pivotEditor struct {26 m pivotHost // the model, through what the editor needs of it27 start int // the history before editing (Checkpoint), to undo back to28 back *sheet.Sheet // for a new pivot, the sheet it summarizes, shown again on Esc29 before string // the pivot when the editor opened, as a macro answers it30 msg string // why the last change was refused, for the status line31 overlay.List32}3334// pivotHost is what the pivot editor acts on. The model implements it.35// The editor stays in package ui: besides the pivot on the sheet shown,36// it opens the model's pickers, filter values list and range prompt and37// comes back from them, which takes more than a small interface.38type pivotHost interface {39 styles() *theme.Theme40 size() (width, height int)41 book() *sheet.Workbook42 // sheetShown is the sheet the pivot is on while it's edited.43 sheetShown() *sheet.Sheet44 // syncChanged makes the modified flag follow the undo history.45 syncChanged()46 openOverlay(o overlay.Overlay)47 closeOverlay()48 afterSheetsChange(prefer *sheet.Sheet, index int)49 showSheet(s *sheet.Sheet)50 selectRect(r sheet.Rect)51 clearSelection()52 newPicker(title, placeholder string, maxW int, items []picker.Item) *picker.Picker53 // pickValues opens a filter's values list at screen column x.54 pickValues(title string, x int, values []sheet.FilterValue, cond sheet.Condition, apply func(sheet.Criteria), cancel func())55 // pointRange asks for a range, pointed at or typed.56 pointRange(label string, done func(sheet.Rect), cancel func())57 // askText asks for text, such as a value's name.58 askText(label, initial string, done func(string), cancel func())59 // recordDialog records the pivot made, as the command id answered60 // with its definition.61 recordDialog(id, answer string)62}6364// Sections of the editor, in order.65const (66 sectRows = iota67 sectColumns68 sectValues69 sectFilters70 numSects71)7273var sectNames = [numSects]string{"Rows", "Columns", "Values", "Filters"}7475var sectDescs = [numSects]string{76 "Group the rows by the values of a field",77 "Spread the groups across columns by the values of a field",78 "Summarize a field for each group: sum, count, average...",79 "Leave out rows by the values of a field",80}8182type pivotItemKind uint88384const (85 itemSource pivotItemKind = iota86 itemError87 itemSection88 itemField89 itemToggle90 itemSep91)9293// pivotItem is a line of the editor: a field is item i of section sect,94// a toggle is i (0 the total row, 1 the total column).95type pivotItem struct {96 kind pivotItemKind97 sect, i int98}99100const pivotEditorID = "pivot"101102// openPivotEditor edits the shown sheet's pivot. start is the state Esc103// returns to; back, for a pivot just created, the sheet to show then.104func (m *Model) openPivotEditor(start int, back *sheet.Sheet) *pivotEditor {105 e := &pivotEditor{m: m, start: start, back: back}106 e.before = e.pivot(m).JSON()107 e.Sel = 2 // the Rows section108 m.clearSelection()109 m.openOverlay(e)110 return e111}112113func (e *pivotEditor) Indicator() string { return "PIVOT" }114115func (e *pivotEditor) pivot(m pivotHost) sheet.Pivot {116 p, _ := m.sheetShown().Pivot()117 return p118}119120// items lists the editor's lines for the pivot's current definition.121func (e *pivotEditor) items(m pivotHost) []pivotItem {122 p := e.pivot(m)123 items := []pivotItem{{kind: itemSource}}124 if m.sheetShown().PivotError() != "" {125 items = append(items, pivotItem{kind: itemError})126 }127 items = append(items, pivotItem{kind: itemSep})128 for sect := range numSects {129 items = append(items, pivotItem{kind: itemSection, sect: sect})130 for i := range sectLen(p, sect) {131 items = append(items, pivotItem{kind: itemField, sect: sect, i: i})132 }133 }134 return append(items, pivotItem{kind: itemSep}, pivotItem{kind: itemToggle}, pivotItem{kind: itemToggle, i: 1})135}136137func sectLen(p sheet.Pivot, sect int) int {138 return [numSects]int{len(p.Rows), len(p.Columns), len(p.Values), len(p.Filters)}[sect]139}140141func selectable(it pivotItem) bool { return it.kind != itemSep && it.kind != itemError }142143// step moves the highlight d selectable lines, stopping at the ends.144func (e *pivotEditor) step(items []pivotItem, d int) {145 for i := e.Sel + d; i >= 0 && i < len(items); i += d {146 if selectable(items[i]) {147 e.Sel = i148 return149 }150 }151}152153// current is the highlighted line, kept on a selectable one.154func (e *pivotEditor) current(m pivotHost) pivotItem {155 items := e.items(m)156 e.Sel = clamp(e.Sel, 0, len(items)-1)157 if !selectable(items[e.Sel]) {158 e.step(items, -1)159 }160 return items[e.Sel]161}162163// selectItem highlights the line showing it, if any.164func (e *pivotEditor) selectItem(m pivotHost, it pivotItem) {165 for i, x := range e.items(m) {166 if x == it {167 e.Sel = i168 }169 }170}171172// set applies a change to the pivot as its own undo step.173func (e *pivotEditor) set(m pivotHost, label string, fn func(p *sheet.Pivot)) {174 p := e.pivot(m)175 fn(&p)176 e.msg = ""177 if err := m.sheetShown().SetPivot(p, label); err != nil {178 e.msg = err.Error()179 }180 m.syncChanged()181}182183func (e *pivotEditor) Key(k tea.KeyPressMsg) tea.Cmd {184 m := e.m185 if _, ok := m.sheetShown().Pivot(); !ok {186 m.closeOverlay()187 return nil188 }189 it := e.current(m)190 switch key := k.String(); key {191 case "enter":192 e.keep(m)193 case "esc":194 e.cancel(m)195 case "up", "ctrl+p", "shift+tab":196 e.step(e.items(m), -1)197 case "down", "ctrl+n", "tab":198 e.step(e.items(m), 1)199 case "home", "pgup":200 e.Sel = 0201 case "end", "pgdown":202 e.Sel = len(e.items(m)) - 1203 case "shift+up", "shift+down":204 e.reorder(m, it, key == "shift+down")205 case "left", "right":206 e.adjust(m, it, map[bool]int{false: -1, true: 1}[key == "right"])207 case "space":208 return e.act(m, it)209 case "a", "+":210 if it.kind == itemSection || it.kind == itemField {211 e.addField(m, it.sect)212 }213 case "s":214 e.cycleShowAs(m, it)215 case "r", "f2":216 e.rename(m, it)217 case "delete", "backspace", "x", "-":218 e.remove(m, it)219 }220 return nil221}222223// cancel undoes the editor's changes and closes it; a pivot just created224// goes with its sheet.225func (e *pivotEditor) cancel(m pivotHost) {226 m.book().UndoTo(e.start)227 m.closeOverlay()228 m.afterSheetsChange(e.back, m.book().Active())229 m.syncChanged()230}231232// keep closes the editor keeping the changes, and records the pivot233// made: a new one as Data > Pivot table, a changed one as Edit pivot234// table, answered with its definition.235func (e *pivotEditor) keep(m pivotHost) {236 m.closeOverlay()237 id, now := "data.pivot_edit", e.pivot(m).JSON()238 if e.back != nil {239 id = "data.pivot"240 } else if now == e.before {241 return242 }243 m.recordDialog(id, now)244}245246// answer makes the pivot a macro's answer defines, as a line of the file247// writes it, and closes the editor.248func (e *pivotEditor) answer(m pivotHost, text string) error {249 p, err := sheet.ParsePivot(text)250 if err == nil {251 err = m.sheetShown().SetPivot(p, "")252 }253 m.syncChanged()254 m.closeOverlay()255 return err256}257258// reopen returns to the editor after a picker or prompt.259func (e *pivotEditor) reopen() { e.m.openOverlay(e) }260261func (e *pivotEditor) Mouse(ev overlay.MouseEvent) tea.Cmd {262 m := e.m263 if ev.Box != pivotEditorID {264 if ev.Kind == overlay.MousePress {265 e.keep(m) // a click elsewhere keeps the changes266 }267 return nil268 }269 items := e.items(m)270 i := e.Top + ev.Row - 1271 switch {272 case ev.Kind == overlay.MouseWheel && ev.Button == tea.MouseWheelUp:273 e.step(items, -1)274 case ev.Kind == overlay.MouseWheel && ev.Button == tea.MouseWheelDown:275 e.step(items, 1)276 case ev.Kind != overlay.MousePress || ev.Button != tea.MouseLeft || i < 0 || i >= len(items) || !selectable(items[i]):277 case i == e.Sel || items[i].kind == itemToggle:278 e.Sel = i279 return e.act(m, items[i])280 default:281 e.Sel = i282 }283 return nil284}285286// rows is how many lines of the list show.287func (e *pivotEditor) rows(m pivotHost, n int) int {288 _, height := m.size()289 return max(min(n, height-1-gridTop-2), 1)290}291292// box places the editor at the right of the grid.293func (e *pivotEditor) box(m pivotHost) (x, y, inner int) {294 width, _ := m.size()295 inner = min(44, width-2)296 return max(width-inner-2, 0), gridTop, inner297}298299func (e *pivotEditor) Layout() []overlay.Box {300 m := e.m301 x, y, inner := e.box(m)302 items := e.items(m)303 e.current(m)304 rows := e.rows(m, len(items))305 e.Show(rows)306 p := e.pivot(m)307 var lines []string308 for r := range rows {309 i := e.Top + r310 if i >= len(items) {311 break312 }313 lines = append(lines, e.line(m, p, items[i], i == e.Sel, inner))314 }315 footer := ""316 if out, ok := m.sheetShown().PivotRange(); ok && m.sheetShown().PivotError() == "" {317 footer = "results " + out.String()318 }319 return []overlay.Box{{ID: pivotEditorID, X: x, Y: y, Lines: m.styles().Frame(inner, "Pivot table", footer, lines)}}320}321322// line draws one line of the editor, inner columns wide.323func (e *pivotEditor) line(m pivotHost, p sheet.Pivot, it pivotItem, sel bool, inner int) string {324 base, dim := m.styles().MenuBar, m.styles().Muted325 if sel {326 base, dim = m.styles().MenuSelected, m.styles().MenuSelected327 }328 var left, right string329 switch it.kind {330 case itemSep:331 return theme.SepRow332 case itemError:333 return theme.Cells(m.styles().Warning, " "+m.sheetShown().PivotError(), inner)334 case itemSource:335 left = base.Render(" Data ") + e.sourceText(m, p, sel)336 case itemSection:337 title := m.styles().Title338 if sel {339 title = base340 }341 left = title.Render(" " + sectNames[it.sect])342 if sel || sectLen(p, it.sect) == 0 {343 right = dim.Render("Space add ")344 }345 case itemField:346 left = base.Render(" " + e.lineName(m, p, it))347 right = e.fieldDetail(m, p, it, sel) + base.Render(" ")348 case itemToggle:349 on := p.RowTotals350 label := "Grand total row"351 if it.i == 1 {352 on, label = p.ColumnTotals, "Grand total column"353 }354 check := "[ ] "355 if on {356 check = "[x] "357 }358 left = base.Render(" " + check + label)359 }360 return spreadIn(base, left, right, inner)361}362363// spreadIn lays left and right out in w columns on base, cutting left to364// make room for right.365func spreadIn(base lipgloss.Style, left, right string, w int) string {366 rw := ansi.StringWidth(right)367 left = ansi.Truncate(left, max(w-rw-1, 1), "…")368 gap := max(w-ansi.StringWidth(left)-rw, 0)369 return ansi.Truncate(left+base.Render(strings.Repeat(" ", gap))+right, w, "")370}371372func (e *pivotEditor) sourceText(m pivotHost, p sheet.Pivot, sel bool) string {373 text := sheet.QuoteSheet(p.Source) + "!" + p.Range.String()374 if p.Lost {375 text = sheet.QuoteSheet(p.Source) + "!#REF!"376 }377 if sel {378 return m.styles().MenuSelected.Render(text)379 }380 return m.styles().Key.Render(text)381}382383func (e *pivotEditor) Status() (string, string) {384 m := e.m385 it := e.current(m)386 width, _ := m.size()387 desc, pairs := e.help(m, it)388 if e.msg != "" {389 desc = m.styles().Warning.Render(e.msg)390 }391 pairs = append(pairs, "Enter", "done", "Esc", "cancel")392 for {393 keys := m.styles().KeyHints(pairs...)394 switch {395 case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= width:396 return desc, keys397 case desc != "":398 desc = ""399 case len(pairs) > 6:400 // Drop the line's last key but one, keeping its first, the401 // one that matters most, and Enter and Esc.402 pairs = append(pairs[:len(pairs)-6], pairs[len(pairs)-4:]...)403 case len(pairs) > 4:404 pairs = pairs[2:]405 default:406 return "", keys407 }408 }409}410411// help is what the highlighted line is and the keys that act on it.412func (e *pivotEditor) help(m pivotHost, it pivotItem) (string, []string) {413 p := e.pivot(m)414 switch it.kind {415 case itemSource:416 return "The data summarized; its first row names the fields", []string{"Space", "change"}417 case itemSection:418 return sectDescs[it.sect], []string{"Space", "add a field"}419 case itemToggle:420 return "Show totals of every group", []string{"Space", "on/off"}421 }422 remove := []string{"Del", "remove"}423 switch it.sect {424 case sectValues:425 v := p.Values[it.i]426 desc := v.Summarize.Desc()427 if v.Name != "" {428 desc = e.fieldName(m, p, it) + ": " + desc429 }430 return desc, append([]string{"←/→", "summary", "S", "show as", "R", "name"}, remove...)431 case sectFilters:432 return "Choose which values count, or a condition", append([]string{"Space", "values"}, remove...)433 }434 return "Order the groups by label or by a value", append([]string{"←/→", "order", "Shift+↑/↓", "move"}, remove...)435}