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 / charteditor.go
charteditor.go 10.8K
1package ui23import (4 "strconv"5 "strings"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/overlay"12 "src.rest/cat/012/internal/ui/theme"13)1415// chartHost is what the chart editor and a selected chart act on. The16// model implements it. They stay in package ui: besides the charts of17// the sheet shown, they reach the grid's geometry (where a chart is on18// screen, the cell under the mouse), the pointer's shape, the chart's19// context menu, and the model's prompts, and hand the keys and clicks20// they don't take back to the grid, which takes more than a small21// interface.22type chartHost interface {23 styles() *theme.Theme24 size() (width, height int)25 sheetShown() *sheet.Sheet26 // syncChanged makes the modified flag follow the undo history.27 syncChanged()28 openOverlay(o overlay.Overlay)29 closeOverlay()30 runCommand(id string) tea.Cmd31 // selectChart selects chart i and returns the selection.32 selectChart(i int) *chartSel33 // forgetChart leaves chart commands no chart to act on but the one34 // under the active cell or the newest.35 forgetChart()36 // showChart scrolls chart i into view.37 showChart(i int)38 chartScreen(c sheet.Chart) (x, y int)39 chartAt(x, y int) int40 chartCellAt(x, y int) sheet.Addr41 setShape(shape string) tea.Cmd42 // holdsKeys reports whether keys can be held, and zoomed is chart c43 // spread over the grid, as Space held shows it (keyboard.go).44 holdsKeys() bool45 zoomed(c sheet.Chart) sheet.Chart46 showChartMenu(x, y int)47 // say puts msg on the context line.48 say(msg string)49 askText(label, initial string, done func(string), cancel func())50 pointRange(label string, done func(sheet.Rect), cancel func())51 selectRect(r sheet.Rect)52 clearSelection()53 // passKey and passMouse hand what a chart doesn't take to the grid,54 // as if nothing were selected.55 passKey(k tea.KeyPressMsg) tea.Cmd56 passMouse(e overlay.MouseEvent) tea.Cmd57 // recordChart records a chart inserted, edited, moved or resized, as58 // the command that does it answered with the chart; placed is set59 // for a move or resize without a command (chartmacro.go).60 recordChart(id string, i int, before, after sheet.Chart, placed bool)61}6263// chartEditor is the chart editor: a bar on the context line, in the64// spirit of the find bar, with the type as a row of chips and the options65// as toggles. A second bar (A) holds the value axis, gridlines and66// legend, as Sheets' Customize tab does; see chartaxes.go. Changes show67// at once; Enter keeps them and Esc undoes them, removing a chart that68// was just inserted.69type chartEditor struct {70 m chartHost // the model, through what the editor needs of it71 i int72 start int // the history before editing (Checkpoint), to undo back to73 before sheet.Chart // the chart when the editor opened, for a macro74 isNew bool75 axes bool // showing the axis options76}7778// openChartEditor edits chart i. start is the sheet's state to return to79// on Esc.80func (m *Model) openChartEditor(i int, isNew bool, start int) *chartEditor {81 charts := m.sheet.Charts()82 if i < 0 || i >= len(charts) {83 return nil84 }85 m.charts.last = i86 e := &chartEditor{m: m, i: i, start: start, before: charts[i], isNew: isNew}87 m.openOverlay(e)88 return e89}9091func (e *chartEditor) Indicator() string { return "CHART" }9293func (e *chartEditor) Layout() []overlay.Box { return nil }9495func (e *chartEditor) chart() sheet.Chart { return e.m.sheetShown().Charts()[e.i] }9697// set applies a change to the chart as its own undo step.98func (e *chartEditor) set(fn func(c *sheet.Chart)) {99 c := e.chart()100 fn(&c)101 e.m.sheetShown().SetChart(e.i, c, "edit chart")102 e.m.syncChanged()103}104105// keep closes the editor keeping the changes, and records them.106func (e *chartEditor) keep() {107 m := e.m108 m.closeOverlay()109 id := "chart.edit"110 if e.isNew {111 id = "insert.chart"112 m.say("Inserted a chart of " + e.chart().Data.String())113 }114 m.recordChart(id, e.i, e.before, e.chart(), false)115}116117func (e *chartEditor) Key(k tea.KeyPressMsg) tea.Cmd {118 m := e.m119 if e.i >= len(m.sheetShown().Charts()) {120 m.closeOverlay()121 return nil122 }123 key := strings.ToLower(k.String())124 if e.axes {125 e.axisKey(key)126 return nil127 }128 switch key {129 case "enter":130 e.keep()131 m.selectChart(e.i)132 case "esc":133 m.sheetShown().Book().UndoTo(e.start)134 m.syncChanged()135 m.closeOverlay()136 m.forgetChart()137 case "left", "right", "shift+tab", "tab":138 d := 1139 if key == "left" || key == "shift+tab" {140 d = -1141 }142 e.set(func(c *sheet.Chart) {143 n := len(sheet.ChartTypes)144 c.Type = sheet.ChartTypes[((int(c.Type)+d)%n+n)%n]145 })146 case editorAxes:147 e.axes = true148 default:149 e.optionKey(key)150 }151 return nil152}153154// optionKey handles the keys of the main bar's options: a type's number,155// the toggles, the range and the title.156func (e *chartEditor) optionKey(key string) {157 if n, err := strconv.Atoi(key); err == nil && n >= 1 && n <= len(sheet.ChartTypes) {158 e.set(func(c *sheet.Chart) { c.Type = sheet.ChartTypes[n-1] })159 return160 }161 switch key {162 case editorSwitch:163 e.set(func(c *sheet.Chart) { c.ByRow = !c.ByRow })164 case editorHeader:165 e.set(func(c *sheet.Chart) { c.Header = !c.Header })166 case editorLabels:167 e.set(func(c *sheet.Chart) { c.Labels = !c.Labels })168 case editorStack:169 if e.chart().Type.Stackable() {170 e.set(func(c *sheet.Chart) { c.Stack = (c.Stack + 1) % 3 })171 }172 case editorTrend:173 if e.chart().Type == sheet.ChartScatter {174 e.set(func(c *sheet.Chart) { c.Trend = !c.Trend })175 }176 case editorRange:177 e.editRange()178 case editorTitle:179 e.editTitle()180 }181}182183// reopen returns to the editor after a prompt.184func (e *chartEditor) reopen() { e.m.openOverlay(e) }185186// editRange asks for the data range by pointing, starting from the187// current one.188func (e *chartEditor) editRange() {189 m := e.m190 m.closeOverlay()191 m.selectRect(e.chart().Data)192 m.pointRange("Chart data:", func(r sheet.Rect) {193 m.clearSelection()194 e.set(func(c *sheet.Chart) { c.Data = r })195 e.reopen()196 }, func() {197 m.clearSelection()198 e.reopen()199 })200}201202func (e *chartEditor) editTitle() {203 m := e.m204 m.closeOverlay()205 m.askText("Chart title:", e.chart().Title, func(text string) {206 e.set(func(c *sheet.Chart) { c.Title = text })207 e.reopen()208 }, e.reopen)209}210211// editorPart is a piece of the editor bar: a type chip or a toggle.212type editorPart struct {213 text string214 key string // the key it stands for215}216217// chip is a part drawn as a toggle, highlighted when on.218func (e *chartEditor) chip(on bool, name, key string) editorPart {219 style := e.m.styles().Muted220 if on {221 style = e.m.styles().MenuSelected222 }223 return editorPart{text: style.Render(" " + name + " "), key: key}224}225226// parts returns the bar's parts, and how many of them lead it: the227// types, which are never dropped and have a wider gap after them. Compact,228// the types are one chip of the current type, which a click moves on.229func (e *chartEditor) parts(compact bool) ([]editorPart, int) {230 if e.axes {231 return e.axisParts(), 0232 }233 c := e.chart()234 var parts []editorPart235 for i, t := range sheet.ChartTypes {236 switch {237 case !compact:238 parts = append(parts, e.chip(t == c.Type, t.Title(), strconv.Itoa(i+1)))239 case t == c.Type:240 next := (i+1)%len(sheet.ChartTypes) + 1241 parts = append(parts, e.chip(true, "◂ "+t.Title()+" ▸", strconv.Itoa(next)))242 }243 }244 lead := len(parts)245 parts = append(parts, editorPart{text: e.m.styles().Key.Render(c.Data.String()), key: editorRange})246 switch {247 case c.Type.Stackable():248 parts = append(parts, e.chip(c.Stack != sheet.StackNone, c.Stack.Title(), editorStack))249 case c.Type == sheet.ChartScatter:250 parts = append(parts, e.chip(c.Trend, "Trend line", editorTrend))251 }252 series := "Series in columns"253 if c.ByRow {254 series = "Series in rows"255 }256 header, labels := "Header row", "Labels in column"257 if c.ByRow {258 header, labels = "Header column", "Labels in row"259 }260 return append(parts,261 e.chip(true, series, editorSwitch),262 e.chip(c.Header, header, editorHeader),263 e.chip(c.Labels, labels, editorLabels)), lead264}265266// spans lays the parts out on the context line and returns each part's267// x. On narrow screens the types become one chip when that leaves room268// for more options, and options that still don't fit are dropped from the269// right.270func (e *chartEditor) spans() ([]editorPart, []int) {271 parts, xs := e.fit(false)272 if all, _ := e.parts(false); len(parts) < len(all) && !e.axes {273 if cp, cxs := e.fit(true); len(cp)+len(sheet.ChartTypes)-1 > len(parts) {274 return cp, cxs275 }276 }277 return parts, xs278}279280// fit lays out the parts, dropping options from the right until they fit.281func (e *chartEditor) fit(compact bool) ([]editorPart, []int) {282 width, _ := e.m.size()283 parts, lead := e.parts(compact)284 for {285 xs := make([]int, len(parts))286 x := 0287 for i, p := range parts {288 if i == lead && lead > 0 {289 x += 2 // a wider gap between the types and the options290 }291 xs[i] = x292 x += ansi.StringWidth(p.text) + 1293 }294 if x-1 <= width || len(parts) <= max(lead, 1) {295 return parts, xs296 }297 parts = parts[:len(parts)-1]298 }299}300301func (e *chartEditor) ContextLine() (string, string) {302 if e.i >= len(e.m.sheetShown().Charts()) {303 return "", ""304 }305 parts, xs := e.spans()306 var b strings.Builder307 for i, p := range parts {308 b.WriteString(strings.Repeat(" ", xs[i]-ansi.StringWidth(b.String())))309 b.WriteString(p.text)310 }311 return b.String(), ""312}313314func (e *chartEditor) Mouse(ev overlay.MouseEvent) tea.Cmd {315 if ev.Kind != overlay.MousePress {316 return nil317 }318 if ev.Y != contextLine {319 // A click elsewhere keeps the changes and acts as usual.320 e.keep()321 return e.m.passMouse(ev)322 }323 parts, xs := e.spans()324 for i, p := range parts {325 if ev.X >= xs[i] && ev.X < xs[i]+ansi.StringWidth(p.text) {326 return e.Key(tea.KeyPressMsg{Code: rune(p.key[0]), Text: p.key})327 }328 }329 return nil330}331332func (e *chartEditor) Status() (string, string) {333 if e.axes {334 return "", e.axisStatus()335 }336 pairs := []string{"←/→", "type", "A", "axes and legend", "S", "switch rows/columns", "H", "header", "L", "labels", "R", "range", "T", "title", "Enter", "done", "Esc", "cancel"}337 if c := e.chart(); c.Type.Stackable() {338 pairs = append(pairs[:4], append([]string{"K", "stacking"}, pairs[4:]...)...)339 } else if c.Type == sheet.ChartScatter {340 pairs = append(pairs[:4], append([]string{"E", "trend line"}, pairs[4:]...)...)341 }342 width, _ := e.m.size()343 return "", fitHints(e.m.styles(), width, pairs)344}345346// fitHints renders key hints, leaving out the pairs before the last two347// (Enter and Esc) from the right until they fit in width.348func fitHints(th *theme.Theme, width int, pairs []string) string {349 for {350 keys := th.KeyHints(pairs...)351 if ansi.StringWidth(keys) <= width || len(pairs) <= 4 {352 return keys353 }354 pairs = append(pairs[:len(pairs)-6], pairs[len(pairs)-4:]...)355 }356}357358// Editor keys, also the toggles' mouse targets.359const (360 editorSwitch = "s"361 editorHeader = "h"362 editorLabels = "l"363 editorRange = "r"364 editorTitle = "t"365 editorStack = "k"366 editorTrend = "e"367 editorAxes = "a"368)