cat / 012
clone

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

cat/module-path
012 / internal / ui / nbgridcmd.go
nbgridcmd.go 7.0K
1package ui23import (4 "slices"5 "strconv"67 tea "charm.land/bubbletea/v2"8 "github.com/charmbracelet/x/ansi"910 "src.rest/cat/012/internal/sheet"11 "src.rest/cat/012/internal/ui/nbview"12 "src.rest/cat/012/internal/ui/overlay"13)1415// Commands on an output's grid, and what the control panel says of it.16// The grid runs the commands that look at its values or change how they17// show (selecting, copying, finding, sorting, filtering, column widths,18// undoing those) and refuses the rest, which would change a value. A19// chart, a pivot table or a frequency table from the output is made on20// a sheet, where they live: the output goes to a sheet as G sends it,21// so what's made from it follows the cell's next run, and a notebook's22// cells stay code and what it printed.2324// gridCommands are the commands an output's grid runs itself.25var gridCommands = map[string]bool{26 "edit.copy": true, "edit.find": true, "edit.find_next": true, "edit.find_prev": true,27 "select.none": true, "select.all": true, "select.columns": true, "select.rows": true,28 "column.width": true, "column.reset": true, "menu.context": true, "data.dropdown": true,29 "data.sort_sheet_az": true, "data.sort_sheet_za": true, "data.sort_range_az": true, "data.sort_range_za": true, "data.sort_range": true,30 "data.filter": true, "data.filter_column": true, "data.filter_remove": true,31}3233// onSheet are the commands whose result goes on a sheet of its own or34// beside the data: run on the output sent to a sheet.35var onSheet = map[string]bool{"insert.chart": true, "data.pivot": true, "data.frequency": true}3637// allows reports whether a command runs on the grid.38func (g *outGrid) allows(id string) bool { return gridCommands[id] || gridTakes(id) || onSheet[id] }3940// gridMenu is the cell menu on an output's grid.41var gridMenu = []menuItem{42 {cmd: "edit.copy"}, sep, {cmd: "data.sort_sheet_az", title: "Sort A to Z"}, {cmd: "data.sort_sheet_za", title: "Sort Z to A"}, sep,43 {cmd: "data.filter"}, {cmd: "data.filter_remove"}, sep, {cmd: "insert.chart"}, {cmd: "data.pivot"},44}4546// cellMenu is the right-click menu of the cells.47func (m *Model) cellMenu() []menuItem {48 if m.out != nil {49 return gridMenu50 }51 return cellMenu52}5354// offers reports whether a menu on the grid lists a command: those it55// runs and the program's own. A nil grid, the program's model, lists56// every one.57func (g *outGrid) offers(id string) bool { return g == nil || g.allows(id) || notebookSafe(id) }5859// command takes a command run on the grid's model, reporting whether it60// did: the program's own commands go back to it, Edit shows the grid61// full-screen, a chart or pivot table goes on a sheet, and anything62// that would change a value is refused.63func (g *outGrid) command(id string) (tea.Cmd, bool) {64 p := g.parent65 switch {66 case g.allows(id) && !onSheet[id]:67 return nil, false68 case id == "edit":69 p.gridFull()70 case onSheet[id]:71 return p.outputOnSheet(g, id), true72 case notebookSafe(id):73 return p.runCommand(id), true74 default:75 g.child.note = commands[id].title + " would change the output: G sends it to a sheet, where its copy can change"76 }77 return nil, true78}7980// outputOnSheet runs command id on the output sent to a sheet: sent to a81// new sheet first unless it's on one, with the columns selected in the82// grid selected there, every row under the header.83func (m *Model) outputOnSheet(g *outGrid, id string) tea.Cmd {84 nb, v := m.sheet, m.nbView()85 if v == nil {86 return nil87 }88 name, ok := m.outputName(nb, g.id)89 if !ok {90 return nil91 }92 if _, _, sent := m.book().Region(name); !sent {93 m.sendToNewSheet(nb, name)94 }95 t, _, ok := m.book().Region(name)96 if !ok {97 return nil98 }99 table, ok := t.RegionTable(name)100 if !ok {101 m.note = "The output sent to " + t.Name() + " has no rows yet"102 return nil103 }104 c := g.child105 r := table106 if c.hasRange() {107 sel := c.selection()108 r.From.Col = table.From.Col + min(sel.From.Col, g.cols-1)109 r.To.Col = table.From.Col + min(sel.To.Col, g.cols-1)110 }111 v.CloseFull()112 m.nb.out.in = nil113 m.showSheet(t)114 m.selectRect(r)115 m.cur = sheet.Addr{Col: table.From.Col + min(c.cur.Col, g.cols-1), Row: r.From.Row}116 return m.runCommand(id)117}118119// outputName is the name of cell id of notebook nb, naming it first as120// sending it does when it has none.121func (m *Model) outputName(nb *sheet.Sheet, id int) (string, bool) {122 for i, c := range nb.NotebookCells() {123 if c.ID != id {124 continue125 }126 name := c.Name()127 if name == "" {128 name = m.book().FreeCellName("cell" + strconv.Itoa(i+1))129 m.renameCell(nb, c, name)130 }131 return name, true132 }133 return "", false134}135136// gridIndicator is the mode while a grid is entered: OUTPUT, or its137// menu's or prompt's.138func (m *Model) gridIndicator(g *outGrid) string {139 c := g.child140 switch {141 case c.overlay != nil:142 return c.overlay.Indicator()143 case c.mode == modePrompt:144 return c.prompt.indicator145 }146 return "OUTPUT"147}148149// gridBusy reports whether the grid's own context line is to be shown:150// a bar, a prompt, a menu, a resize or a message of its own.151func (g *outGrid) gridBusy() bool {152 c := g.child153 return c.mode != modeReady || c.overlay != nil || c.mouse.drag == dragResize154}155156// gridContext is the context line while grid g is entered: the grid's157// own when it has something to say, otherwise what's shown and the keys.158func (m *Model) gridContext(g *outGrid, v *nbview.View) string {159 c := g.child160 switch {161 case g.gridBusy():162 return c.contextLineText()163 case c.warn != "":164 return v.Rule(m.th.Warning.Render(c.warn), "", m.width)165 case c.note != "":166 return v.Rule(m.th.Hint.Render(c.note), "", m.width)167 }168 name, _ := v.Head()169 keys := []string{"Shift+arrows", "select", "Ctrl+C", "copy", "Ctrl+F", "find", "Enter", "full-screen", "Esc", "back"}170 if v.FullOpen() {171 keys = slices.Delete(keys, 6, 8)172 }173 for len(keys) > 4 && ansi.StringWidth("Output of "+name+m.th.KeyHints(keys...))+8 > m.width {174 keys = keys[2:] // the keys that matter most are last175 }176 return v.Rule(m.th.Muted.Render("Output of "+name), m.th.KeyHints(keys...), m.width)177}178179// gridCursor is where the terminal's caret goes while grid g is180// entered: in its bar or prompt, or its box's search field.181func (m *Model) gridCursor(g *outGrid, v *nbview.View) (int, int, bool) {182 x, y, ok := g.child.cursorPos()183 if !ok || y == formulaLine || y == contextLine {184 return x, y, ok185 }186 ox, oy := g.origin(v)187 return x + ox, windowY(y) + oy, true188}189190// rangeLabel names a selected range for the status line: its address,191// or on an output's grid the columns' names and the rows as numbered.192func (m *Model) rangeLabel(r sheet.Rect) string {193 if m.out == nil {194 return r.String()195 }196 name := func(c int) string { return ansi.Truncate(m.sheet.ShownText(sheet.Addr{Col: c}), 16, "…") }197 cols := name(r.From.Col)198 if r.To.Col > r.From.Col {199 cols += " to " + name(min(r.To.Col, m.out.cols-1))200 }201 from, to := max(r.From.Row, 1), min(r.To.Row, m.out.rows)202 if from == to {203 return cols + ", row " + strconv.Itoa(from)204 }205 return cols + ", rows " + strconv.Itoa(from) + " to " + strconv.Itoa(to)206}207208// boxWidth is how wide a box is drawn.209func boxWidth(b overlay.Box) int {210 w := 0211 for _, l := range b.Lines {212 w = max(w, ansi.StringWidth(l))213 }214 return w215}