The chart editor and a selected chart stay in package ui behind one
interface of what they reach: the sheet's charts, the grid's geometry,
the pointer shape, the chart menu and prompts, and passing keys and
clicks on. Inserting a chart records run("insert.chart", answer={...})
with its line of the file; editing, moving or resizing one records
run("chart.edit", answer={"chart": n, ...}) with the fields changed,
steps of a move merged into one call. Scripts answer both commands.- commit
- dffc28fa05af0aaf38c649fbd30b1f9584105c98
- parent
- 21cc23c5e760f2a7d00aec67c485ec4980e58ea9
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 2.5 of 3, Jev's estimate
| M | internal/ | 49 | +++--- |
| M | internal/ | 224 | +++++++++++++++++----------- |
| A | internal/ | 240 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 39 | ++++- |
| M | internal/ | 104 | ++++++++------ |
| M | internal/ | 1 | + |
| M | internal/ | 57 | +++++++- |
@@ -4,8 +4,6 @@ import (
44 "strconv"55 "strings"66 7- tea "charm.land/bubbletea/v2"8-97 "github.com/FelineStateMachine/012/internal/sheet"108 )119 @@ -23,8 +21,8 @@ const (
2321 axisLegend = "p"2422 )2523 26-func (e *chartEditor) axisParts(m *Model) []editorPart {27- c := e.chart(m)24+func (e *chartEditor) axisParts() []editorPart {25+ c := e.chart()2826 end := func(name string, v float64, set bool, key string) editorPart {2927 text := name + " auto"3028 if set {@@ -41,57 +39,56 @@ func (e *chartEditor) axisParts(m *Model) []editorPart {
4139 }4240 }4341 44-func (e *chartEditor) axisKey(m *Model, key string) {42+func (e *chartEditor) axisKey(key string) {4543 switch key {4644 case "enter", "esc":4745 e.axes = false4846 case axisMin:49- e.editAxisEnd(m, "Axis minimum (blank for auto):", func(c *sheet.Chart) (*float64, *bool) { return &c.Min, &c.HasMin })47+ e.editAxisEnd("Axis minimum (blank for auto):", func(c *sheet.Chart) (*float64, *bool) { return &c.Min, &c.HasMin })5048 case axisMax:51- e.editAxisEnd(m, "Axis maximum (blank for auto):", func(c *sheet.Chart) (*float64, *bool) { return &c.Max, &c.HasMax })49+ e.editAxisEnd("Axis maximum (blank for auto):", func(c *sheet.Chart) (*float64, *bool) { return &c.Max, &c.HasMax })5250 case axisLog:53- e.set(m, func(c *sheet.Chart) { c.Log = !c.Log })51+ e.set(func(c *sheet.Chart) { c.Log = !c.Log })5452 case axisGrid:55- e.set(m, func(c *sheet.Chart) { c.NoGrid = !c.NoGrid })53+ e.set(func(c *sheet.Chart) { c.NoGrid = !c.NoGrid })5654 case axisLegend:57- e.set(m, func(c *sheet.Chart) { c.Legend = (c.Legend + 1) % 3 })55+ e.set(func(c *sheet.Chart) { c.Legend = (c.Legend + 1) % 3 })5856 }5957 }6058 6159 // editAxisEnd asks for an end of the value axis, as a number the way6260 // 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)61+func (e *chartEditor) editAxisEnd(label string, end func(*sheet.Chart) (*float64, *bool)) {62+ c := e.chart()6563 v, set := end(&c)6664 initial := ""6765 if *set {6866 initial = strconv.FormatFloat(*v, 'g', -1, 64)6967 }70- m.closeOverlay()71- e.askAxisEnd(m, label, label, initial, end)68+ e.m.closeOverlay()69+ e.askAxisEnd(label, label, initial, end)7270 }7371 7472 // askAxisEnd asks with the prompt label, and asks again, saying so, until7573 // 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 {74+func (e *chartEditor) askAxisEnd(label, question, initial string, end func(*sheet.Chart) (*float64, *bool)) {75+ e.m.askText(label, initial, func(text string) {7876 text = strings.TrimSpace(text)7977 n, _, ok := sheet.ParseValue(text)8078 switch {8179 case text == "":82- e.set(m, func(c *sheet.Chart) { _, set := end(c); *set = false })80+ e.set(func(c *sheet.Chart) { _, set := end(c); *set = false })8381 case !ok:84- e.askAxisEnd(m, "Not a number. "+question, question, text, end)85- return nil82+ e.askAxisEnd("Not a number. "+question, question, text, end)83+ return8684 default:87- e.set(m, func(c *sheet.Chart) { v, set := end(c); *v, *set = n, true })85+ e.set(func(c *sheet.Chart) { v, set := end(c); *v, *set = n, true })8886 }89- e.reopen(m)90- return nil91- })92- m.prompt.onCancel = func() { e.reopen(m) }87+ e.reopen()88+ }, e.reopen)9389 }9490 95-func (e *chartEditor) axisStatus(m *Model) string {96- return fitHints(m, []string{"N", "min", "X", "max", "L", "log", "G", "grid", "P", "legend", "Esc", "back"})91+func (e *chartEditor) axisStatus() string {92+ width, _ := e.m.size()93+ return fitHints(e.m.styles(), width, []string{"N", "min", "X", "max", "L", "log", "G", "grid", "P", "legend", "Esc", "back"})9794 }@@ -9,8 +9,52 @@ import (
99 1010 "github.com/FelineStateMachine/012/internal/sheet"1111 "github.com/FelineStateMachine/012/internal/ui/overlay"12+ "github.com/FelineStateMachine/012/internal/ui/theme"1213 )1314 15+// 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.22+type 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+ showChartMenu(x, y int)43+ // say puts msg on the context line.44+ say(msg string)45+ askText(label, initial string, done func(string), cancel func())46+ pointRange(label string, done func(sheet.Rect), cancel func())47+ selectRect(r sheet.Rect)48+ clearSelection()49+ // passKey and passMouse hand what a chart doesn't take to the grid,50+ // as if nothing were selected.51+ passKey(k tea.KeyPressMsg) tea.Cmd52+ passMouse(e overlay.MouseEvent) tea.Cmd53+ // recordChart records a chart inserted, edited, moved or resized, as54+ // the command that does it answered with the chart (chartmacro.go).55+ recordChart(id string, i int, before, after sheet.Chart)56+}57+1458 // chartEditor is the chart editor: a bar on the context line, in the1559 // spirit of the find bar, with the type as a row of chips and the options1660 // as toggles. A second bar (A) holds the value axis, gridlines and@@ -18,139 +62,148 @@ import (
1862 // at once; Enter keeps them and Esc undoes them, removing a chart that1963 // was just inserted.2064 type chartEditor struct {21- m *Model // the model it acts on22- i int23- start int // the sheet's state before editing, to undo back to24- isNew bool25- axes bool // showing the axis options65+ m chartHost // the model, through what the editor needs of it66+ i int67+ start int // the sheet's state before editing, to undo back to68+ before sheet.Chart // the chart when the editor opened, for a macro69+ isNew bool70+ axes bool // showing the axis options2671 }2772 2873 // openChartEditor edits chart i. start is the sheet's state to return to2974 // on Esc.30-func (m *Model) openChartEditor(i int, isNew bool, start int) {31- if i < 0 || i >= len(m.sheet.Charts()) {32- return75+func (m *Model) openChartEditor(i int, isNew bool, start int) *chartEditor {76+ charts := m.sheet.Charts()77+ if i < 0 || i >= len(charts) {78+ return nil3379 }3480 m.charts.last = i35- m.openOverlay(&chartEditor{m: m, i: i, start: start, isNew: isNew})81+ e := &chartEditor{m: m, i: i, start: start, before: charts[i], isNew: isNew}82+ m.openOverlay(e)83+ return e3684 }3785 3886 func (e *chartEditor) Indicator() string { return "CHART" }3987 4088 func (e *chartEditor) Layout() []overlay.Box { return nil }4189 42-func (e *chartEditor) chart(m *Model) sheet.Chart { return m.sheet.Charts()[e.i] }90+func (e *chartEditor) chart() sheet.Chart { return e.m.sheetShown().Charts()[e.i] }4391 4492 // set applies a change to the chart as its own undo step.45-func (e *chartEditor) set(m *Model, fn func(c *sheet.Chart)) {46- c := e.chart(m)93+func (e *chartEditor) set(fn func(c *sheet.Chart)) {94+ c := e.chart()4795 fn(&c)48- m.sheet.SetChart(e.i, c, "edit chart")49- m.changed = m.sheet.StateID() != m.saved96+ e.m.sheetShown().SetChart(e.i, c, "edit chart")97+ e.m.syncChanged()98+}99+100+// keep closes the editor keeping the changes, and records them.101+func (e *chartEditor) keep() {102+ m := e.m103+ m.closeOverlay()104+ id := "chart.edit"105+ if e.isNew {106+ id = "insert.chart"107+ m.say("Inserted a chart of " + e.chart().Data.String())108+ }109+ m.recordChart(id, e.i, e.before, e.chart())50110 }51111 52112 func (e *chartEditor) Key(k tea.KeyPressMsg) tea.Cmd {53113 m := e.m54- if e.i >= len(m.sheet.Charts()) {114+ if e.i >= len(m.sheetShown().Charts()) {55115 m.closeOverlay()56116 return nil57117 }58118 key := strings.ToLower(k.String())59119 if e.axes {60- e.axisKey(m, key)120+ e.axisKey(key)61121 return nil62122 }63123 switch key {64124 case "enter":125+ e.keep()65126 m.selectChart(e.i)66- if e.isNew {67- m.note = "Inserted a chart of " + e.chart(m).Data.String()68- }69127 case "esc":70- for m.sheet.StateID() != e.start && m.sheet.CanUndo() {71- m.sheet.Undo()128+ s := m.sheetShown()129+ for s.StateID() != e.start && s.CanUndo() {130+ s.Undo()72131 }73- m.changed = m.sheet.StateID() != m.saved132+ m.syncChanged()74133 m.closeOverlay()75- m.charts.last = -1134+ m.forgetChart()76135 case "left", "right", "shift+tab", "tab":77136 d := 178137 if key == "left" || key == "shift+tab" {79138 d = -180139 }81- e.set(m, func(c *sheet.Chart) {140+ e.set(func(c *sheet.Chart) {82141 n := len(sheet.ChartTypes)83142 c.Type = sheet.ChartTypes[((int(c.Type)+d)%n+n)%n]84143 })85144 case editorAxes:86145 e.axes = true87146 default:88- e.optionKey(m, key)147+ e.optionKey(key)89148 }90149 return nil91150 }92151 93152 // optionKey handles the keys of the main bar's options: a type's number,94153 // the toggles, the range and the title.95-func (e *chartEditor) optionKey(m *Model, key string) {154+func (e *chartEditor) optionKey(key string) {96155 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] })156+ e.set(func(c *sheet.Chart) { c.Type = sheet.ChartTypes[n-1] })98157 return99158 }100159 switch key {101160 case editorSwitch:102- e.set(m, func(c *sheet.Chart) { c.ByRow = !c.ByRow })161+ e.set(func(c *sheet.Chart) { c.ByRow = !c.ByRow })103162 case editorHeader:104- e.set(m, func(c *sheet.Chart) { c.Header = !c.Header })163+ e.set(func(c *sheet.Chart) { c.Header = !c.Header })105164 case editorLabels:106- e.set(m, func(c *sheet.Chart) { c.Labels = !c.Labels })165+ e.set(func(c *sheet.Chart) { c.Labels = !c.Labels })107166 case editorStack:108- if e.chart(m).Type.Stackable() {109- e.set(m, func(c *sheet.Chart) { c.Stack = (c.Stack + 1) % 3 })167+ if e.chart().Type.Stackable() {168+ e.set(func(c *sheet.Chart) { c.Stack = (c.Stack + 1) % 3 })110169 }111170 case editorTrend:112- if e.chart(m).Type == sheet.ChartScatter {113- e.set(m, func(c *sheet.Chart) { c.Trend = !c.Trend })171+ if e.chart().Type == sheet.ChartScatter {172+ e.set(func(c *sheet.Chart) { c.Trend = !c.Trend })114173 }115174 case editorRange:116- e.editRange(m)175+ e.editRange()117176 case editorTitle:118- e.editTitle(m)177+ e.editTitle()119178 }120179 }121180 122181 // reopen returns to the editor after a prompt.123-func (e *chartEditor) reopen(m *Model) {124- m.openOverlay(e)125-}182+func (e *chartEditor) reopen() { e.m.openOverlay(e) }126183 127184 // editRange asks for the data range by pointing, starting from the128185 // current one.129-func (e *chartEditor) editRange(m *Model) {130- d := e.chart(m).Data186+func (e *chartEditor) editRange() {187+ m := e.m131188 m.closeOverlay()132- m.cur, m.ext, m.selecting, m.whole = d.From, d.To, true, wholeNone133- m.openRange("Chart data:", func(m *Model, r sheet.Rect) tea.Cmd {189+ m.selectRect(e.chart().Data)190+ m.pointRange("Chart data:", func(r sheet.Rect) {134191 m.clearSelection()135- e.set(m, func(c *sheet.Chart) { c.Data = r })136- e.reopen(m)137- return nil138- })139- m.prompt.onCancel = func() {192+ e.set(func(c *sheet.Chart) { c.Data = r })193+ e.reopen()194+ }, func() {140195 m.clearSelection()141- e.reopen(m)142- }196+ e.reopen()197+ })143198 }144199 145-func (e *chartEditor) editTitle(m *Model) {146- title := e.chart(m).Title200+func (e *chartEditor) editTitle() {201+ m := e.m147202 m.closeOverlay()148- m.openText("Chart title:", title, func(m *Model, text string) tea.Cmd {149- e.set(m, func(c *sheet.Chart) { c.Title = text })150- e.reopen(m)151- return nil152- })153- m.prompt.onCancel = func() { e.reopen(m) }203+ m.askText("Chart title:", e.chart().Title, func(text string) {204+ e.set(func(c *sheet.Chart) { c.Title = text })205+ e.reopen()206+ }, e.reopen)154207 }155208 156209 // editorPart is a piece of the editor bar: a type chip or a toggle.@@ -161,9 +214,9 @@ type editorPart struct {
161214 162215 // chip is a part drawn as a toggle, highlighted when on.163216 func (e *chartEditor) chip(on bool, name, key string) editorPart {164- style := e.m.th.Muted217+ style := e.m.styles().Muted165218 if on {166- style = e.m.th.MenuSelected219+ style = e.m.styles().MenuSelected167220 }168221 return editorPart{text: style.Render(" " + name + " "), key: key}169222 }@@ -171,11 +224,11 @@ func (e *chartEditor) chip(on bool, name, key string) editorPart {
171224 // parts returns the bar's parts, and how many of them lead it: the172225 // types, which are never dropped and have a wider gap after them. Compact,173226 // the types are one chip of the current type, which a click moves on.174-func (e *chartEditor) parts(m *Model, compact bool) ([]editorPart, int) {227+func (e *chartEditor) parts(compact bool) ([]editorPart, int) {175228 if e.axes {176- return e.axisParts(m), 0229+ return e.axisParts(), 0177230 }178- c := e.chart(m)231+ c := e.chart()179232 var parts []editorPart180233 for i, t := range sheet.ChartTypes {181234 switch {@@ -187,7 +240,7 @@ func (e *chartEditor) parts(m *Model, compact bool) ([]editorPart, int) {
187240 }188241 }189242 lead := len(parts)190- parts = append(parts, editorPart{text: m.th.Key.Render(c.Data.String()), key: editorRange})243+ parts = append(parts, editorPart{text: e.m.styles().Key.Render(c.Data.String()), key: editorRange})191244 switch {192245 case c.Type.Stackable():193246 parts = append(parts, e.chip(c.Stack != sheet.StackNone, c.Stack.Title(), editorStack))@@ -212,10 +265,10 @@ func (e *chartEditor) parts(m *Model, compact bool) ([]editorPart, int) {
212265 // x. On narrow screens the types become one chip when that leaves room213266 // for more options, and options that still don't fit are dropped from the214267 // right.215-func (e *chartEditor) spans(m *Model) ([]editorPart, []int) {216- parts, xs := e.fit(m, false)217- if all, _ := e.parts(m, false); len(parts) < len(all) && !e.axes {218- if cp, cxs := e.fit(m, true); len(cp)+len(sheet.ChartTypes)-1 > len(parts) {268+func (e *chartEditor) spans() ([]editorPart, []int) {269+ parts, xs := e.fit(false)270+ if all, _ := e.parts(false); len(parts) < len(all) && !e.axes {271+ if cp, cxs := e.fit(true); len(cp)+len(sheet.ChartTypes)-1 > len(parts) {219272 return cp, cxs220273 }221274 }@@ -223,8 +276,9 @@ func (e *chartEditor) spans(m *Model) ([]editorPart, []int) {
223276 }224277 225278 // fit lays out the parts, dropping options from the right until they fit.226-func (e *chartEditor) fit(m *Model, compact bool) ([]editorPart, []int) {227- parts, lead := e.parts(m, compact)279+func (e *chartEditor) fit(compact bool) ([]editorPart, []int) {280+ width, _ := e.m.size()281+ parts, lead := e.parts(compact)228282 for {229283 xs := make([]int, len(parts))230284 x := 0@@ -235,7 +289,7 @@ func (e *chartEditor) fit(m *Model, compact bool) ([]editorPart, []int) {
235289 xs[i] = x236290 x += ansi.StringWidth(p.text) + 1237291 }238- if x-1 <= m.width || len(parts) <= max(lead, 1) {292+ if x-1 <= width || len(parts) <= max(lead, 1) {239293 return parts, xs240294 }241295 parts = parts[:len(parts)-1]@@ -243,11 +297,10 @@ func (e *chartEditor) fit(m *Model, compact bool) ([]editorPart, []int) {
243297 }244298 245299 func (e *chartEditor) ContextLine() (string, string) {246- m := e.m247- if e.i >= len(m.sheet.Charts()) {300+ if e.i >= len(e.m.sheetShown().Charts()) {248301 return "", ""249302 }250- parts, xs := e.spans(m)303+ parts, xs := e.spans()251304 var b strings.Builder252305 for i, p := range parts {253306 b.WriteString(strings.Repeat(" ", xs[i]-ansi.StringWidth(b.String())))@@ -257,16 +310,15 @@ func (e *chartEditor) ContextLine() (string, string) {
257310 }258311 259312 func (e *chartEditor) Mouse(ev overlay.MouseEvent) tea.Cmd {260- m := e.m261313 if ev.Kind != overlay.MousePress {262314 return nil263315 }264316 if ev.Y != contextLine {265317 // A click elsewhere keeps the changes and acts as usual.266- m.closeOverlay()267- return m.handlePress(tea.Mouse{X: ev.X, Y: ev.Y, Button: ev.Button})318+ e.keep()319+ return e.m.passMouse(ev)268320 }269- parts, xs := e.spans(m)321+ parts, xs := e.spans()270322 for i, p := range parts {271323 if ev.X >= xs[i] && ev.X < xs[i]+ansi.StringWidth(p.text) {272324 return e.Key(tea.KeyPressMsg{Code: rune(p.key[0]), Text: p.key})@@ -276,25 +328,25 @@ func (e *chartEditor) Mouse(ev overlay.MouseEvent) tea.Cmd {
276328 }277329 278330 func (e *chartEditor) Status() (string, string) {279- m := e.m280331 if e.axes {281- return "", e.axisStatus(m)332+ return "", e.axisStatus()282333 }283334 pairs := []string{"←/→", "type", "A", "axes and legend", "S", "switch rows/columns", "H", "header", "L", "labels", "R", "range", "T", "title", "Enter", "done", "Esc", "cancel"}284- if c := e.chart(m); c.Type.Stackable() {335+ if c := e.chart(); c.Type.Stackable() {285336 pairs = append(pairs[:4], append([]string{"K", "stacking"}, pairs[4:]...)...)286337 } else if c.Type == sheet.ChartScatter {287338 pairs = append(pairs[:4], append([]string{"E", "trend line"}, pairs[4:]...)...)288339 }289- return "", fitHints(m, pairs)340+ width, _ := e.m.size()341+ return "", fitHints(e.m.styles(), width, pairs)290342 }291343 292344 // fitHints renders key hints, leaving out the pairs before the last two293-// (Enter and Esc) from the right until they fit.294-func fitHints(m *Model, pairs []string) string {345+// (Enter and Esc) from the right until they fit in width.346+func fitHints(th *theme.Theme, width int, pairs []string) string {295347 for {296- keys := m.th.KeyHints(pairs...)297- if ansi.StringWidth(keys) <= m.width || len(pairs) <= 4 {348+ keys := th.KeyHints(pairs...)349+ if ansi.StringWidth(keys) <= width || len(pairs) <= 4 {298350 return keys299351 }300352 pairs = append(pairs[:len(pairs)-6], pairs[len(pairs)-4:]...)@@ -0,0 +1,240 @@
1+package ui2+3+import (4+ "bytes"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "slices"9+ "strconv"10+11+ tea "charm.land/bubbletea/v2"12+13+ "github.com/FelineStateMachine/012/internal/macro"14+ "github.com/FelineStateMachine/012/internal/sheet"15+)16+17+// Charts in macros. A chart inserted is recorded as18+// run("insert.chart", answer={...}) with the whole chart as a line of the19+// file writes it; a chart edited, moved or resized as20+// run("chart.edit", answer={"chart": 2, "at": "F3"}), its number21+// (counting from 1) and the fields that changed, a field left out of the22+// file's line given as its default (False, "" or None). Scripts answer23+// both commands the same way, and a replay changes only the fields24+// named. Moving a chart a step at a time records one call.25+26+// jsonField is a field of a JSON object, in the order written.27+type jsonField struct {28+ key string29+ value json.RawMessage30+}31+32+// jsonFields reads a JSON object's fields in order.33+func jsonFields(text string) ([]jsonField, error) {34+ d := json.NewDecoder(bytes.NewReader([]byte(text)))35+ if t, err := d.Token(); err != nil || t != json.Delim('{') {36+ return nil, errors.New("the answer isn't an object: give a dict")37+ }38+ var out []jsonField39+ for d.More() {40+ k, err := d.Token()41+ if err != nil {42+ return nil, err43+ }44+ var v json.RawMessage45+ if err := d.Decode(&v); err != nil {46+ return nil, err47+ }48+ out = append(out, jsonField{k.(string), v})49+ }50+ return out, nil51+}52+53+// jsonObject writes fields as a JSON object.54+func jsonObject(fields []jsonField) string {55+ var b bytes.Buffer56+ b.WriteByte('{')57+ for i, f := range fields {58+ if i > 0 {59+ b.WriteByte(',')60+ }61+ k, _ := json.Marshal(f.key)62+ b.Write(k)63+ b.WriteByte(':')64+ b.Write(f.value)65+ }66+ b.WriteByte('}')67+ return b.String()68+}69+70+func fieldIndex(fields []jsonField, key string) int {71+ return slices.IndexFunc(fields, func(f jsonField) bool { return f.key == key })72+}73+74+// chartChanges are the fields of after's line that differ from before's.75+// A field before's line has and after's leaves out, at its default, is76+// given as that default: the other boolean, "", or null.77+func chartChanges(before, after sheet.Chart) []jsonField {78+ bf, _ := jsonFields(before.JSON())79+ af, _ := jsonFields(after.JSON())80+ var out []jsonField81+ for _, f := range af {82+ if i := fieldIndex(bf, f.key); i < 0 || !bytes.Equal(bf[i].value, f.value) {83+ out = append(out, f)84+ }85+ }86+ for _, f := range bf {87+ if fieldIndex(af, f.key) >= 0 {88+ continue89+ }90+ def := json.RawMessage("null")91+ switch f.value[0] {92+ case 't':93+ def = json.RawMessage("false")94+ case 'f':95+ def = json.RawMessage("true")96+ case '"':97+ def = json.RawMessage(`""`)98+ }99+ out = append(out, jsonField{f.key, def})100+ }101+ return out102+}103+104+// chartWith is c with the fields of a macro's answer: null leaves a105+// field at its default; "chart" says which chart, and isn't a field.106+func chartWith(c sheet.Chart, answer []jsonField) (sheet.Chart, error) {107+ fields, _ := jsonFields(c.JSON())108+ for _, f := range answer {109+ i := fieldIndex(fields, f.key)110+ switch {111+ case f.key == "chart":112+ case string(f.value) == "null" && i >= 0:113+ fields = slices.Delete(fields, i, i+1)114+ case string(f.value) == "null":115+ case i >= 0:116+ fields[i].value = f.value117+ default:118+ fields = append(fields, f)119+ }120+ }121+ return sheet.ParseChart(jsonObject(fields))122+}123+124+// recordChart records chart i inserted or changed from before to after,125+// as the command id that does it answered with the chart.126+func (m *Model) recordChart(id string, i int, before, after sheet.Chart) {127+ r := m.rec128+ if r == nil || r.depth > 0 {129+ return130+ }131+ if id == "insert.chart" {132+ m.recordDialog(id, after.JSON())133+ return134+ }135+ changes := chartChanges(before, after)136+ if len(changes) == 0 {137+ return138+ }139+ n := json.RawMessage(strconv.Itoa(i + 1))140+ if last, ok := r.lastPlacement(i + 1); ok && r.base == m.selState() && placement(changes) {141+ for _, f := range changes {142+ if k := fieldIndex(last, f.key); k >= 0 {143+ last[k].value = f.value144+ } else {145+ last = append(last, f)146+ }147+ }148+ r.actions[len(r.actions)-1] = macro.Call("run", id).With("answer", macro.JSON(jsonObject(last)))149+ r.acted = true150+ return151+ }152+ m.recordDialog(id, jsonObject(append([]jsonField{{"chart", n}}, changes...)))153+}154+155+// placement reports whether fields only place a chart: where it is and156+// its size.157+func placement(fields []jsonField) bool {158+ for _, f := range fields {159+ switch f.key {160+ case "chart", "at", "width", "height":161+ default:162+ return false163+ }164+ }165+ return true166+}167+168+// lastPlacement is the answer of the last action recorded when it placed169+// chart n, so that moving a chart step by step records one call.170+func (r *recorder) lastPlacement(n int) ([]jsonField, bool) {171+ if len(r.actions) == 0 {172+ return nil, false173+ }174+ a := r.actions[len(r.actions)-1]175+ if a.Func != "run" || len(a.Args) != 1 || a.Args[0] != "chart.edit" || len(a.Named) != 1 {176+ return nil, false177+ }178+ text, ok := a.Named[0].Value.(macro.JSON)179+ if !ok {180+ return nil, false181+ }182+ fields, err := jsonFields(string(text))183+ if err != nil || !placement(fields) {184+ return nil, false185+ }186+ if i := fieldIndex(fields, "chart"); i < 0 || string(fields[i].value) != strconv.Itoa(n) {187+ return nil, false188+ }189+ return fields, true190+}191+192+// answerInsertChart inserts a chart as a script answers Insert > Chart:193+// the chart the selection would make, with the answer's fields.194+func (m *Model) answerInsertChart(text string) (tea.Cmd, error) {195+ fields, err := jsonFields(text)196+ if err != nil {197+ return nil, err198+ }199+ m.insertChart()200+ e, ok := m.overlay.(*chartEditor)201+ if !ok {202+ return nil, errors.New(m.note)203+ }204+ return nil, e.answer(fields)205+}206+207+// answerEditChart changes a chart as a script answers Edit chart: the208+// chart numbered "chart", or the one the command would edit, with the209+// answer's fields.210+func (m *Model) answerEditChart(text string) (tea.Cmd, error) {211+ fields, err := jsonFields(text)212+ if err != nil {213+ return nil, err214+ }215+ charts := m.sheet.Charts()216+ i := m.targetChart()217+ if k := fieldIndex(fields, "chart"); k >= 0 {218+ n, err := strconv.Atoi(string(fields[k].value))219+ if err != nil || n < 1 || n > len(charts) {220+ return nil, fmt.Errorf("there's no chart %s on %s: it has %d", fields[k].value, m.sheet.Name(), len(charts))221+ }222+ i = n - 1223+ }224+ if i < 0 {225+ return nil, fmt.Errorf("there's no chart on %s", m.sheet.Name())226+ }227+ return nil, m.openChartEditor(i, false, m.sheet.StateID()).answer(fields)228+}229+230+// answer makes the chart the answer's fields say and closes the editor.231+func (e *chartEditor) answer(fields []jsonField) error {232+ c, err := chartWith(e.chart(), fields)233+ if err != nil {234+ e.m.closeOverlay()235+ return err236+ }237+ e.set(func(x *sheet.Chart) { *x = c })238+ e.m.closeOverlay()239+ return nil240+}@@ -31,12 +31,14 @@ const newChartW, newChartH = 48, 16
3131 3232 func init() {3333 register(34- &command{id: "insert.chart", title: "Chart", desc: "Chart the selected data, or the table around the active cell", run: (*Model).insertChart},34+ &command{id: "insert.chart", title: "Chart", desc: "Chart the selected data, or the table around the active cell", run: (*Model).insertChart,35+ answer: (*Model).answerInsertChart},3536 &command{id: "chart.edit", title: "Edit chart", desc: "Change a chart's type, data and labels",3637 enabled: hasCharts, run: func(m *Model) tea.Cmd {3738 m.openChartEditor(m.targetChart(), false, m.sheet.StateID())3839 return nil39- }},40+ },41+ answer: (*Model).answerEditChart},4042 &command{id: "chart.delete", title: "Delete chart", desc: "Remove a chart from the sheet",4143 enabled: hasCharts, run: func(m *Model) tea.Cmd {4244 m.deleteChart(m.targetChart())@@ -325,23 +327,44 @@ func (m *Model) chartClick(mouse tea.Mouse) (tea.Cmd, bool) {
325327 if i < 0 {326328 return nil, false327329 }328- m.selectChart(i)329- s := m.overlay.(*chartSel)330+ s := m.selectChart(i)330331 if mouse.Button == tea.MouseRight {331- m.showContextMenu(chartMenu, mouse.X, mouse.Y+1)332+ m.showChartMenu(mouse.X, mouse.Y+1)332333 return nil, true333334 }334- s.press(m, mouse.X, mouse.Y)335+ s.press(mouse.X, mouse.Y)335336 return nil, true336337 }337338 338339 var chartMenu = []menuItem{{cmd: "chart.edit"}, {cmd: "chart.delete"}}339340 340341 // selectChart selects chart i.341-func (m *Model) selectChart(i int) {342+func (m *Model) selectChart(i int) *chartSel {342343 m.clearSelection()343344 m.charts.last = i344- m.openOverlay(&chartSel{m: m, i: i})345+ s := &chartSel{m: m, i: i}346+ m.openOverlay(s)347+ return s348+}349+350+func (m *Model) forgetChart() { m.charts.last = -1 }351+func (m *Model) showChartMenu(x, y int) { m.showContextMenu(chartMenu, x, y) }352+func (m *Model) say(msg string) { m.note = msg }353+func (m *Model) passKey(k tea.KeyPressMsg) tea.Cmd {354+ return m.handleKey(k)355+}356+357+// passMouse hands a press or the wheel to the grid, as if no chart were358+// selected.359+func (m *Model) passMouse(e overlay.MouseEvent) tea.Cmd {360+ mouse := tea.Mouse{X: e.X, Y: e.Y, Button: e.Button}361+ switch e.Kind {362+ case overlay.MousePress:363+ return m.handlePress(mouse)364+ case overlay.MouseWheel:365+ m.handleWheel(mouse)366+ }367+ return nil345368 }346369 347370 // setShape sets the terminal's pointer shape, if it changed.@@ -12,7 +12,7 @@ import (
1212 // and arrows resize it, Enter edits it and Del deletes it; the mouse1313 // drags it or its corner. Other keys deselect it and act as usual.1414 type chartSel struct {15- m *Model // the model it acts on15+ m chartHost // the model, through what a selected chart needs of it1616 i int1717 drag int // chartDragNone, chartDragMove or chartDragResize1818 grabX int // where the chart was grabbed, relative to its corner@@ -26,17 +26,25 @@ func (s *chartSel) Layout() []overlay.Box {
2626 return nil // drawn with the other charts, see chartBoxes2727 }2828 29-func (s *chartSel) chart(m *Model) (sheet.Chart, bool) {30- charts := m.sheet.Charts()29+func (s *chartSel) chart() (sheet.Chart, bool) {30+ charts := s.m.sheetShown().Charts()3131 if s.i < 0 || s.i >= len(charts) {3232 return sheet.Chart{}, false3333 }3434 return charts[s.i], true3535 }3636 37+// shown is the chart as drawn: where it's being dragged, if it is.38+func (s *chartSel) shown() (sheet.Chart, bool) {39+ if s.preview != nil {40+ return *s.preview, true41+ }42+ return s.chart()43+}44+3745 func (s *chartSel) Key(k tea.KeyPressMsg) tea.Cmd {3846 m := s.m39- c, ok := s.chart(m)47+ c, ok := s.chart()4048 if !ok {4149 m.closeOverlay()4250 return nil@@ -51,7 +59,7 @@ func (s *chartSel) Key(k tea.KeyPressMsg) tea.Cmd {
5159 case "delete", "backspace":5260 return m.runCommand("chart.delete")5361 case "tab":54- m.selectChart((s.i + 1) % len(m.sheet.Charts()))62+ m.selectChart((s.i + 1) % len(m.sheetShown().Charts()))5563 return nil5664 case "up":5765 moved.At.Row--@@ -71,25 +79,35 @@ func (s *chartSel) Key(k tea.KeyPressMsg) tea.Cmd {
7179 moved.W += 27280 default:7381 m.closeOverlay()74- return m.handleKey(k)82+ return m.passKey(k)7583 }7684 label := "move chart"7785 if moved.W != c.W || moved.H != c.H {7886 label = "resize chart"7987 }80- m.sheet.SetChart(s.i, moved, label)81- m.changed = true88+ s.place(c, moved, label)8289 m.showChart(s.i)8390 return nil8491 }8592 93+// place puts the chart at to, from where it was, as one undo step, and94+// records it.95+func (s *chartSel) place(from, to sheet.Chart, label string) {96+ sh := s.m.sheetShown()97+ sh.SetChart(s.i, to, label)98+ s.m.syncChanged()99+ if now, ok := s.chart(); ok {100+ s.m.recordChart("chart.edit", s.i, from, now)101+ }102+}103+86104 // press starts a drag at x, y: the corner resizes, anywhere else moves.87-func (s *chartSel) press(m *Model, x, y int) {88- c, ok := s.chart(m)105+func (s *chartSel) press(x, y int) {106+ c, ok := s.chart()89107 if !ok {90108 return91109 }92- cx, cy := m.chartScreen(c)110+ cx, cy := s.m.chartScreen(c)93111 s.grabX, s.grabY = x-cx, y-cy94112 s.drag = chartDragMove95113 if x >= cx+c.W-2 && y == cy+c.H-1 {@@ -99,46 +117,46 @@ func (s *chartSel) press(m *Model, x, y int) {
99117 }100118 101119 func (s *chartSel) Mouse(e overlay.MouseEvent) tea.Cmd {102- m := s.m103120 switch e.Kind {104121 case overlay.MousePress:105- return s.pressAt(m, e)122+ return s.pressAt(e)106123 case overlay.MouseMotion:107- return s.motion(m, e)124+ return s.motion(e)108125 case overlay.MouseRelease:109- s.release(m)126+ s.release()110127 case overlay.MouseWheel:111- m.handleWheel(tea.Mouse{X: e.X, Y: e.Y, Button: e.Button})128+ return s.m.passMouse(e)112129 }113130 return nil114131 }115132 116133 // pressAt selects the chart pressed and starts dragging it, or opens its117134 // menu; pressing off the charts deselects and acts as usual.118-func (s *chartSel) pressAt(m *Model, e overlay.MouseEvent) tea.Cmd {135+func (s *chartSel) pressAt(e overlay.MouseEvent) tea.Cmd {136+ m := s.m119137 i := m.chartAt(e.X, e.Y)120138 switch {121139 case i < 0:122140 m.closeOverlay()123- return m.handlePress(tea.Mouse{X: e.X, Y: e.Y, Button: e.Button})141+ return m.passMouse(e)124142 case e.Button == tea.MouseRight:125143 m.selectChart(i)126- m.showContextMenu(chartMenu, e.X, e.Y+1)144+ m.showChartMenu(e.X, e.Y+1)127145 case e.Button == tea.MouseLeft:128146 if i != s.i {129- m.selectChart(i)130- s = m.overlay.(*chartSel)147+ s = m.selectChart(i)131148 }132- s.press(m, e.X, e.Y)149+ s.press(e.X, e.Y)133150 }134151 return nil135152 }136153 137154 // motion moves or resizes the chart being dragged, or else sets the138155 // pointer shape for what's under the mouse.139-func (s *chartSel) motion(m *Model, e overlay.MouseEvent) tea.Cmd {156+func (s *chartSel) motion(e overlay.MouseEvent) tea.Cmd {157+ m := s.m140158 if s.drag == chartDragNone || s.preview == nil {141- return m.setShape(s.hoverShape(m, e.X, e.Y))159+ return m.setShape(s.hoverShape(e.X, e.Y))142160 }143161 p := *s.preview144162 if s.drag == chartDragResize {@@ -154,13 +172,13 @@ func (s *chartSel) motion(m *Model, e overlay.MouseEvent) tea.Cmd {
154172 155173 // hoverShape is the pointer shape at x, y: a move cursor over charts,156174 // and a resize one over the selected chart's corner.157-func (s *chartSel) hoverShape(m *Model, x, y int) string {158- i := m.chartAt(x, y)175+func (s *chartSel) hoverShape(x, y int) string {176+ i := s.m.chartAt(x, y)159177 if i < 0 {160178 return "default"161179 }162- if c := m.displayCharts()[i]; i == s.i {163- cx, cy := m.chartScreen(c)180+ if c, ok := s.shown(); ok && i == s.i {181+ cx, cy := s.m.chartScreen(c)164182 if x >= cx+c.W-2 && y == cy+c.H-1 {165183 return "nwse-resize"166184 }@@ -169,17 +187,14 @@ func (s *chartSel) hoverShape(m *Model, x, y int) string {
169187 }170188 171189 // release drops the chart being dragged where it is, as one undo step.172-func (s *chartSel) release(m *Model) {173- if s.preview != nil && s.drag != chartDragNone {190+func (s *chartSel) release() {191+ c, ok := s.chart()192+ if ok && s.preview != nil && s.drag != chartDragNone && *s.preview != c {174193 label := "move chart"175194 if s.drag == chartDragResize {176195 label = "resize chart"177196 }178- before := m.sheet.StateID()179- m.sheet.SetChart(s.i, *s.preview, label)180- if m.sheet.StateID() != before {181- m.changed = true182- }197+ s.place(c, *s.preview, label)183198 }184199 s.drag, s.preview = chartDragNone, nil185200 }@@ -205,17 +220,18 @@ func (g *grid) chartCellAt(x, y int) sheet.Addr {
205220 }206221 207222 func (s *chartSel) Status() (string, string) {208- m := s.m209- c, ok := s.chart(m)223+ th := s.m.styles()224+ width, _ := s.m.size()225+ c, ok := s.chart()210226 if !ok {211227 return "", ""212228 }213- desc := m.th.Muted.Render("Chart of " + c.Data.String())229+ desc := th.Muted.Render("Chart of " + c.Data.String())214230 pairs := []string{"Enter", "edit", "Del", "delete", "Arrows", "move", "Shift+arrows", "resize", "Esc", "done"}215231 for {216- keys := m.th.KeyHints(pairs...)232+ keys := th.KeyHints(pairs...)217233 switch {218- case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:234+ case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= width:219235 return desc, keys220236 case desc != "":221237 desc = ""@@ -229,13 +245,13 @@ func (s *chartSel) Status() (string, string) {
229245 230246 // ContextLine says what's selected and how to change it.231247 func (s *chartSel) ContextLine() (string, string) {232- m := s.m233- c, ok := s.chart(m)248+ th := s.m.styles()249+ c, ok := s.chart()234250 if !ok {235251 return "", ""236252 }237- return m.th.Key.Render(c.Type.Title()+" chart") + m.th.Muted.Render(" of ") + c.Data.String() +238- m.th.Muted.Render(" drag to move, drag the corner to resize"), ""253+ return th.Key.Render(c.Type.Title()+" chart") + th.Muted.Render(" of ") + c.Data.String() +254+ th.Muted.Render(" drag to move, drag the corner to resize"), ""239255 }240256 241257 const (@@ -4,6 +4,8 @@ import (
44 "strings"55 "testing"66 7+ tea "charm.land/bubbletea/v2"8+79 "github.com/FelineStateMachine/012/internal/sheet"810 )911 @@ -12,8 +14,13 @@ import (
1214 1315 // salesModel is a table of sales with a header row, the fixture both a1416 // recording and its replay start from.15-func salesModel() *Model {16- m := newModel()17+func salesModel() *Model { return withSales(newModel()) }18+19+// wideSales is salesModel on a screen wide enough for a chart beside the20+// table.21+func wideSales() *Model { return withSales(wideModel()) }22+23+func withSales(m *Model) *Model {1724 for i, row := range [][]string{1825 {"Region", "Units", "Price"},1926 {"North", "12", "3.5"},@@ -157,3 +164,49 @@ run("data.filter_column", answer={"hidden": ["40", "7"]})`)
157164 t.Fatalf("hidden %d note %q warn %q", m.sheet.HiddenRows(), m.note, m.warn)158165 }159166 }167+168+func TestRecordChartEditorAndDrags(t *testing.T) {169+ m := wideSales()170+ got := recordDo(t, m, "Chart", func() {171+ run(m, m.runCommand("insert.chart"))172+ press(t, m, "3", "t", "Units", "<enter>", "<enter>")173+ // Moved a step at a time, then dragged by the corner: one call.174+ press(t, m, "<down>", "<right>", "<right>")175+ c := m.sheet.Charts()[0]176+ x, y := m.chartScreen(c)177+ send(m, tea.MouseClickMsg{X: x + c.W - 1, Y: y + c.H - 1, Button: tea.MouseLeft})178+ send(m, tea.MouseMotionMsg{X: x + 29, Y: y + 11, Button: tea.MouseLeft})179+ send(m, tea.MouseReleaseMsg{X: x + 29, Y: y + 11, Button: tea.MouseLeft})180+ // Edited again: the header row off.181+ press(t, m, "<enter>", "h", "<enter>", "<esc>")182+ })183+ want := `run("insert.chart", answer={"type": "line", "data": "A1:C5", "at": "D1", "width": 48, "height": 16, "header": True, "labels": True, "title": "Units"})184+run("chart.edit", answer={"chart": 1, "at": "F2", "width": 30, "height": 12})185+run("chart.edit", answer={"chart": 1, "header": False})`186+ if got != want {187+ t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)188+ }189+ replays(t, m, "Chart", wideSales)190+}191+192+func TestScriptAnswersChartCommands(t *testing.T) {193+ m := wideSales()194+ script(t, m, `select("A1:B5")195+run("insert.chart", answer={"type": "pie", "title": "Units"})196+run("chart.edit", answer={"chart": 1, "at": "H2", "legend": "none"})`)197+ cs := m.sheet.Charts()198+ if len(cs) != 1 || cs[0].Type != sheet.ChartPie || cs[0].Title != "Units" || cs[0].At.String() != "H2" || cs[0].Legend != sheet.LegendNone || cs[0].Data.String() != "A1:B5" {199+ t.Fatalf("charts %+v, warn %q", cs, m.warn)200+ }201+ if m.overlay != nil || m.mode != modeReady {202+ t.Fatalf("left mode %v overlay %T", m.mode, m.overlay)203+ }204+ script(t, m, `run("chart.edit", answer={"chart": 2, "at": "A1"})`)205+ if !strings.Contains(m.warn, "there's no chart 2 on Sheet1: it has 1") {206+ t.Fatalf("warn %q", m.warn)207+ }208+ script(t, m, `run("chart.edit", answer={"type": "blimp"})`)209+ if !strings.Contains(m.warn, `unknown chart type "blimp"`) || m.overlay != nil {210+ t.Fatalf("warn %q overlay %T", m.warn, m.overlay)211+ }212+}