Pastes (Ctrl+V, values, text from another program) and fills (Ctrl+D, Ctrl+R, Ctrl+Enter, the fill handle) are checked after they're written: a cell whose rule rejects takes the whole change back (Discard, which leaves nothing to redo) with the reason in ERROR mode, or keeps Ctrl+Enter's entry open; rules that warn keep it and say how many cells are invalid. Sheet.InvalidIn lists what fails in a range. Cut and paste on a sheet takes the cut cells' conditional formats and validation along and takes the landed-on cells out of theirs; a rule with a relative formula that partly moves splits in two, each written for its own first cell.
- commit
- b4e042d548b2593307e801a411646a21c44101a0
- parent
- 126ed86872d1ac6f0a7997250a6347a589e8d8e9
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 19 | +++++- |
| M | internal/ | 1 | + |
| A | internal/ | 117 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 75 | +++++++++++++++++++ |
| A | internal/ | 53 | ++++++++++++++ |
| M | internal/ | 8 | ++ |
| M | internal/ | 8 | ++- |
| M | internal/ | 3 | + |
| M | internal/ | 48 | ++++++++++++ |
| A | internal/ | 88 | +++++++++++++++++++++++ |
@@ -53,6 +53,16 @@ results. They follow inserted and deleted rows and columns, and a rule
5353 whose cells are all deleted goes. Format > Clear conditional formats5454 takes the selection out of every rule.5555 56+Rules, conditional formats and validation alike, move with cut and57+paste on a sheet, as in Sheets: the cut cells take their rules to where58+they land, and the cells they land on lose theirs. A rule whose cells59+partly move keeps both parts; with a custom formula it becomes two60+rules, each with the formula written for its own first cell, so the61+cells that stayed read what they read and the moved ones read the cells62+beside their new places. Formulas in rules follow cells that63+move. Cut on one sheet and pasted on another, the cells leave their64+rules behind and meet the rules where they land.65+5666 ## Data validation5767 5868 Data > Data validation opens the sheet's validation rules in the same@@ -87,7 +97,14 @@ are always valid.
8797 8898 Each cell has one rule: a rule added over cells that had another takes8999 them from it. Validation checks what's typed, picked from a dropdown and90-entered by macros; pastes, fills and imports aren't refused, and what100+entered by macros. Pastes (Ctrl+V, paste values, text pasted from101+another program) and fills (Ctrl+D, Ctrl+R, Ctrl+Enter, the fill102+handle) are checked after they're written, as in Sheets: when a cell's103+rule rejects what landed there, the whole paste or fill is undone and104+ERROR mode says which cell and why ("Paste undone: Invalid entry in105+B2: ..."; Ctrl+Enter keeps the entry open instead); when only rules that106+warn fail, it stays, the cells are marked, and the context line says how107+many ("2 cells invalid, first B2: ..."). Imports aren't checked, and what91108 they leave invalid is marked. Validation can't go on a pivot table's92109 results, which take no entries. Data > Remove data validation takes the93110 selection out of every rule.@@ -38,6 +38,7 @@ func (s *Sheet) Move(src Rect, to Addr) (Rect, error) {
3838 s.remap(label, dst, cell, rng)3939 s.remapNames(rng) // a name for exactly the moved cells follows them4040 s.moveFormats(s, &f, src, dst)41+ s.moveRules(src, dst, cell, rng) // rulemove.go4142 })4243 return dst, nil4344 }@@ -0,0 +1,117 @@
1+package sheet2+3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/012/internal/formula"7+)8+9+// Conditional formats and validation move with cut and paste on a sheet,10+// as in Sheets: the cut cells take their rules to where they land, the11+// cells they land on lose theirs, and formulas in the rules follow the12+// cells they read.13+14+// moveRules moves the sheet's rules with the cells in src, landing on15+// dst; cell and rng are the move's, for the rules' formulas.16+func (s *Sheet) moveRules(src, dst Rect, cell func(Addr) (Addr, bool), rng func(Rect) (Rect, bool)) {17+ if s.rules.empty() {18+ return19+ }20+ next := s.rules.moved(src, dst, formula.Relocate(s.onThis(s), cell, rng))21+ if !next.equal(s.rules) {22+ s.recordRules()23+ s.rules = next24+ }25+}26+27+// moved is the rules after the cells in src move to dst, with their28+// formulas rewritten by rw. A rule whose cells are partly moved keeps one29+// range list, or, when its formula is relative to its first cell, splits30+// into a rule for the cells that stayed and one for those that moved,31+// each with the formula written for its own first cell.32+func (r rulesState) moved(src, dst Rect, rw formula.Rewriter) rulesState {33+ d := Addr{Col: dst.From.Col - src.From.Col, Row: dst.From.Row - src.From.Row}34+ var out rulesState35+ for _, f := range r.formats {36+ for _, p := range movedParts(f.Ranges, src, dst, d, f.Op == RuleFormula) {37+ g := f38+ g.Ranges = p.ranges39+ if f.Op == RuleFormula {40+ g.Args[0] = reanchor(f.Args[0], p.shift, rw)41+ }42+ out.formats = append(out.formats, g)43+ }44+ }45+ for _, v := range r.validations {46+ for _, p := range movedParts(v.Ranges, src, dst, d, v.Kind == ValidFormula) {47+ w := v.clone()48+ w.Ranges = p.ranges49+ switch v.Kind {50+ case ValidFormula:51+ w.Args[0] = reanchor(v.Args[0], p.shift, rw)52+ case ValidRange:53+ w.Source = strings.TrimPrefix(shiftFormula("="+v.Source, rw), "=")54+ }55+ out.validations = append(out.validations, w)56+ }57+ }58+ return out59+}60+61+// rulePart is the ranges of a rule after a move, and how far its62+// formula's relative references shift: from the rule's first cell to63+// where the part's first cell was before the move.64+type rulePart struct {65+ ranges []Rect66+ shift Addr67+}68+69+// movedParts splits a rule's ranges rs by the move of src to dst (d70+// away): the cells that stay, outside both, and those that move. split71+// keeps the two apart, for a rule with a relative formula.72+func movedParts(rs []Rect, src, dst Rect, d Addr, split bool) []rulePart {73+ var kept, moved []Rect74+ for _, r := range rs {75+ for _, k := range subtract(r, src) {76+ kept = append(kept, subtract(k, dst)...)77+ }78+ if in, ok := overlap(r, src); ok {79+ moved = append(moved, Rect{From: plus(in.From, d), To: plus(in.To, d)})80+ }81+ }82+ from := anchor(rs)83+ keptPart := func() rulePart { return rulePart{kept, minus(anchor(kept), from)} }84+ movedPart := func() rulePart { return rulePart{moved, minus(minus(anchor(moved), d), from)} }85+ switch {86+ case len(moved) == 0 && len(kept) == 0:87+ return nil88+ case len(moved) == 0:89+ return []rulePart{keptPart()}90+ case len(kept) == 0:91+ return []rulePart{movedPart()}92+ case split:93+ return []rulePart{keptPart(), movedPart()}94+ }95+ return []rulePart{{ranges: append(kept, moved...)}}96+}97+98+// reanchor rewrites a rule's formula (with its "=") for a first cell99+// shift away, then for the move (rw).100+func reanchor(src string, shift Addr, rw formula.Rewriter) string {101+ if shift != (Addr{}) {102+ src = shiftFormula(src, formula.Shift(shift.Col, shift.Row))103+ }104+ return shiftFormula(src, rw)105+}106+107+func plus(a, d Addr) Addr { return Addr{Col: a.Col + d.Col, Row: a.Row + d.Row} }108+func minus(a, d Addr) Addr { return Addr{Col: a.Col - d.Col, Row: a.Row - d.Row} }109+110+// overlap is the cells a and b share, and whether they share any.111+func overlap(a, b Rect) (Rect, bool) {112+ r := Rect{113+ From: Addr{Col: max(a.From.Col, b.From.Col), Row: max(a.From.Row, b.From.Row)},114+ To: Addr{Col: min(a.To.Col, b.To.Col), Row: min(a.To.Row, b.To.Row)},115+ }116+ return r, r.From.Col <= r.To.Col && r.From.Row <= r.To.Row117+}@@ -0,0 +1,75 @@
1+package sheet2+3+import "testing"4+5+// Rules move with cut and paste: the cut cells take them along, the6+// cells they land on lose theirs, and relative formulas stay right for7+// the cells that stayed and those that moved.8+func TestRulesMoveWithCells(t *testing.T) {9+ s := New()10+ for r := range 10 {11+ s.Set(Addr{Row: r}, "5")12+ }13+ mustAdd(t, s, CondFormat{Ranges: ranges("A1:A10"), Op: RuleFormula, Args: [2]string{"=A1>4"}, Style: green})14+ mustAdd(t, s, CondFormat{Ranges: ranges("A1:A10"), Op: RuleGreater, Args: [2]string{"3"}, Style: green})15+ if err := s.AddValidation(Validation{Ranges: ranges("A1:A10"), Kind: ValidNumber, Op: RuleBetween, Args: [2]string{"1", "9"}, Reject: true}); err != nil {16+ t.Fatal(err)17+ }18+ if err := s.AddValidation(Validation{Ranges: ranges("C1:C3"), Kind: ValidList, Items: []string{"x"}}); err != nil {19+ t.Fatal(err)20+ }21+ if _, err := s.Move(rect("A1:A3"), at("C2")); err != nil {22+ t.Fatal(err)23+ }24+ fs, vs := s.CondFormats(), s.Validations()25+ if len(fs) != 3 {26+ t.Fatalf("formats %+v", fs)27+ }28+ // The formula rule splits: A4:A10 reads its own cells, and the moved29+ // cells read theirs, at their new places.30+ if RangesText(fs[0].Ranges) != "A4:A10" || fs[0].Args[0] != "=A4>4" {31+ t.Errorf("kept part: %s %s", RangesText(fs[0].Ranges), fs[0].Args[0])32+ }33+ if RangesText(fs[1].Ranges) != "C2:C4" || fs[1].Args[0] != "=C2>4" {34+ t.Errorf("moved part: %s %s", RangesText(fs[1].Ranges), fs[1].Args[0])35+ }36+ if RangesText(fs[2].Ranges) != "A4:A10,C2:C4" {37+ t.Errorf("a plain rule keeps one list: %s", RangesText(fs[2].Ranges))38+ }39+ // The list rule loses the cells landed on; the number rule reaches them.40+ if len(vs) != 2 || RangesText(vs[0].Ranges) != "A4:A10,C2:C4" || RangesText(vs[1].Ranges) != "C1" {41+ t.Errorf("validations %s, %s", RangesText(vs[0].Ranges), RangesText(vs[len(vs)-1].Ranges))42+ }43+ if !s.Look(at("C3")).Styled || s.Look(at("A2")).Styled {44+ t.Error("looks don't follow the move")45+ }46+ s.Undo()47+ if fs := s.CondFormats(); len(fs) != 2 || RangesText(fs[0].Ranges) != "A1:A10" || fs[0].Args[0] != "=A1>4" {48+ t.Errorf("undo: %+v", fs)49+ }50+}51+52+// A paste or fill is checked after the fact: InvalidIn lists what fails,53+// and Discard takes the change back without leaving it to redo.54+func TestInvalidInAndDiscard(t *testing.T) {55+ s := New()56+ if err := s.AddValidation(Validation{Ranges: ranges("B1:B9"), Kind: ValidNumber, Op: RuleBetween, Args: [2]string{"1", "10"}, Reject: true}); err != nil {57+ t.Fatal(err)58+ }59+ s.Set(at("A1"), "5")60+ s.Set(at("A2"), "50")61+ s.Set(at("A3"), "x")62+ if _, err := s.Paste(s.Copy(rect("A1:A3")), rect("B1"), false); err != nil {63+ t.Fatal(err)64+ }65+ bad := s.InvalidIn(rect("B1:B3"))66+ if len(bad) != 2 || bad[0].Addr != at("B2") || bad[1].Addr != at("B3") || !bad[0].Reject {67+ t.Fatalf("invalid %+v", bad)68+ }69+ if !s.Discard() || s.Value(at("B2")).Kind != Empty || s.CanRedo() {70+ t.Errorf("discard: B2 %v, redo %v", s.Value(at("B2")), s.CanRedo())71+ }72+ if len(s.InvalidIn(rect("B1:B3"))) != 0 {73+ t.Error("still invalid after discarding")74+ }75+}@@ -0,0 +1,53 @@
1+package sheet2+3+import "slices"4+5+// Validation of what pastes and fills write, as Sheets does it: they6+// don't ask cell by cell, so the UI checks the range they wrote after7+// the fact, and takes the whole change back (Discard) when a rule that8+// rejects fails.9+10+// InvalidIn lists the cells in r whose contents fail their validation,11+// in reading order: blanks never do. A cell's rule decides whether its12+// entry is refused (Reject) or kept and marked. The cost is the cells13+// stored where r meets a rule's ranges.14+func (s *Sheet) InvalidIn(r Rect) []*InvalidEntry {15+ var out []*InvalidEntry16+ for _, v := range s.rules.validations {17+ for _, vr := range v.Ranges {18+ in := Rect{19+ From: Addr{Col: max(vr.From.Col, r.From.Col), Row: max(vr.From.Row, r.From.Row)},20+ To: Addr{Col: min(vr.To.Col, r.To.Col), Row: min(vr.To.Row, r.To.Row)},21+ }22+ if in.From.Col > in.To.Col || in.From.Row > in.To.Row {23+ continue24+ }25+ for _, a := range s.cellsIn(in) {26+ if c := s.cells.get(a); !c.Blank() && s.Look(a).Invalid {27+ out = append(out, &InvalidEntry{Addr: a, Help: v.HelpText(), Reject: v.Reject})28+ }29+ }30+ }31+ }32+ slices.SortFunc(out, func(a, b *InvalidEntry) int {33+ if a.Addr.Row != b.Addr.Row {34+ return a.Addr.Row - b.Addr.Row35+ }36+ return a.Addr.Col - b.Addr.Col37+ })38+ return out39+}40+41+// Discard takes back the last change and forgets it, so redo can't bring42+// it back: a paste or fill refused after the fact. It reports whether43+// there was a change to take back.44+func (w *Workbook) Discard() bool {45+ if _, ok := w.swap(true); !ok {46+ return false47+ }48+ w.hist.redo = w.hist.redo[:len(w.hist.redo)-1]49+ return true50+}51+52+// Discard takes back the workbook's last change; see Workbook.Discard.53+func (s *Sheet) Discard() bool { return s.wb.Discard() }@@ -79,6 +79,11 @@ func (m *Model) paste(values bool) tea.Cmd {
7979 m.fail(err.Error())8080 return nil8181 }82+ // Cells moved on their sheet take their rules along (sheet.Move);83+ // anything else meets the rules where it lands.84+ if (!c.cut || values || c.sheet != m.sheet) && !m.checkWritten(r, "Paste") {85+ return nil86+ }8287 m.selectRect(r)8388 switch {8489 case c.cut && !values:@@ -174,6 +179,9 @@ func (m *Model) pasteText(content string) bool {
174179 }175180 return nil176181 })182+ if !m.checkWritten(r, "Paste") {183+ return true184+ }177185 m.selectRect(r)178186 m.note = "Pasted " + countCells(r) + " at " + r.String()179187 return true@@ -134,9 +134,12 @@ func (g *grid) selectRect(r sheet.Rect) {
134134 }135135 136136 func (m *Model) fill(fn func(sheet.Rect) (sheet.Rect, error)) tea.Cmd {137- if _, err := fn(m.selection()); err != nil {137+ r, err := fn(m.selection())138+ if err != nil {138139 m.fail(err.Error())140+ return nil139141 }142+ m.checkWritten(r, "Fill")140143 return nil141144 }142145 @@ -156,6 +159,9 @@ func (m *Model) fillEntry() bool {
156159 m.entryError(err, input)157160 return false158161 }162+ if m.checkEntryFill(m.entrySheet().InvalidIn(m.selection()), input) {163+ return false164+ }159165 m.cancelEntry()160166 m.recordEntry(input, true)161167 return true@@ -93,6 +93,9 @@ func (m *Model) finishFill() {
9393 m.fail(err.Error())9494 return9595 }96+ if !m.checkWritten(got, "Fill") {97+ return98+ }9699 m.changed = true97100 m.selectRect(got)98101 m.record(fillAction(m.rec, src, dst))@@ -2,6 +2,7 @@ package ui
22 33 import (44 "errors"5+ "fmt"56 "image/color"67 78 tea "charm.land/bubbletea/v2"@@ -258,6 +259,53 @@ func (m *Model) checkEntry(input string) (warn string, ok bool) {
258259 return bad.Error(), true259260 }260261 262+// checkWritten checks what a paste or fill (what) wrote into r against263+// the cells' validation, as Sheets does: when a rule that rejects fails,264+// the whole change is taken back and ERROR mode says why; when only265+// rules that warn fail, it stays, the cells are marked, and the context266+// line says how many. It reports whether the change stayed.267+func (m *Model) checkWritten(r sheet.Rect, what string) bool {268+ if !m.sheet.HasRules() {269+ return true270+ }271+ bad := m.sheet.InvalidIn(r)272+ for _, b := range bad {273+ if b.Reject {274+ m.sheet.Discard()275+ m.fail(what + " undone: " + b.Error())276+ return false277+ }278+ }279+ if len(bad) > 0 {280+ m.warn = invalidNote(bad)281+ }282+ return true283+}284+285+// checkEntryFill handles the cells an entry filled (Ctrl+Enter) that fail286+// their validation: a rule that rejects takes the fill back and keeps287+// the entry open with the rule's help, as for one cell; rules that warn288+// let it in, saying so. It reports whether the entry stays open.289+func (m *Model) checkEntryFill(bad []*sheet.InvalidEntry, input string) bool {290+ for _, b := range bad {291+ if b.Reject {292+ m.entrySheet().Discard()293+ m.entryError(b, input)294+ return true295+ }296+ }297+ if len(bad) > 0 {298+ m.warn = invalidNote(bad)299+ }300+ return false301+}302+303+// invalidNote says how many cells a paste or fill left invalid, and why304+// the first is.305+func invalidNote(bad []*sheet.InvalidEntry) string {306+ return fmt.Sprintf("%s invalid, first %s: %s", cellCount(len(bad)), bad[0].Addr, bad[0].Help)307+}308+261309 // The rules panel's host.262310 263311 func (h host) Sheet() *sheet.Sheet { return h.m.sheet }@@ -0,0 +1,88 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// Pastes and fills meet the validation of the cells they write, as in13+// Sheets: a rule that rejects takes the whole change back, one that warns14+// keeps it and says so.15+func TestValidationOnPastesAndFills(t *testing.T) {16+ setup := func(t *testing.T) *Model {17+ m := newModel()18+ mustValidate(t, m, `{"ranges":"B1:B9","criteria":"number","condition":"between","values":["1","10"],"reject":true}`)19+ mustValidate(t, m, `{"ranges":"C1:C9","criteria":"number","condition":"between","values":["1","10"]}`)20+ m.sheet.Set(addr("A1"), "5")21+ m.sheet.Set(addr("A2"), "50")22+ return m23+ }24+25+ t.Run("paste rejected", func(t *testing.T) {26+ m := setup(t)27+ press(t, m, "<shift+down>", "<ctrl+c>", "<right>", "<ctrl+v>")28+ if m.mode != modeError || input(m, "B1") != "" || input(m, "B2") != "" || m.sheet.CanRedo() {29+ t.Fatalf("mode %v B1 %q B2 %q", m.mode, input(m, "B1"), input(m, "B2"))30+ }31+ if m.errMsg != "Paste undone: Invalid entry in B2: Input must be a number between 1 and 10" {32+ t.Errorf("reason %q", m.errMsg)33+ }34+ })35+36+ t.Run("paste warned", func(t *testing.T) {37+ m := setup(t)38+ press(t, m, "<shift+down>", "<ctrl+c>", "<right>", "<right>", "<ctrl+v>")39+ if input(m, "C2") != "50" || !m.sheet.Look(addr("C2")).Invalid {40+ t.Fatalf("C2 %q", input(m, "C2"))41+ }42+ if l := line(m, contextLine); !strings.Contains(l, "1 cell invalid, first C2: Input must be a number between 1 and 10") {43+ t.Errorf("context line %q", l)44+ }45+ })46+47+ t.Run("text paste rejected", func(t *testing.T) {48+ m := setup(t)49+ m.cur = addr("B1")50+ send(m, tea.PasteMsg{Content: "3\n30"})51+ if m.mode != modeError || input(m, "B1") != "" {52+ t.Errorf("mode %v B1 %q", m.mode, input(m, "B1"))53+ }54+ })55+56+ t.Run("fill down rejected", func(t *testing.T) {57+ m := setup(t)58+ m.sheet.Set(addr("B1"), "7")59+ m.sheet.Set(addr("B1"), "=A1*2") // 10, valid; filled down it's 10060+ press(t, m, "<right>", "<shift+down>", "<ctrl+d>")61+ if m.mode != modeError || input(m, "B2") != "" {62+ t.Errorf("mode %v B2 %q", m.mode, input(m, "B2"))63+ }64+ })65+66+ t.Run("ctrl+enter rejected keeps the entry", func(t *testing.T) {67+ m := setup(t)68+ press(t, m, "<right>", "<shift+down>", "=A1*2", "<ctrl+enter>")69+ if m.mode != modeEdit || input(m, "B1") != "" || !strings.Contains(line(m, contextLine), "Invalid entry in B2") {70+ t.Errorf("mode %v B1 %q %q", m.mode, input(m, "B1"), line(m, contextLine))71+ }72+ })73+}74+75+// Cut and paste takes the cells' rules along.76+func TestRulesMoveWithCutAndPaste(t *testing.T) {77+ m := newModel()78+ mustValidate(t, m, `{"ranges":"A1:A2","criteria":"list","items":["Yes","No"]}`)79+ m.sheet.Set(addr("A1"), "Yes")80+ press(t, m, "<shift+down>", "<ctrl+x>", "<right>", "<right>", "<ctrl+v>")81+ vs := m.sheet.Validations()82+ if len(vs) != 1 || sheet.RangesText(vs[0].Ranges) != "C1:C2" || !m.sheet.Look(addr("C1")).Dropdown {83+ t.Fatalf("validations %+v", vs)84+ }85+ if m.mode == modeError {86+ t.Errorf("a move was refused: %s", screen(m))87+ }88+}