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 / chartmacro.go
chartmacro.go 6.6K
1package ui23import (4 "bytes"5 "encoding/json"6 "errors"7 "fmt"8 "slices"9 "strconv"1011 tea "charm.land/bubbletea/v2"1213 "src.rest/cat/012/internal/macro"14 "src.rest/cat/012/internal/sheet"15)1617// 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.2526// jsonField is a field of a JSON object, in the order written.27type jsonField struct {28 key string29 value json.RawMessage30}3132// jsonFields reads a JSON object's fields in order.33func 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}5253// jsonObject writes fields as a JSON object.54func 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}6970func fieldIndex(fields []jsonField, key string) int {71 return slices.IndexFunc(fields, func(f jsonField) bool { return f.key == key })72}7374// 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.77func 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}103104// 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.106func 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}123124// recordChart records chart i inserted or changed from before to after,125// as the command id that does it answered with the chart. A chart placed126// (moved or resized while selected, not by a command) comes after the127// selection it follows.128func (m *Model) recordChart(id string, i int, before, after sheet.Chart, placed bool) {129 r := m.rec130 if r == nil || r.depth > 0 {131 return132 }133 if placed {134 r.flush(m)135 }136 if id == "insert.chart" {137 m.recordDialog(id, after.JSON())138 return139 }140 changes := chartChanges(before, after)141 if len(changes) == 0 {142 return143 }144 n := json.RawMessage(strconv.Itoa(i + 1))145 if last, ok := r.lastPlacement(i + 1); ok && placed && placement(changes) {146 for _, f := range changes {147 if k := fieldIndex(last, f.key); k >= 0 {148 last[k].value = f.value149 } else {150 last = append(last, f)151 }152 }153 r.actions[len(r.actions)-1] = macro.Call("run", id).With("answer", macro.JSON(jsonObject(last)))154 r.acted = true155 return156 }157 m.recordDialog(id, jsonObject(append([]jsonField{{"chart", n}}, changes...)))158}159160// placement reports whether fields only place a chart: where it is and161// its size.162func placement(fields []jsonField) bool {163 for _, f := range fields {164 switch f.key {165 case "chart", "at", "width", "height":166 default:167 return false168 }169 }170 return true171}172173// lastPlacement is the answer of the last action recorded when it placed174// chart n, so that moving a chart step by step records one call.175func (r *recorder) lastPlacement(n int) ([]jsonField, bool) {176 if len(r.actions) == 0 {177 return nil, false178 }179 a := r.actions[len(r.actions)-1]180 if a.Func != "run" || len(a.Args) != 1 || a.Args[0] != "chart.edit" || len(a.Named) != 1 {181 return nil, false182 }183 text, ok := a.Named[0].Value.(macro.JSON)184 if !ok {185 return nil, false186 }187 fields, err := jsonFields(string(text))188 if err != nil || !placement(fields) {189 return nil, false190 }191 if i := fieldIndex(fields, "chart"); i < 0 || string(fields[i].value) != strconv.Itoa(n) {192 return nil, false193 }194 return fields, true195}196197// answerInsertChart inserts a chart as a script answers Insert > Chart:198// the chart the selection would make, with the answer's fields.199func (m *Model) answerInsertChart(text string) (tea.Cmd, error) {200 fields, err := jsonFields(text)201 if err != nil {202 return nil, err203 }204 m.insertChart()205 e, ok := m.overlay.(*chartEditor)206 if !ok {207 return nil, errors.New(m.note)208 }209 return nil, e.answer(fields)210}211212// answerEditChart changes a chart as a script answers Edit chart: the213// chart numbered "chart", or the one the command would edit, with the214// answer's fields.215func (m *Model) answerEditChart(text string) (tea.Cmd, error) {216 fields, err := jsonFields(text)217 if err != nil {218 return nil, err219 }220 charts := m.sheet.Charts()221 i := m.targetChart()222 if k := fieldIndex(fields, "chart"); k >= 0 {223 n, err := strconv.Atoi(string(fields[k].value))224 if err != nil || n < 1 || n > len(charts) {225 return nil, fmt.Errorf("there's no chart %s on %s: it has %d", fields[k].value, m.sheet.Name(), len(charts))226 }227 i = n - 1228 }229 if i < 0 {230 return nil, fmt.Errorf("there's no chart on %s", m.sheet.Name())231 }232 return nil, m.openChartEditor(i, false, m.book().Checkpoint()).answer(fields)233}234235// answer makes the chart the answer's fields say and closes the editor.236func (e *chartEditor) answer(fields []jsonField) error {237 c, err := chartWith(e.chart(), fields)238 if err != nil {239 e.m.closeOverlay()240 return err241 }242 e.set(func(x *sheet.Chart) { *x = c })243 e.m.closeOverlay()244 return nil245}