cat / 012
clone

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

Chart editor: stacking, trend line, and an axis and legend bar

Dami committed e34b723
K cycles stacking on column, bar and area charts, E toggles a scatter's
trend line, and A opens a second bar for the value axis (N minimum, X
maximum, typed as cells take numbers), a log scale (L), gridlines (G)
and the legend position (P). A prompt's cancel now closes it before its
handler runs, so Esc in the editor's prompts returns to the editor.
commit
e34b72346c07acc6a4059598923fa1d273f3245f
parent
c3be4a34aa4dd2ade6b31e32af239e5288d5c5d9
author
Dami <dami.etoile@gmail.com>
6 files changed, +311 -28
Ainternal/ui/chartaxes.go97++++++++++++++++++++++++
Ainternal/ui/chartaxes_test.go123++++++++++++++++++++++++++++++
Minternal/ui/charteditor.go109++++++++++++++++++++------
Minternal/ui/charts.go3+-
Minternal/ui/graphics.go1+
Minternal/ui/prompt.go6+-
A internal/ui/chartaxes.go +97 -0
@@ -0,0 +1,97 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// The chart editor's axis bar: the ends of the value axis, typed on the13+// context line (blank for automatic), a log scale, gridlines and where14+// the legend goes. Enter and Esc go back to the main bar, keeping the15+// changes; Esc there undoes them all.16+17+// Axis bar keys, also the chips' mouse targets.18+const (19+ axisMin = "n"20+ axisMax = "x"21+ axisLog = "l"22+ axisGrid = "g"23+ axisLegend = "p"24+)25+26+func (e *chartEditor) axisParts(m *Model) []editorPart {27+ c := e.chart(m)28+ end := func(name string, v float64, set bool, key string) editorPart {29+ text := name + " auto"30+ if set {31+ text = name + " " + strconv.FormatFloat(v, 'g', 10, 64)32+ }33+ return e.chip(set, text, key)34+ }35+ return []editorPart{36+ end("Min", c.Min, c.HasMin, axisMin),37+ end("Max", c.Max, c.HasMax, axisMax),38+ e.chip(c.Log, "Log", axisLog),39+ e.chip(!c.NoGrid, "Gridlines", axisGrid),40+ e.chip(c.Legend != sheet.LegendNone, "Legend "+c.Legend.String(), axisLegend),41+ }42+}43+44+func (e *chartEditor) axisKey(m *Model, key string) {45+ switch key {46+ case "enter", "esc":47+ e.axes = false48+ case axisMin:49+ e.editAxisEnd(m, "Axis minimum (blank for auto):", func(c *sheet.Chart) (*float64, *bool) { return &c.Min, &c.HasMin })50+ case axisMax:51+ e.editAxisEnd(m, "Axis maximum (blank for auto):", func(c *sheet.Chart) (*float64, *bool) { return &c.Max, &c.HasMax })52+ case axisLog:53+ e.set(m, func(c *sheet.Chart) { c.Log = !c.Log })54+ case axisGrid:55+ e.set(m, func(c *sheet.Chart) { c.NoGrid = !c.NoGrid })56+ case axisLegend:57+ e.set(m, func(c *sheet.Chart) { c.Legend = (c.Legend + 1) % 3 })58+ }59+}60+61+// editAxisEnd asks for an end of the value axis, as a number the way62+// cells take one ("500", "$1,200", "10%"); blank makes it automatic.63+func (e *chartEditor) editAxisEnd(m *Model, label string, end func(*sheet.Chart) (*float64, *bool)) {64+ c := e.chart(m)65+ v, set := end(&c)66+ initial := ""67+ if *set {68+ initial = strconv.FormatFloat(*v, 'g', -1, 64)69+ }70+ m.closeOverlay()71+ e.askAxisEnd(m, label, label, initial, end)72+}73+74+// askAxisEnd asks with the prompt label, and asks again, saying so, until75+// the answer is a number or blank.76+func (e *chartEditor) askAxisEnd(m *Model, label, question, initial string, end func(*sheet.Chart) (*float64, *bool)) {77+ m.openText(label, initial, func(m *Model, text string) tea.Cmd {78+ text = strings.TrimSpace(text)79+ n, _, ok := sheet.ParseValue(text)80+ switch {81+ case text == "":82+ e.set(m, func(c *sheet.Chart) { _, set := end(c); *set = false })83+ case !ok:84+ e.askAxisEnd(m, "Not a number. "+question, question, text, end)85+ return nil86+ default:87+ e.set(m, func(c *sheet.Chart) { v, set := end(c); *v, *set = n, true })88+ }89+ e.reopen(m)90+ return nil91+ })92+ m.prompt.onCancel = e.reopen93+}94+95+func (e *chartEditor) axisStatus(m *Model) string {96+ return fitHints(m, []string{"N", "minimum", "X", "maximum", "L", "log scale", "G", "gridlines", "P", "legend", "Enter", "back", "Esc", "back"})97+}
A internal/ui/chartaxes_test.go +123 -0
@@ -0,0 +1,123 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+func TestChartEditorStackingAndTrend(t *testing.T) {11+ m := wideModel()12+ chartTable(t, m)13+ m.runCommand("insert.chart")14+ if _, keys := m.overlay.Status(); !strings.Contains(line(m, contextLine), "Not stacked") || !strings.Contains(keys, "stacking") {15+ t.Errorf("no stacking chip: %q / %q", line(m, contextLine), keys)16+ }17+ press(t, m, "k")18+ if c := m.sheet.Charts()[0]; c.Stack != sheet.StackNormal || !strings.Contains(line(m, contextLine), "Stacked") {19+ t.Errorf("stack %v: %q", c.Stack, line(m, contextLine))20+ }21+ press(t, m, "k")22+ if c := m.sheet.Charts()[0]; c.Stack != sheet.StackPercent || !strings.Contains(screen(m), "100% ┤") {23+ t.Errorf("100%% stacking %v:\n%s", c.Stack, screen(m))24+ }25+ // Scatter has a trend line instead; K does nothing there.26+ press(t, m, "6")27+ if !strings.Contains(line(m, contextLine), "Trend line") || strings.Contains(line(m, contextLine), "stacked") {28+ t.Errorf("scatter bar: %q", line(m, contextLine))29+ }30+ press(t, m, "e", "k")31+ if c := m.sheet.Charts()[0]; c.Type != sheet.ChartScatter || !c.Trend || c.Stack != sheet.StackPercent {32+ t.Errorf("after E and K: %+v", c)33+ }34+ // Esc undoes every change, removing the new chart.35+ press(t, m, "<esc>")36+ if n := len(m.sheet.Charts()); n != 0 {37+ t.Errorf("%d charts after Esc", n)38+ }39+}40+41+func TestChartEditorAxes(t *testing.T) {42+ m := wideModel()43+ chartTable(t, m)44+ m.runCommand("insert.chart")45+ press(t, m, "a")46+ ctx := line(m, contextLine)47+ for _, want := range []string{"Min auto", "Max auto", "Log", "Gridlines", "Legend bottom"} {48+ if !strings.Contains(ctx, want) {49+ t.Errorf("axis bar lacks %q: %q", want, ctx)50+ }51+ }52+ // The minimum is typed as a cell takes a number.53+ press(t, m, "n")54+ if m.mode != modePrompt {55+ t.Fatalf("minimum prompt not open, mode %v", m.mode)56+ }57+ m.line.Buf, m.line.Pos = []rune("$500"), 458+ press(t, m, "<enter>")59+ if c := m.sheet.Charts()[0]; !c.HasMin || c.Min != 500 || !strings.Contains(line(m, contextLine), "Min 500") {60+ t.Errorf("min %v %v: %q", c.HasMin, c.Min, line(m, contextLine))61+ }62+ // Not a number: asked again, and Esc leaves the axis as it was.63+ press(t, m, "x")64+ m.line.Buf, m.line.Pos = []rune("lots"), 465+ press(t, m, "<enter>")66+ if m.mode != modePrompt || !strings.Contains(line(m, contextLine), "Not a number. Axis maximum") {67+ t.Errorf("not asked again: %q", line(m, contextLine))68+ }69+ press(t, m, "<esc>")70+ if c := m.sheet.Charts()[0]; c.HasMax || !strings.Contains(line(m, contextLine), "Max auto") {71+ t.Errorf("max %v %v: %q", c.HasMax, c.Max, line(m, contextLine))72+ }73+ press(t, m, "l", "g", "p")74+ c := m.sheet.Charts()[0]75+ if !c.Log || !c.NoGrid || c.Legend != sheet.LegendRight {76+ t.Errorf("toggles: %+v", c.ChartOptions)77+ }78+ if !strings.Contains(screen(m), "■ Rent") {79+ t.Errorf("right legend missing:\n%s", screen(m))80+ }81+ press(t, m, "p")82+ if c := m.sheet.Charts()[0]; c.Legend != sheet.LegendNone || strings.Contains(screen(m), "■ Rent") {83+ t.Errorf("legend none: %v", c.Legend)84+ }85+ // A blank minimum is automatic again.86+ press(t, m, "n")87+ m.line.Buf, m.line.Pos = nil, 088+ press(t, m, "<enter>")89+ if c := m.sheet.Charts()[0]; c.HasMin {90+ t.Errorf("min still set: %v", c.Min)91+ }92+ // Esc goes back to the main bar, keeping the changes.93+ press(t, m, "<esc>")94+ if _, ok := m.overlay.(*chartEditor); !ok || !strings.Contains(line(m, contextLine), "Column") {95+ t.Fatalf("not back on the main bar: %T %q", m.overlay, line(m, contextLine))96+ }97+ press(t, m, "<enter>")98+ if c := m.sheet.Charts()[0]; !c.Log || c.Legend != sheet.LegendNone {99+ t.Errorf("changes lost: %+v", c.ChartOptions)100+ }101+ // Undo takes back the last change.102+ press(t, m, "<esc>", "<ctrl+z>")103+ if c := m.sheet.Charts()[0]; !c.HasMin {104+ t.Errorf("undo: %+v", c.ChartOptions)105+ }106+}107+108+func TestChartEditorFitsNarrow(t *testing.T) {109+ m := wideModel()110+ chartTable(t, m)111+ m.runCommand("insert.chart")112+ press(t, m, "a")113+ m.Update(teaSize(60, 16))114+ if ctx := line(m, contextLine); !strings.Contains(ctx, "Legend bottom") {115+ t.Errorf("axis bar clipped at 60 columns: %q", ctx)116+ }117+ press(t, m, "<esc>")118+ for _, want := range []string{"Column", "Scatter"} {119+ if ctx := line(m, contextLine); !strings.Contains(ctx, want) {120+ t.Errorf("main bar lacks %q at 60 columns: %q", want, ctx)121+ }122+ }123+}
M internal/ui/charteditor.go +84 -25
@@ -13,13 +13,16 @@ import (
1313 1414 // chartEditor is the chart editor: a bar on the context line, in the1515 // spirit of the find bar, with the type as a row of chips and the options16-// as toggles. Changes show at once; Enter keeps them and Esc undoes them,17-// removing a chart that was just inserted.16+// as toggles. A second bar (A) holds the value axis, gridlines and17+// legend, as Sheets' Customize tab does; see chartaxes.go. Changes show18+// at once; Enter keeps them and Esc undoes them, removing a chart that19+// was just inserted.1820 type chartEditor struct {1921 m *Model // the model it acts on2022 i int2123 start int // the sheet's state before editing, to undo back to2224 isNew bool25+ axes bool // showing the axis options2326 }2427 2528 // openChartEditor edits chart i. start is the sheet's state to return to
@@ -52,7 +55,12 @@ func (e *chartEditor) Key(k tea.KeyPressMsg) tea.Cmd {
5255 m.closeOverlay()5356 return nil5457 }55- switch key := strings.ToLower(k.String()); key {58+ key := strings.ToLower(k.String())59+ if e.axes {60+ e.axisKey(m, key)61+ return nil62+ }63+ switch key {5664 case "enter":5765 m.selectChart(e.i)5866 if e.isNew {
@@ -74,20 +82,41 @@ func (e *chartEditor) Key(k tea.KeyPressMsg) tea.Cmd {
7482 n := len(sheet.ChartTypes)7583 c.Type = sheet.ChartTypes[((int(c.Type)+d)%n+n)%n]7684 })77- case "1", "2", "3", "4":78- e.set(m, func(c *sheet.Chart) { c.Type = sheet.ChartTypes[key[0]-'1'] })85+ case editorAxes:86+ e.axes = true87+ default:88+ e.optionKey(m, key)89+ }90+ return nil91+}92+93+// optionKey handles the keys of the main bar's options: a type's number,94+// the toggles, the range and the title.95+func (e *chartEditor) optionKey(m *Model, key string) {96+ if n, err := strconv.Atoi(key); err == nil && n >= 1 && n <= len(sheet.ChartTypes) {97+ e.set(m, func(c *sheet.Chart) { c.Type = sheet.ChartTypes[n-1] })98+ return99+ }100+ switch key {79101 case editorSwitch:80102 e.set(m, func(c *sheet.Chart) { c.ByRow = !c.ByRow })81103 case editorHeader:82104 e.set(m, func(c *sheet.Chart) { c.Header = !c.Header })83105 case editorLabels:84106 e.set(m, func(c *sheet.Chart) { c.Labels = !c.Labels })107+ case editorStack:108+ if e.chart(m).Type.Stackable() {109+ e.set(m, func(c *sheet.Chart) { c.Stack = (c.Stack + 1) % 3 })110+ }111+ case editorTrend:112+ if e.chart(m).Type == sheet.ChartScatter {113+ e.set(m, func(c *sheet.Chart) { c.Trend = !c.Trend })114+ }85115 case editorRange:86116 e.editRange(m)87117 case editorTitle:88118 e.editTitle(m)89119 }90- return nil91120 }92121 93122 // reopen returns to the editor after a prompt.
@@ -130,18 +159,32 @@ type editorPart struct {
130159 key string // the key it stands for131160 }132161 133-func (e *chartEditor) parts(m *Model) []editorPart {134- c := e.chart(m)135- chip := func(on bool, name, key string) editorPart {136- style := m.th.Muted137- if on {138- style = m.th.MenuSelected139- }140- return editorPart{text: style.Render(" " + name + " "), key: key}162+// chip is a part drawn as a toggle, highlighted when on.163+func (e *chartEditor) chip(on bool, name, key string) editorPart {164+ style := e.m.th.Muted165+ if on {166+ style = e.m.th.MenuSelected167+ }168+ return editorPart{text: style.Render(" " + name + " "), key: key}169+}170+171+// parts returns the bar's parts, and how many of them lead it: the172+// types, which are never dropped and have a wider gap after them.173+func (e *chartEditor) parts(m *Model) ([]editorPart, int) {174+ if e.axes {175+ return e.axisParts(m), 0141176 }177+ c := e.chart(m)142178 var parts []editorPart143179 for i, t := range sheet.ChartTypes {144- parts = append(parts, chip(t == c.Type, t.Title(), strconv.Itoa(i+1)))180+ parts = append(parts, e.chip(t == c.Type, t.Title(), strconv.Itoa(i+1)))181+ }182+ parts = append(parts, editorPart{text: m.th.Key.Render(c.Data.String()), key: editorRange})183+ switch {184+ case c.Type.Stackable():185+ parts = append(parts, e.chip(c.Stack != sheet.StackNone, c.Stack.Title(), editorStack))186+ case c.Type == sheet.ChartScatter:187+ parts = append(parts, e.chip(c.Trend, "Trend line", editorTrend))145188 }146189 series := "Series in columns"147190 if c.ByRow {
@@ -152,27 +195,26 @@ func (e *chartEditor) parts(m *Model) []editorPart {
152195 header, labels = "Header column", "Labels in row"153196 }154197 return append(parts,155- editorPart{text: m.th.Key.Render(c.Data.String()), key: editorRange},156- chip(true, series, editorSwitch),157- chip(c.Header, header, editorHeader),158- chip(c.Labels, labels, editorLabels))198+ e.chip(true, series, editorSwitch),199+ e.chip(c.Header, header, editorHeader),200+ e.chip(c.Labels, labels, editorLabels)), len(sheet.ChartTypes)159201 }160202 161203 // spans lays the parts out on the context line, dropping options from the162204 // right on narrow screens, and returns each part's x.163205 func (e *chartEditor) spans(m *Model) ([]editorPart, []int) {164- parts := e.parts(m)206+ parts, lead := e.parts(m)165207 for {166208 xs := make([]int, len(parts))167209 x := 0168210 for i, p := range parts {169- if i == len(sheet.ChartTypes) {211+ if i == lead && lead > 0 {170212 x += 2 // a wider gap between the types and the options171213 }172214 xs[i] = x173215 x += ansi.StringWidth(p.text) + 1174216 }175- if x-1 <= m.width || len(parts) <= len(sheet.ChartTypes) {217+ if x-1 <= m.width || len(parts) <= max(lead, 1) {176218 return parts, xs177219 }178220 parts = parts[:len(parts)-1]
@@ -214,13 +256,27 @@ func (e *chartEditor) Mouse(ev overlay.MouseEvent) tea.Cmd {
214256 215257 func (e *chartEditor) Status() (string, string) {216258 m := e.m217- pairs := []string{"←/→", "type", "S", "switch rows/columns", "H", "header", "L", "labels", "R", "range", "T", "title", "Enter", "done", "Esc", "cancel"}259+ if e.axes {260+ return "", e.axisStatus(m)261+ }262+ pairs := []string{"←/→", "type", "A", "axes and legend", "S", "switch rows/columns", "H", "header", "L", "labels", "R", "range", "T", "title", "Enter", "done", "Esc", "cancel"}263+ if c := e.chart(m); c.Type.Stackable() {264+ pairs = append(pairs[:4], append([]string{"K", "stacking"}, pairs[4:]...)...)265+ } else if c.Type == sheet.ChartScatter {266+ pairs = append(pairs[:4], append([]string{"E", "trend line"}, pairs[4:]...)...)267+ }268+ return "", fitHints(m, pairs)269+}270+271+// fitHints renders key hints, leaving out the pairs before the last two272+// (Enter and Esc) from the right until they fit.273+func fitHints(m *Model, pairs []string) string {218274 for {219275 keys := m.th.KeyHints(pairs...)220276 if ansi.StringWidth(keys) <= m.width || len(pairs) <= 4 {221- return "", keys277+ return keys222278 }223- pairs = append(pairs[:len(pairs)-6], pairs[len(pairs)-4:]...) // keep Enter and Esc279+ pairs = append(pairs[:len(pairs)-6], pairs[len(pairs)-4:]...)224280 }225281 }226282
@@ -231,4 +287,7 @@ const (
231287 editorLabels = "l"232288 editorRange = "r"233289 editorTitle = "t"290+ editorStack = "k"291+ editorTrend = "e"292+ editorAxes = "a"234293 )
M internal/ui/charts.go +2 -1
M internal/ui/graphics.go +1 -0
M internal/ui/prompt.go +4 -2