cat / 012
clone

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

Rules panel: macros record and replay edits, removals and moves

Dami committed 01489f7
New commands replace, remove and move a conditional format rule, and
replace or remove a validation rule, by its number from a line of the
file; the panel's edits are recorded as them, and the rules it adds as
dict answers.
commit
01489f73f55754c3033fc2bc1d4af671e4d73f87
parent
67f9f1535644c9f722655106798ea7eb4dd462a7
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.9 of 3, Jev's estimate
8 files changed, +255 -38
Minternal/ui/macrodialog_test.go53+++++++++++
Ainternal/ui/rulemacro.go147++++++++++++++++++++++++++++++
Minternal/ui/rules.go25+++--
Minternal/ui/rules/condfmt.go6+-
Minternal/ui/rules/editor.go16++-
Minternal/ui/rules/editor_test.go36++++---
Minternal/ui/rules/validation.go2+
Minternal/ui/rules_test.go8+-
M internal/ui/macrodialog_test.go +53 -0
@@ -232,6 +232,59 @@ select("A4")`
232232 replays(t, m, "Replace", salesModel)233233 }234234 235+// salesRules is salesModel with two conditional formats on B2:B5 and a236+// dropdown on A2:A5.237+func salesRules() *Model {238+ m := salesModel()239+ for _, line := range []string{`{"ranges":"B2:B5","condition":"gt","values":["20"],"fill":"green"}`, `{"ranges":"B2:B5","condition":"lt","values":["10"],"fill":"red"}`} {240+ f, _ := sheet.ParseCondFormat(line)241+ m.sheet.AddCondFormat(f)242+ }243+ v, _ := sheet.ParseValidation(`{"ranges":"A2:A5","criteria":"list","items":["North","South","East","West"]}`)244+ m.sheet.AddValidation(v)245+ return m246+}247+248+func TestRecordRulesPanelEdits(t *testing.T) {249+ m := salesRules()250+ got := recordDo(t, m, "Rules", func() {251+ run(m, m.runCommand("format.conditional"))252+ press(t, m, "<down>", "<shift+down>") // green after red253+ press(t, m, "<enter>", "<down>", "<down>", "<down>", "<backspace>", "<backspace>", "30", "<enter>", "<esc>")254+ run(m, m.runCommand("data.validation"))255+ press(t, m, "<down>", "<delete>", "<esc>")256+ })257+ want := `run("format.conditional_move", answer={"rule": 1, "to": 2})258+run("format.conditional_set", answer={"rule": 2, "ranges": "B2:B5", "condition": "gt", "values": ["30"], "fill": "green"})259+run("data.validation_remove", answer=1)`260+ if got != want {261+ t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)262+ }263+ replays(t, m, "Rules", salesRules)264+}265+266+func TestScriptsChangeRules(t *testing.T) {267+ m := salesRules()268+ script(t, m, `run("format.conditional_set", answer={"rule": 1, "ranges": "C2:C5", "condition": "empty", "fill": "yellow"})269+run("data.validation_set", answer={"rule": 1, "ranges": "A2:A9", "criteria": "list", "items": ["N", "S"]})270+run("format.conditional_move", answer={"rule": 2, "to": 1})`)271+ fs, vs := m.sheet.CondFormats(), m.sheet.Validations()272+ if len(fs) != 2 || fs[1].Ranges[0].String() != "C2:C5" || vs[0].Ranges[0].String() != "A2:A9" || m.note != "Ran S" {273+ t.Fatalf("formats %+v validations %+v warn %q", fs, vs, m.warn)274+ }275+ for src, want := range map[string]string{276+ `run("format.conditional_remove", answer=3)`: "there's no rule 3: the sheet has 2",277+ `run("format.conditional_move", answer={"rule": 1})`: `give "to", a place from 1 to 2`,278+ `run("data.validation_set", answer={"ranges": "A1"})`: "give the rule's number",279+ `run("format.conditional_set", answer={"rule": 1, "ranges": "?"})`: `invalid ranges "?"`,280+ } {281+ script(t, m, src)282+ if !strings.Contains(m.warn, want) {283+ t.Errorf("%s: warn %q, want %q", src, m.warn, want)284+ }285+ }286+}287+235288 func TestRecordPivotEditor(t *testing.T) {236289 m := wideSales()237290 got := recordDo(t, m, "Pivot", func() {
A internal/ui/rulemacro.go +147 -0
@@ -0,0 +1,147 @@
1+package ui2+3+import (4+ "encoding/json"5+ "fmt"6+ "strconv"7+ "strings"8+9+ tea "charm.land/bubbletea/v2"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// Rules in macros: what the rules panel does is recorded as the commands15+// that do it from a line of the file, and those commands are how scripts16+// change rules. A rule is numbered from 1 in the panel's order.17+//18+// run("format.conditional_add", answer={"ranges": "B2:B9", ...}) a rule added19+// run("format.conditional_set", answer={"rule": 2, "ranges": ...}) rule 2 replaced20+// run("format.conditional_remove", answer=2) rule 2 removed21+// run("format.conditional_move", answer={"rule": 2, "to": 1}) rule 2 moved first22+//23+// and the same for data validation, whose rules have no order.24+25+func init() {26+ hasFormats := func(m *Model) bool { return len(m.sheet.CondFormats()) > 0 }27+ hasValidations := func(m *Model) bool { return len(m.sheet.Validations()) > 0 }28+ register(29+ &command{id: "format.conditional_set", title: "Replace conditional format rule", enabled: hasFormats,30+ desc: `Replace a rule with one written as a line of the file, numbered: {"rule":2,"ranges":"B2:B9",...}`,31+ run: func(m *Model) tea.Cmd {32+ m.askRule(func(m *Model, text string) error {33+ i, err := ruleNumber(text, len(m.sheet.CondFormats()))34+ if err != nil {35+ return err36+ }37+ f, err := sheet.ParseCondFormat(text)38+ if err != nil {39+ return err40+ }41+ return m.host().SaveFormat(i, f)42+ })43+ return nil44+ }},45+ &command{id: "format.conditional_remove", title: "Remove conditional format rule", enabled: hasFormats,46+ desc: "Remove a rule by its number in the list, counting from 1",47+ run: func(m *Model) tea.Cmd {48+ m.askRule(func(m *Model, text string) error {49+ i, err := ruleNumber(text, len(m.sheet.CondFormats()))50+ if err == nil {51+ m.sheet.DeleteCondFormat(i)52+ m.changed = true53+ }54+ return err55+ })56+ return nil57+ }},58+ &command{id: "format.conditional_move", title: "Move conditional format rule", enabled: hasFormats,59+ desc: `Move a rule to another place in the list, where the first rule that matches wins: {"rule":2,"to":1}`,60+ run: func(m *Model) tea.Cmd {61+ m.askRule(func(m *Model, text string) error {62+ n := len(m.sheet.CondFormats())63+ i, err := ruleNumber(text, n)64+ if err != nil {65+ return err66+ }67+ var a struct {68+ To int `json:"to"`69+ }70+ if json.Unmarshal([]byte(text), &a); a.To < 1 || a.To > n {71+ return fmt.Errorf(`give "to", a place from 1 to %d`, n)72+ }73+ m.sheet.MoveCondFormat(i, a.To-1)74+ m.changed = true75+ return nil76+ })77+ return nil78+ }},79+ &command{id: "data.validation_set", title: "Replace data validation rule", enabled: hasValidations,80+ desc: `Replace a rule with one written as a line of the file, numbered: {"rule":2,"ranges":"D2:D9",...}`,81+ run: func(m *Model) tea.Cmd {82+ m.askRule(func(m *Model, text string) error {83+ i, err := ruleNumber(text, len(m.sheet.Validations()))84+ if err != nil {85+ return err86+ }87+ v, err := sheet.ParseValidation(text)88+ if err != nil {89+ return err90+ }91+ return m.host().SaveValidation(i, v)92+ })93+ return nil94+ }},95+ &command{id: "data.validation_remove", title: "Remove data validation rule", enabled: hasValidations,96+ desc: "Remove a rule by its number in the list, counting from 1",97+ run: func(m *Model) tea.Cmd {98+ m.askRule(func(m *Model, text string) error {99+ i, err := ruleNumber(text, len(m.sheet.Validations()))100+ if err == nil {101+ m.sheet.DeleteValidation(i)102+ m.changed = true103+ }104+ return err105+ })106+ return nil107+ }},108+ )109+}110+111+// askRule asks for a rule's number, or a rule, on the context line, and112+// hands it to do.113+func (m *Model) askRule(do func(m *Model, text string) error) {114+ m.openText("Rule:", "", func(m *Model, text string) tea.Cmd {115+ m.failOn(do(m, text))116+ return nil117+ })118+}119+120+// ruleNumber reads which of n rules an answer names, counting from 1:121+// the number itself, or "rule" in a line of the file.122+func ruleNumber(text string, n int) (int, error) {123+ i, err := strconv.Atoi(strings.TrimSpace(text))124+ if err != nil {125+ var a struct {126+ Rule int `json:"rule"`127+ }128+ if json.Unmarshal([]byte(text), &a) != nil || a.Rule == 0 {129+ return 0, fmt.Errorf(`give the rule's number, from 1 to %d: {"rule":1,...}`, n)130+ }131+ i = a.Rule132+ }133+ if i < 1 || i > n {134+ return 0, fmt.Errorf("there's no rule %d: the sheet has %d", i, n)135+ }136+ return i - 1, nil137+}138+139+// numbered is a rule's line with its number first, as the commands that140+// replace or move a rule take it.141+func numbered(i int, line string) string {142+ rest := strings.TrimPrefix(line, "{")143+ if rest != "}" {144+ rest = "," + rest145+ }146+ return `{"rule":` + strconv.Itoa(i+1) + rest147+}
M internal/ui/rules.go +13 -12
@@ -335,20 +335,21 @@ func (h host) SaveFormat(i int, f sheet.CondFormat) error {
335335 if i < 0 {336336 m.recordRule("format.conditional_add", f.JSON())337337 } else {338- m.noteUnrecorded()338+ m.recordRule("format.conditional_set", numbered(i, f.JSON()))339339 }340340 }341341 return err342342 }343343 344-// Reworked follows a rule removed or moved in the panel.345-func (h host) Reworked() {344+// Reworked follows a rule removed or moved in the panel, recorded as the345+// command id that does the same, answered with answer.346+func (h host) Reworked(id, answer string) {346347 h.m.changed = true347- h.m.noteUnrecorded()348+ h.m.recordRule(id, answer)348349 }349350 350-// noteUnrecorded notes in a recording the last undo step, a change to351-// rules the panel made that no command replays, as dialogs are noted.351+// noteUnrecorded notes in a recording the last undo step, a change no352+// command replays, as the named ranges picker makes.352353 func (m *Model) noteUnrecorded() {353354 if r := m.rec; r != nil && r.depth == 0 && r.pending == "" {354355 r.flush(m)
@@ -376,19 +377,19 @@ func (h host) SaveValidation(i int, v sheet.Validation) error {
376377 if i < 0 {377378 m.recordRule("data.validation_add", v.JSON())378379 } else {379- m.noteUnrecorded()380+ m.recordRule("data.validation_set", numbered(i, v.JSON()))380381 }381382 }382383 return err383384 }384385 385-// recordRule records a rule added from the panel as the command that386-// adds it, answered with its line. A command that adds one itself is387-// recorded where it runs.388-func (m *Model) recordRule(id, line string) {386+// recordRule records a change the panel made to rules as the command387+// that makes it, answered with a rule's line or number (rulemacro.go). A388+// command that changes one itself is recorded where it runs.389+func (m *Model) recordRule(id, answer string) {389390 if m.rec == nil || m.rec.depth > 0 || m.rec.pending != "" {390391 return391392 }392393 m.rec.flush(m)393- m.rec.add(m, macro.Call("run", id).With("answer", line))394+ m.rec.add(m, macro.Call("run", id).With("answer", macro.JSON(answer)))394395 }
M internal/ui/rules/condfmt.go +4 -2
M internal/ui/rules/editor.go +10 -6
@@ -35,8 +35,9 @@ type Host interface {
3535 SaveFormat(i int, f sheet.CondFormat) error3636 SaveValidation(i int, v sheet.Validation) error3737 // Reworked follows a rule removed or moved: the file is modified,38- // and a macro being recorded notes what it can't replay.39- Reworked()38+ // and a macro being recorded records the command id that does the39+ // same, answered with answer.40+ Reworked(id, answer string)4041 // Slot is the terminal's color for an ANSI slot, for color scales.4142 Slot(i int) color.Color4243 }
@@ -56,6 +57,9 @@ type kind interface {
5657 // says how rules combine, for the status line.5758 ordered() bool5859 listHint() string60+ // command is the id of the command that does action ("remove",61+ // "move") to a rule, as a macro records it.62+ command(action string) string5963 }6064 6165 // form is a rule being added or edited.
@@ -156,9 +160,9 @@ func (e *Editor) Key(k tea.KeyPressMsg) tea.Cmd {
156160 case "a", "+":157161 e.openForm(-1)158162 case "delete", "backspace", "x", "-":159- if e.list.Sel > 0 {160- e.k.remove(e.h.Sheet(), e.list.Sel-1)161- e.h.Reworked()163+ if i := e.list.Sel - 1; i >= 0 {164+ e.k.remove(e.h.Sheet(), i)165+ e.h.Reworked(e.k.command("remove"), strconv.Itoa(i+1))162166 e.list.Sel = min(e.list.Sel, e.k.count(e.h.Sheet()))163167 }164168 case "shift+up", "shift+down":
@@ -179,7 +183,7 @@ func (e *Editor) reorder(down bool) {
179183 }180184 if e.k.move(e.h.Sheet(), i, to) {181185 e.list.Sel = to + 1182- e.h.Reworked()186+ e.h.Reworked(e.k.command("move"), `{"rule":`+strconv.Itoa(i+1)+`,"to":`+strconv.Itoa(to+1)+`}`)183187 }184188 }185189
M internal/ui/rules/editor_test.go +22 -14
@@ -16,13 +16,14 @@ import (
1616 1717 // fakeHost is a sheet and an edit line, recording what the panel asks.1818 type fakeHost struct {19- th theme.Theme20- line lineedit.Line21- s *sheet.Sheet22- sel sheet.Rect23- closed bool24- edits int25- saves []string19+ th theme.Theme20+ line lineedit.Line21+ s *sheet.Sheet22+ sel sheet.Rect23+ closed bool24+ edits int25+ reworked []string26+ saves []string2627 }2728 2829 func newHost() *fakeHost {
@@ -33,13 +34,16 @@ func newHost() *fakeHost {
3334 return &fakeHost{th: theme.New(true), s: s, sel: sheet.Rect{To: sheet.Addr{Row: 2}}}3435 }3536 36-func (h *fakeHost) Theme() *theme.Theme { return &h.th }37-func (h *fakeHost) Size() (int, int) { return 100, 30 }38-func (h *fakeHost) Line() *lineedit.Line { return &h.line }39-func (h *fakeHost) Close() { h.closed = true }40-func (h *fakeHost) Sheet() *sheet.Sheet { return h.s }41-func (h *fakeHost) Selection() sheet.Rect { return h.sel }42-func (h *fakeHost) Reworked() { h.edits++ }37+func (h *fakeHost) Theme() *theme.Theme { return &h.th }38+func (h *fakeHost) Size() (int, int) { return 100, 30 }39+func (h *fakeHost) Line() *lineedit.Line { return &h.line }40+func (h *fakeHost) Close() { h.closed = true }41+func (h *fakeHost) Sheet() *sheet.Sheet { return h.s }42+func (h *fakeHost) Selection() sheet.Rect { return h.sel }43+func (h *fakeHost) Reworked(id, answer string) {44+ h.edits++45+ h.reworked = append(h.reworked, id+" "+answer)46+}4347 func (h *fakeHost) Slot(i int) color.Color { return color.Gray{Y: uint8(i * 16)} }4448 func (h *fakeHost) SaveFormat(i int, f sheet.CondFormat) error {4549 h.saves = append(h.saves, f.JSON())
@@ -184,6 +188,10 @@ func TestRemoveAndReorder(t *testing.T) {
184188 if fs := h.s.CondFormats(); len(fs) != 1 || fs[0].Args[0] != "2" || e.list.Sel != 1 {185189 t.Errorf("remove: %+v sel %d", fs, e.list.Sel)186190 }191+ want := `format.conditional_move {"rule":1,"to":2}` + "\n" + "format.conditional_remove 2"192+ if got := strings.Join(h.reworked, "\n"); got != want {193+ t.Errorf("as a macro records them:\n%s\nwant:\n%s", got, want)194+ }187195 }188196 189197 func TestValidationForm(t *testing.T) {
M internal/ui/rules/validation.go +2 -0
M internal/ui/rules_test.go +4 -4
@@ -160,8 +160,8 @@ func TestRuleMacros(t *testing.T) {
160160 m := newModel()161161 press(t, m, "<shift+down>")162162 src := recordRules(t, m)163- for _, want := range []string{`run("format.conditional_add", answer="{\"ranges\":\"A1:A2\",\"condition\":\"not_empty\",\"fill\":\"green\"}")`,164- `# Not recorded: remove conditional format A1:A2`,163+ for _, want := range []string{`run("format.conditional_add", answer={"ranges": "A1:A2", "condition": "not_empty", "fill": "green"})`,164+ `run("format.conditional_remove", answer=1)`,165165 `run("insert.checkbox")`, `run("data.validation_add", answer="{\"ranges\":\"C1:C2\",\"criteria\":\"list\",\"items\":[\"a\",\"b\"]}")`} {166166 if !strings.Contains(src, want) {167167 t.Errorf("script lacks %s:\n%s", want, src)
@@ -169,7 +169,7 @@ func TestRuleMacros(t *testing.T) {
169169 }170170 fresh := newModel()171171 script(t, fresh, src)172- if len(fresh.sheet.CondFormats()) != 2 || len(fresh.sheet.Validations()) != 2 { // the removal is only noted172+ if len(fresh.sheet.CondFormats()) != 1 || len(fresh.sheet.Validations()) != 2 {173173 t.Errorf("replay: %+v %+v %q", fresh.sheet.CondFormats(), fresh.sheet.Validations(), fresh.warn)174174 }175175 }
@@ -182,7 +182,7 @@ func recordRules(t *testing.T, m *Model) string {
182182 run(m, m.runCommand("macro.record"))183183 run(m, m.runCommand("format.conditional"))184184 press(t, m, "<enter>", "<enter>") // add the rule the form starts with185- press(t, m, "<down>", "<delete>", "<esc>") // removed, and noted185+ press(t, m, "<down>", "<delete>", "<esc>") // removed186186 run(m, m.runCommand("format.conditional"))187187 press(t, m, "<enter>", "<enter>", "<esc>", "<right>")188188 run(m, m.runCommand("insert.checkbox"))