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
| M | internal/ | 53 | +++++++++++ |
| A | internal/ | 147 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 25 | +++-- |
| M | internal/ | 6 | +- |
| M | internal/ | 16 | ++- |
| M | internal/ | 36 | ++++--- |
| M | internal/ | 2 | + |
| M | internal/ | 8 | +- |
@@ -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() {@@ -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+}@@ -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 }@@ -15,8 +15,10 @@ func (cfKind) title() string { return "Conditional format rules"
1515 func (cfKind) count(s *sheet.Sheet) int { return len(s.CondFormats()) }1616 func (cfKind) remove(s *sheet.Sheet, i int) { s.DeleteCondFormat(i) }1717 18-func (cfKind) ordered() bool { return true }19-func (cfKind) listHint() string { return "The first rule that matches a cell wins" }18+func (cfKind) ordered() bool { return true }19+20+func (cfKind) command(action string) string { return "format.conditional_" + action }21+func (cfKind) listHint() string { return "The first rule that matches a cell wins" }2022 2123 func (cfKind) move(s *sheet.Sheet, i, to int) bool {2224 s.MoveCondFormat(i, to)@@ -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 @@ -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) {@@ -19,6 +19,8 @@ func (dvKind) count(s *sheet.Sheet) int { return len(s.Validations()) }
1919 func (dvKind) remove(s *sheet.Sheet, i int) { s.DeleteValidation(i) }2020 func (dvKind) move(*sheet.Sheet, int, int) bool { return false }2121 func (dvKind) ordered() bool { return false }22+23+func (dvKind) command(action string) string { return "data.validation_" + action }2224 func (dvKind) listHint() string {2325 return "A cell has one rule: a new rule takes its cells from the others"2426 }@@ -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"))