cat / 012
clone

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

Add conditional formats and data validation to the engine

Dami committed e458001
Rules belong to a sheet: conditional formats (single color tests and
2 or 3 point color scales) and data validation (dropdown lists from
items or a range, checkboxes, number, date and text length bounds,
custom formulas, reject or warn). They're undoable, saved in .012 as
optional fields one rule per line, follow inserted and deleted lines
and renamed sheets, and are evaluated per cell as drawn, cached until
the next recalculation.
commit
e458001ee4e19cbe82de678972fe77857501ca7e
parent
fb9d110c486f6ce4a9cc78d2cf661559fe39da83
author
Dami <dami.etoile@gmail.com>
13 files changed, +2089 -4
Ainternal/sheet/condfmt.go356+++++++++++++++++++++++++
Minternal/sheet/file.go9+-
Minternal/sheet/history.go25++-
Minternal/sheet/historysize.go4+
Ainternal/sheet/looks.go359+++++++++++++++++++++++++
Minternal/sheet/ops.go1+
Minternal/sheet/recalc.go2+
Ainternal/sheet/rulefile.go226++++++++++++++++
Ainternal/sheet/rules.go367++++++++++++++++++++++++++
Ainternal/sheet/rules_test.go312++++++++++++++++++++++
Minternal/sheet/sheet.go3+
Ainternal/sheet/validation.go423++++++++++++++++++++++++++++++
Minternal/sheet/workbook.go6+
A internal/sheet/condfmt.go +356 -0
@@ -0,0 +1,356 @@
1+package sheet2+3+import (4+ "errors"5+ "fmt"6+ "slices"7+ "strings"8+9+ "github.com/FelineStateMachine/012/internal/numfmt"10+ "github.com/FelineStateMachine/012/internal/value"11+)12+13+// Conditional formats follow Sheets' Format > Conditional formatting: a14+// rule applies to ranges of the sheet and either formats the cells that15+// meet a test in a single color (a text color, a fill, text styles) or16+// colors every number along a color scale from its lowest to its17+// highest. Rules are tried in order and the first that applies to a cell18+// formats it; see looks.go for how they're evaluated.19+20+// CondFormat is a conditional format rule.21+type CondFormat struct {22+ Ranges []Rect23+ // Op is the test of a single-color rule and Args its values, as24+ // typed: "100", "=B2" (a formula, relative to the first range's25+ // top-left cell as a copied formula would be), a date, or for26+ // RuleFormula the formula, e.g. "=$C2>100".27+ Op RuleOp28+ Args [2]string29+ Style RuleStyle30+ // Scale, when set, makes the rule a color scale of 2 or 3 points,31+ // lowest first, and Op, Args and Style mean nothing.32+ Scale []ScalePoint33+}34+35+// ScalePoint is a point of a color scale: where it is among the values,36+// and its color.37+type ScalePoint struct {38+ Kind PointKind39+ Value string // the number, percent or percentile; unused by min and max40+ Color Color41+}42+43+// PointKind is how a color scale point is placed.44+type PointKind uint845+46+const (47+ PointMin PointKind = iota // the lowest value48+ PointMax // the highest value49+ PointNumber // a number50+ PointPercent // a percentage of the way from lowest to highest51+ PointPercentile // a percentile of the values52+ numPointKinds53+)54+55+var pointNames = [numPointKinds]string{"min", "max", "num", "percent", "percentile"}56+var pointTitles = [numPointKinds]string{"Min value", "Max value", "Number", "Percent", "Percentile"}57+58+func (k PointKind) String() string {59+ if k >= numPointKinds {60+ return ""61+ }62+ return pointNames[k]63+}64+65+// Title names the kind for people, e.g. "Percentile".66+func (k PointKind) Title() string {67+ if k >= numPointKinds {68+ return ""69+ }70+ return pointTitles[k]71+}72+73+// TakesValue reports whether the point needs a value.74+func (k PointKind) TakesValue() bool { return k >= PointNumber }75+76+// ParsePointKind is the inverse of PointKind.String.77+func ParsePointKind(s string) (PointKind, bool) {78+ i := slices.Index(pointNames[:], s)79+ return PointKind(max(i, 0)), i >= 080+}81+82+// PointKinds lists the kinds in the order the rules editor offers them.83+func PointKinds() []PointKind {84+ return []PointKind{PointMin, PointMax, PointNumber, PointPercent, PointPercentile}85+}86+87+// IsScale reports whether the rule is a color scale.88+func (f CondFormat) IsScale() bool { return len(f.Scale) > 0 }89+90+// Summary describes the rule in a few words, e.g. "Greater than 100".91+func (f CondFormat) Summary() string {92+ if f.IsScale() {93+ return "Color scale"94+ }95+ switch f.Op.Args() {96+ case 1:97+ return f.Op.Title() + " " + f.Args[0]98+ case 2:99+ return f.Op.Title() + " " + f.Args[0] + " and " + f.Args[1]100+ }101+ return f.Op.Title()102+}103+104+// clone copies the rule's slices, so a stored rule is never shared.105+func (f CondFormat) clone() CondFormat {106+ f.Ranges, f.Scale = slices.Clone(f.Ranges), slices.Clone(f.Scale)107+ return f108+}109+110+// Check reports why a rule can't be used, in words for the rules editor.111+func (f CondFormat) Check() error {112+ if len(f.Ranges) == 0 {113+ return errors.New("Enter the range the rule applies to, e.g. A2:A100")114+ }115+ if f.IsScale() {116+ return checkScale(f.Scale)117+ }118+ if !slices.Contains(CondFormatOps(), f.Op) {119+ return errors.New("Choose a condition")120+ }121+ for i := range f.Op.Args() {122+ if err := checkArg(f.Op, strings.TrimSpace(f.Args[i])); err != nil {123+ return err124+ }125+ }126+ if f.Style.IsZero() {127+ return errors.New("Choose a text color, a fill or a text style")128+ }129+ return nil130+}131+132+// checkArg checks one value of a test.133+func checkArg(op RuleOp, arg string) error {134+ switch {135+ case arg == "":136+ return errors.New("Enter a value")137+ case op == RuleFormula && !IsFormulaEntry(arg):138+ return errors.New("A custom formula starts with =, e.g. =$C2>100")139+ case IsFormulaEntry(arg):140+ if _, err := Parse(arg); err != nil {141+ return fmt.Errorf("Formula: %w", err)142+ }143+ case op == RuleDateIs || op == RuleDateBefore || op == RuleDateAfter:144+ if _, ok := dateArg(arg); !ok {145+ return errors.New("Enter a date, e.g. 2026-09-30, or today, tomorrow or yesterday")146+ }147+ case op == RuleBetween || op == RuleNotBetween:148+ if _, _, ok := ParseValue(arg); !ok {149+ return errors.New("Enter a number")150+ }151+ }152+ return nil153+}154+155+// checkScale checks a color scale's points: 2 or 3, each with a color156+// and a value where it takes one.157+func checkScale(ps []ScalePoint) error {158+ if len(ps) < 2 || len(ps) > 3 {159+ return errors.New("A color scale has a minimum, a maximum and at most one midpoint")160+ }161+ for _, p := range ps {162+ if p.Kind >= numPointKinds {163+ return errors.New("Unknown kind of point")164+ }165+ if p.Color == ColorNone {166+ return errors.New("Choose a color for every point")167+ }168+ if !p.Kind.TakesValue() {169+ continue170+ }171+ n, _, ok := ParseValue(strings.TrimSpace(p.Value))172+ switch {173+ case !ok:174+ return errors.New("Enter a number for the " + strings.ToLower(p.Kind.Title()))175+ case p.Kind != PointNumber && (n < 0 || n > 100):176+ return errors.New(p.Kind.Title() + " goes from 0 to 100")177+ }178+ }179+ return nil180+}181+182+// dateArg reads a date condition's value: a date as typed, a number of183+// days, or today, tomorrow or yesterday, as Sheets offers them.184+func dateArg(arg string) (float64, bool) {185+ today := float64(int64(numfmt.SerialOf(value.Now())))186+ switch strings.ToLower(strings.TrimSpace(arg)) {187+ case "today":188+ return today, true189+ case "tomorrow":190+ return today + 1, true191+ case "yesterday":192+ return today - 1, true193+ }194+ n, _, ok := ParseValue(strings.TrimSpace(arg))195+ return float64(int64(n)), ok196+}197+198+// CondFormats returns the sheet's conditional format rules, in the order199+// they're tried.200+func (s *Sheet) CondFormats() []CondFormat {201+ out := make([]CondFormat, len(s.rules.formats))202+ for i, f := range s.rules.formats {203+ out[i] = f.clone()204+ }205+ return out206+}207+208+// AddCondFormat adds a rule after the others, as one undo step.209+func (s *Sheet) AddCondFormat(f CondFormat) error {210+ if err := f.Check(); err != nil {211+ return err212+ }213+ r := s.rules214+ r.formats = append(slices.Clip(r.formats), f.clone())215+ s.setRules("add conditional format "+rangesText(f.Ranges), f.Ranges[0], r)216+ return nil217+}218+219+// SetCondFormat replaces rule i, as one undo step.220+func (s *Sheet) SetCondFormat(i int, f CondFormat) error {221+ if i < 0 || i >= len(s.rules.formats) {222+ return errors.New("That rule was removed")223+ }224+ if err := f.Check(); err != nil {225+ return err226+ }227+ r := s.rules228+ r.formats = slices.Clone(r.formats)229+ r.formats[i] = f.clone()230+ s.setRules("edit conditional format "+rangesText(f.Ranges), f.Ranges[0], r)231+ return nil232+}233+234+// DeleteCondFormat removes rule i.235+func (s *Sheet) DeleteCondFormat(i int) {236+ if i < 0 || i >= len(s.rules.formats) {237+ return238+ }239+ r := s.rules240+ r.formats = slices.Delete(slices.Clone(r.formats), i, i+1)241+ f := s.rules.formats[i]242+ s.setRules("remove conditional format "+rangesText(f.Ranges), f.Ranges[0], r)243+}244+245+// MoveCondFormat moves rule i to position to, changing which rule wins246+// where several apply.247+func (s *Sheet) MoveCondFormat(i, to int) {248+ n := len(s.rules.formats)249+ if i < 0 || i >= n || to < 0 || to >= n || i == to {250+ return251+ }252+ r := s.rules253+ r.formats = slices.Clone(r.formats)254+ f := r.formats[i]255+ r.formats = slices.Insert(slices.Delete(r.formats, i, i+1), to, f)256+ s.setRules("reorder conditional formats", f.Ranges[0], r)257+}258+259+// ClearCondFormats takes the cells of cr out of every rule, removing260+// rules left with no cells, as Sheets' "Clear formatting rules" for a261+// selection.262+func (s *Sheet) ClearCondFormats(cr Rect) {263+ r := s.rules264+ r.formats = nil265+ for _, f := range s.rules.formats {266+ f.Ranges = subtractAll(f.Ranges, cr)267+ if len(f.Ranges) > 0 {268+ r.formats = append(r.formats, f)269+ }270+ }271+ s.setRules("clear conditional formats from "+cr.String(), cr, r)272+}273+274+// LoadCondFormats adds rules as a loader does: without recording undo.275+// Rules that don't check are left out and counted.276+func (s *Sheet) LoadCondFormats(fs []CondFormat) (skipped int) {277+ for _, f := range fs {278+ if f.Check() != nil {279+ skipped++280+ continue281+ }282+ s.rules.formats = append(s.rules.formats, f.clone())283+ }284+ s.looks.reset()285+ return skipped286+}287+288+// subtractAll removes cut from every range of rs.289+func subtractAll(rs []Rect, cut Rect) []Rect {290+ var out []Rect291+ for _, r := range rs {292+ out = append(out, subtract(r, cut)...)293+ }294+ return out295+}296+297+// subtract is r without the cells of cut: r itself, nothing, or up to298+// four ranges around cut.299+func subtract(r, cut Rect) []Rect {300+ lo := Addr{Col: max(r.From.Col, cut.From.Col), Row: max(r.From.Row, cut.From.Row)}301+ hi := Addr{Col: min(r.To.Col, cut.To.Col), Row: min(r.To.Row, cut.To.Row)}302+ if lo.Col > hi.Col || lo.Row > hi.Row {303+ return []Rect{r}304+ }305+ var out []Rect306+ if r.From.Row < lo.Row { // above307+ out = append(out, Rect{From: r.From, To: Addr{Col: r.To.Col, Row: lo.Row - 1}})308+ }309+ if hi.Row < r.To.Row { // below310+ out = append(out, Rect{From: Addr{Col: r.From.Col, Row: hi.Row + 1}, To: r.To})311+ }312+ if r.From.Col < lo.Col { // left, beside cut313+ out = append(out, Rect{From: Addr{Col: r.From.Col, Row: lo.Row}, To: Addr{Col: lo.Col - 1, Row: hi.Row}})314+ }315+ if hi.Col < r.To.Col { // right, beside cut316+ out = append(out, Rect{From: Addr{Col: hi.Col + 1, Row: lo.Row}, To: Addr{Col: r.To.Col, Row: hi.Row}})317+ }318+ return out319+}320+321+// scaleValue is where a point sits among values, sorted ascending.322+func (p ScalePoint) at(vals []float64) float64 {323+ lo, hi := vals[0], vals[len(vals)-1]324+ n, _, _ := ParseValue(strings.TrimSpace(p.Value))325+ switch p.Kind {326+ case PointMin:327+ return lo328+ case PointMax:329+ return hi330+ case PointPercent:331+ return lo + (hi-lo)*n/100332+ case PointPercentile:333+ return percentile(vals, n/100)334+ }335+ return n336+}337+338+// percentile interpolates between sorted values, as PERCENTILE.INC.339+func percentile(vals []float64, p float64) float64 {340+ p = min(max(p, 0), 1)341+ x := p * float64(len(vals)-1)342+ i := int(x)343+ if i >= len(vals)-1 {344+ return vals[len(vals)-1]345+ }346+ return vals[i] + (x-float64(i))*(vals[i+1]-vals[i])347+}348+349+// pointText writes a point as the rules editor shows it, e.g. "Min" or350+// "Percentile 50".351+func (p ScalePoint) String() string {352+ if p.Kind.TakesValue() {353+ return p.Kind.Title() + " " + p.Value354+ }355+ return p.Kind.Title()356+}
M internal/sheet/file.go +8 -1
M internal/sheet/history.go +22 -3
@@ -48,6 +48,9 @@ type step struct {
4848 charts map[*Sheet][]Chart4949 // pivots holds each touched sheet's pivot definition before the step.5050 pivots map[*Sheet]*Pivot51+ // rules holds each touched sheet's conditional formats and data52+ // validation before the step.53+ rules map[*Sheet]rulesState5154 // sheets is the sheet list before the step, when it changed.5255 sheets *sheetList5356 // decimal is the arithmetic setting before the step, when it changed.
@@ -64,7 +67,7 @@ type colKey struct {
6467 6568 func newStep(label string, s *Sheet, focus Rect) *step {6669 return &step{label: label, sheet: s, focus: focus, cells: map[loc]*Cell{}, widths: map[colKey]int{}, lines: map[lineKey]lineFmt{},67- names: map[string]*Name{}, views: map[*Sheet]*viewState{}, charts: map[*Sheet][]Chart{}, pivots: map[*Sheet]*Pivot{}}70+ names: map[string]*Name{}, views: map[*Sheet]*viewState{}, charts: map[*Sheet][]Chart{}, pivots: map[*Sheet]*Pivot{}, rules: map[*Sheet]rulesState{}}6871 }6972 7073 func (st *step) empty() bool {
@@ -73,7 +76,7 @@ func (st *step) empty() bool {
7376 7477 // widthOnly reports whether the step changed nothing but column widths.7578 func (st *step) widthOnly() bool {76- return len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && st.sheets == nil && st.decimal == nil &&79+ return len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && st.sheets == nil && st.decimal == nil &&7780 st.macros == nil7881 }7982
@@ -240,6 +243,7 @@ func (w *Workbook) push(st *step) {
240243 // dropUnchanged removes from st what ended the step as it began.241244 func (w *Workbook) dropUnchanged(st *step) {242245 st.dropUnchangedLines()246+ st.dropUnchangedRules()243247 for l, c := range st.cells {244248 if c == nil && l.s.cells.get(l.a) == nil {245249 delete(st.cells, l)
@@ -292,6 +296,16 @@ func (st *step) dropUnchangedLines() {
292296 }293297 }294298 299+// dropUnchangedRules removes the sheets' rules that ended the step as300+// they began.301+func (st *step) dropUnchangedRules() {302+ for s, r := range st.rules {303+ if r.equal(s.rules) {304+ delete(st.rules, s)305+ }306+ }307+}308+295309 // sameName reports whether two named ranges (nil for undefined) are the296310 // same.297311 func sameName(a, b *Name) bool {
@@ -432,6 +446,11 @@ func (w *Workbook) swap(undo bool) (Change, bool) {
432446 inv.pivots[s] = s.pivot.def433447 s.pivot.def, s.pivot.stale = p, true434448 }449+ for s, r := range st.rules {450+ inv.rules[s] = s.rules451+ s.rules = r452+ s.looks.reset()453+ }435454 if st.decimal != nil {436455 cur := w.decimal437456 inv.decimal, w.decimal = &cur, *st.decimal
@@ -459,7 +478,7 @@ func (w *Workbook) swap(undo bool) (Change, bool) {
459478 w.pushUndo(inv)460479 }461480 h.mergeWidths = false462- macrosOnly := st.macros != nil && len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && st.sheets == nil481+ macrosOnly := st.macros != nil && len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && st.sheets == nil463482 return Change{Label: st.label, Focus: st.focus, Sheet: st.sheet, Tabs: st.sheets != nil, Macros: macrosOnly}, true464483 }465484
M internal/sheet/historysize.go +4 -0
A internal/sheet/looks.go +359 -0
@@ -0,0 +1,359 @@
1+package sheet2+3+import (4+ "slices"5+ "strings"6+ "unicode/utf8"7+8+ "github.com/FelineStateMachine/012/internal/formula"9+ "github.com/FelineStateMachine/012/internal/functions"10+)11+12+// Look is how a sheet's rules draw a cell: a conditional format's style13+// or place on a color scale, and what its validation rule shows (a14+// checkbox, a dropdown's marker, an invalid entry's mark).15+type Look struct {16+ // Styled is set when a single-color rule applies, with its Style.17+ Styled bool18+ Style RuleStyle19+ // Scaled is set when a color scale colors the cell, Pos of the way20+ // (0 to 1) from From to To.21+ Scaled bool22+ From, To Color23+ Pos float6424+25+ Checkbox bool // a checkbox, checked when Checked26+ Checked bool27+ Dropdown bool // offers a list to pick from28+ Invalid bool // holds what its validation rule doesn't accept29+}30+31+// Looks are worked out per cell as the screen asks for them, and kept32+// until the next recalculation or change of rules: work follows what's33+// drawn, and a color scale reads its range's values once per34+// recalculation, not per frame. The cache is dropped whole when it35+// grows past maxLooks, as scrolling through a long range would make it.36+type looksCache struct {37+ wb *Workbook38+ gen uint64 // the workbook's recalculation when filled39+ valid bool40+ cells map[Addr]Look41+ tests map[int]*ruleTest // compiled conditional formats by index42+ scales map[int]*scaleStats // color scales' points by index43+ lists map[int]map[string]bool // dropdown sources' values by validation index44+ rd *reader45+}46+47+const maxLooks = 1 << 1648+49+func (c *looksCache) reset() { c.valid = false }50+51+// fresh empties the cache if it was filled before the last52+// recalculation or rule change.53+func (c *looksCache) fresh(s *Sheet) {54+ if c.valid && c.wb == s.wb && c.gen == s.wb.gen {55+ return56+ }57+ *c = looksCache{wb: s.wb, gen: s.wb.gen, valid: true, cells: map[Addr]Look{}, tests: map[int]*ruleTest{},58+ scales: map[int]*scaleStats{}, lists: map[int]map[string]bool{}}59+}60+61+// HasRules reports whether the sheet has conditional formats or data62+// validation, so drawing can skip asking for looks when it has none.63+func (s *Sheet) HasRules() bool { return !s.rules.empty() }64+65+// Look returns how the sheet's rules draw the cell at a.66+func (s *Sheet) Look(a Addr) Look {67+ if s.rules.empty() {68+ return Look{}69+ }70+ c := &s.looks71+ c.fresh(s)72+ if l, ok := c.cells[a]; ok {73+ return l74+ }75+ l := c.look(s, a)76+ if len(c.cells) >= maxLooks {77+ clear(c.cells)78+ }79+ c.cells[a] = l80+ return l81+}82+83+func (c *looksCache) look(s *Sheet, a Addr) Look {84+ var l Look85+ v := s.Value(a)86+ for i, f := range s.rules.formats {87+ if !inRanges(f.Ranges, a) {88+ continue89+ }90+ if f.IsScale() {91+ if pos, from, to, ok := c.scale(s, i).place(v); ok {92+ l.Scaled, l.Pos, l.From, l.To = true, pos, from, to93+ break94+ }95+ continue96+ }97+ if c.test(s, i).match(c, s, a, v) {98+ l.Styled, l.Style = true, f.Style99+ break100+ }101+ }102+ if i := s.validationIndex(a); i >= 0 {103+ k := s.rules.validations[i].Kind104+ l.Checkbox, l.Dropdown = k == ValidCheckbox, k.Dropdown()105+ l.Checked = l.Checkbox && v.Kind == Bool && v.Num != 0106+ l.Invalid = !s.cells.get(a).Blank() && !c.check(s, i, a, v, s.DisplayFormat(a))107+ }108+ return l109+}110+111+// reader reads current values for rules' formulas.112+func (c *looksCache) reader(s *Sheet) *reader {113+ if c.rd == nil {114+ c.rd = s.wb.values(s)115+ }116+ return c.rd117+}118+119+// eval evaluates n as if in the cell at a.120+func (c *looksCache) eval(s *Sheet, n Node, a Addr) Value {121+ return functions.EvalAt(n, c.reader(s).lib, a)122+}123+124+// evalFrom evaluates a rule's formula n, written for the cell from, in125+// the cell at a: its relative references move as a copy's would.126+func (c *looksCache) evalFrom(s *Sheet, n Node, from, a Addr) Value {127+ if a != from {128+ n, _ = formula.Rewrite(n, formula.Shift(a.Col-from.Col, a.Row-from.Row))129+ }130+ return c.eval(s, n, a)131+}132+133+func truthy(v Value) bool { return (v.Kind == Bool || v.Kind == Number) && v.Num != 0 }134+135+// ruleTest is a single-color rule ready to run: its values parsed, or136+// kept as formulas to evaluate in each cell.137+type ruleTest struct {138+ op RuleOp139+ from Addr140+ args [2]Node // formulas among the values; nil for constants141+ nums [2]float64142+ isNum [2]bool143+ shared func(Value, string) bool // the filter's test, for the ops filters share with constant values144+}145+146+// filterOps maps the tests conditional formats share with filters.147+var filterOps = map[RuleOp]CondOp{RuleEmpty: CondEmpty, RuleNotEmpty: CondNotEmpty,148+ RuleContains: CondContains, RuleNotContains: CondNotContains, RuleStartsWith: CondStartsWith,149+ RuleEndsWith: CondEndsWith, RuleExactly: CondExactly, RuleGreater: CondGreater,150+ RuleGreaterEq: CondGreaterEq, RuleLess: CondLess, RuleLessEq: CondLessEq, RuleEqual: CondEqual,151+ RuleNotEqual: CondNotEqual}152+153+func (c *looksCache) test(s *Sheet, i int) *ruleTest {154+ if t, ok := c.tests[i]; ok {155+ return t156+ }157+ f := s.rules.formats[i]158+ t := &ruleTest{op: f.Op, from: anchor(f.Ranges)}159+ t.parse(f.Op, f.Args)160+ if cop, ok := filterOps[f.Op]; ok && t.args[0] == nil {161+ t.shared = Condition{Op: cop, Arg: f.Args[0]}.test()162+ }163+ c.tests[i] = t164+ return t165+}166+167+// parse reads the values of op: formulas are parsed, dates and numbers168+// read.169+func (t *ruleTest) parse(op RuleOp, args [2]string) {170+ for k := range op.Args() {171+ arg := strings.TrimSpace(args[k])172+ switch {173+ case IsFormulaEntry(arg):174+ t.args[k], _ = Parse(arg)175+ case op == RuleDateIs || op == RuleDateBefore || op == RuleDateAfter:176+ t.nums[k], t.isNum[k] = dateArg(arg)177+ default:178+ n, _, ok := ParseValue(arg)179+ t.nums[k], t.isNum[k] = n, ok180+ }181+ }182+}183+184+// arg is value k of the test in the cell at a.185+func (t *ruleTest) arg(c *looksCache, s *Sheet, k int, a Addr) Value {186+ if t.args[k] != nil {187+ return c.evalFrom(s, t.args[k], t.from, a)188+ }189+ if t.isNum[k] {190+ return Value{Kind: Number, Num: t.nums[k]}191+ }192+ return Value{}193+}194+195+func (t *ruleTest) match(c *looksCache, s *Sheet, a Addr, v Value) bool {196+ switch {197+ case t.shared != nil:198+ return t.shared(v, s.ShownText(a))199+ case t.op == RuleFormula:200+ return t.args[0] != nil && truthy(c.evalFrom(s, t.args[0], t.from, a))201+ }202+ if cop, ok := filterOps[t.op]; ok { // a shared test with a formula value203+ arg := t.arg(c, s, 0, a)204+ return Condition{Op: cop, Arg: FormatText(arg, Format{})}.test()(v, s.ShownText(a))205+ }206+ if v.Kind != Number {207+ return false208+ }209+ x, lo := v.Num, t.arg(c, s, 0, a)210+ if lo.Kind != Number {211+ return false212+ }213+ switch t.op {214+ case RuleDateIs:215+ return float64(int64(x)) == float64(int64(lo.Num))216+ case RuleDateBefore:217+ return float64(int64(x)) < float64(int64(lo.Num))218+ case RuleDateAfter:219+ return float64(int64(x)) > float64(int64(lo.Num))220+ }221+ hi := t.arg(c, s, 1, a)222+ return hi.Kind == Number && compareNum(t.op, x, lo.Num, hi.Num)223+}224+225+// compareNum compares x with one bound, or two for between.226+func compareNum(op RuleOp, x, lo, hi float64) bool {227+ switch op {228+ case RuleBetween:229+ return x >= min(lo, hi) && x <= max(lo, hi)230+ case RuleNotBetween:231+ return x < min(lo, hi) || x > max(lo, hi)232+ case RuleEqual:233+ return x == lo234+ case RuleNotEqual:235+ return x != lo236+ case RuleGreater:237+ return x > lo238+ case RuleGreaterEq:239+ return x >= lo240+ case RuleLess:241+ return x < lo242+ case RuleLessEq:243+ return x <= lo244+ }245+ return true246+}247+248+// scaleStats is a color scale's points among the values of its ranges.249+type scaleStats struct {250+ ok bool251+ at []float64252+ colors []Color253+}254+255+func (c *looksCache) scale(s *Sheet, i int) *scaleStats {256+ if st, ok := c.scales[i]; ok {257+ return st258+ }259+ f := s.rules.formats[i]260+ var vals []float64261+ for _, r := range f.Ranges {262+ for _, cell := range s.cells.inRange(r) {263+ if cell.Value.Kind == Number {264+ vals = append(vals, cell.Value.Num)265+ }266+ }267+ }268+ st := &scaleStats{}269+ if len(vals) > 0 {270+ slices.Sort(vals)271+ st.ok = true272+ for _, p := range f.Scale {273+ st.at = append(st.at, p.at(vals))274+ st.colors = append(st.colors, p.Color)275+ }276+ }277+ c.scales[i] = st278+ return st279+}280+281+// place is where v falls on the scale: the fraction of the way between282+// the colors of the points around it.283+func (st *scaleStats) place(v Value) (pos float64, from, to Color, ok bool) {284+ if !st.ok || v.Kind != Number {285+ return 0, 0, 0, false286+ }287+ k := 0288+ if len(st.at) == 3 && v.Num > st.at[1] {289+ k = 1290+ }291+ lo, hi := st.at[k], st.at[k+1]292+ switch {293+ case hi <= lo:294+ pos = 0295+ if v.Num >= hi {296+ pos = 1297+ }298+ default:299+ pos = min(max((v.Num-lo)/(hi-lo), 0), 1)300+ }301+ return pos, st.colors[k], st.colors[k+1], true302+}303+304+// check reports whether value v, shown in format f, meets validation305+// rule i in the cell at a. Blanks always do.306+func (c *looksCache) check(s *Sheet, i int, a Addr, v Value, f Format) bool {307+ c.fresh(s)308+ r := s.rules.validations[i]309+ switch r.Kind {310+ case ValidList:311+ shown := strings.TrimSpace(FormatText(v, f))312+ return v.Kind == Empty || slices.ContainsFunc(r.Items, func(it string) bool { return strings.EqualFold(strings.TrimSpace(it), shown) })313+ case ValidRange:314+ return v.Kind == Empty || c.list(s, i)[strings.ToLower(strings.TrimSpace(FormatText(v, f)))]315+ case ValidCheckbox:316+ return v.Kind == Bool || v.Kind == Empty317+ case ValidFormula:318+ n, err := Parse(strings.TrimSpace(r.Args[0]))319+ return err == nil && truthy(c.evalFrom(s, n, anchor(r.Ranges), a))320+ case ValidDate:321+ if v.Kind != Number || f.Kind < FmtDate || f.Kind > FmtDateTime || f.Kind == FmtTime {322+ return false323+ }324+ if r.Op == RuleNone {325+ return true326+ }327+ case ValidNumber:328+ if v.Kind != Number {329+ return false330+ }331+ case ValidLength:332+ v = Value{Kind: Number, Num: float64(utf8.RuneCountInString(FormatText(v, f)))}333+ }334+ return c.bounds(s, r, a, v.Num)335+}336+337+// bounds compares x with a rule's values, evaluated in the cell at a.338+func (c *looksCache) bounds(s *Sheet, r Validation, a Addr, x float64) bool {339+ t := ruleTest{op: r.Op, from: anchor(r.Ranges)}340+ t.parse(r.Op, r.Args)341+ lo, hi := t.arg(c, s, 0, a), t.arg(c, s, 1, a)342+ if lo.Kind != Number || r.Op.Args() == 2 && hi.Kind != Number {343+ return false344+ }345+ return compareNum(r.Op, x, lo.Num, hi.Num)346+}347+348+// list is the values of a dropdown's source, by lower case.349+func (c *looksCache) list(s *Sheet, i int) map[string]bool {350+ if l, ok := c.lists[i]; ok {351+ return l352+ }353+ l := map[string]bool{}354+ for _, it := range s.sourceItems(s.rules.validations[i]) {355+ l[strings.ToLower(it)] = true356+ }357+ c.lists[i] = l358+ return l359+}
M internal/sheet/ops.go +1 -0
M internal/sheet/recalc.go +2 -0
A internal/sheet/rulefile.go +226 -0
@@ -0,0 +1,226 @@
1+package sheet2+3+import (4+ "bytes"5+ "encoding/json"6+ "fmt"7+ "strings"8+)9+10+// Rules in the file are two optional fields after a sheet's cells and11+// charts, one rule per line, which older builds ignore (losing the12+// rules, as they lose charts), so they need no version:13+//14+// "conditionalFormats": [15+// {"ranges": "B2:B20", "condition": "gt", "values": ["100"], "fill": "green", "bold": true},16+// {"ranges": "C2:C20", "scale": [{"type": "min", "color": "red"}, {"type": "max", "color": "green"}]}17+// ],18+// "validations": [19+// {"ranges": "D2:D20", "criteria": "list", "items": ["Yes", "No"], "reject": true}20+// ]21+//22+// The same lines are the answers of the commands that add rules, so a23+// recorded macro adds a rule as the file would.24+25+type fileCondFormat struct {26+ Ranges string `json:"ranges"`27+ Condition string `json:"condition,omitempty"`28+ Values []string `json:"values,omitempty"`29+ Text string `json:"text,omitempty"`30+ Fill string `json:"fill,omitempty"`31+ Bold bool `json:"bold,omitempty"`32+ Italic bool `json:"italic,omitempty"`33+ Underline bool `json:"underline,omitempty"`34+ Strike bool `json:"strikethrough,omitempty"`35+ Scale []fileScaled `json:"scale,omitempty"`36+}37+38+type fileScaled struct {39+ Type string `json:"type"`40+ Value string `json:"value,omitempty"`41+ Color string `json:"color"`42+}43+44+type fileValidation struct {45+ Ranges string `json:"ranges"`46+ Criteria string `json:"criteria"`47+ Condition string `json:"condition,omitempty"`48+ Values []string `json:"values,omitempty"`49+ Items []string `json:"items,omitempty"`50+ Source string `json:"source,omitempty"`51+ Reject bool `json:"reject,omitempty"`52+ Help string `json:"help,omitempty"`53+}54+55+// values lists the first n of args, as files keep them.56+func values(args [2]string, n int) []string {57+ if n == 0 {58+ return nil59+ }60+ return args[:n:n]61+}62+63+// JSON writes the rule as a line of the file, e.g.64+// {"ranges":"B2:B20","condition":"gt","values":["100"],"fill":"green"}.65+func (f CondFormat) JSON() string {66+ fc := fileCondFormat{Ranges: rangesText(f.Ranges)}67+ if f.IsScale() {68+ for _, p := range f.Scale {69+ sp := fileScaled{Type: p.Kind.String(), Color: p.Color.String()}70+ if p.Kind.TakesValue() {71+ sp.Value = p.Value72+ }73+ fc.Scale = append(fc.Scale, sp)74+ }75+ } else {76+ st := f.Style77+ fc.Condition, fc.Values = f.Op.String(), values(f.Args, f.Op.Args())78+ fc.Text, fc.Fill = st.Text.String(), st.Fill.String()79+ fc.Bold, fc.Italic, fc.Underline, fc.Strike = st.Bold, st.Italic, st.Underline, st.Strikethrough80+ }81+ raw, _ := json.Marshal(fc)82+ return string(raw)83+}84+85+// ParseCondFormat reads a rule written by JSON, checking it.86+func ParseCondFormat(line string) (CondFormat, error) {87+ var fc fileCondFormat88+ if err := json.Unmarshal([]byte(line), &fc); err != nil {89+ return CondFormat{}, err90+ }91+ f, err := fc.rule()92+ if err != nil {93+ return CondFormat{}, err94+ }95+ return f, f.Check()96+}97+98+func (fc fileCondFormat) rule() (CondFormat, error) {99+ rs, ok := ParseRanges(fc.Ranges)100+ if !ok {101+ return CondFormat{}, fmt.Errorf("invalid ranges %q", fc.Ranges)102+ }103+ f := CondFormat{Ranges: rs}104+ for _, sp := range fc.Scale {105+ k, ok := ParsePointKind(sp.Type)106+ c, cok := ParseColor(sp.Color)107+ if !ok || !cok {108+ return CondFormat{}, fmt.Errorf("invalid color scale point %q %q", sp.Type, sp.Color)109+ }110+ f.Scale = append(f.Scale, ScalePoint{Kind: k, Value: sp.Value, Color: c})111+ }112+ if f.IsScale() {113+ return f, nil114+ }115+ op, ok := ParseRuleOp(fc.Condition)116+ text, tok := ParseColor(fc.Text)117+ fill, fok := ParseColor(fc.Fill)118+ if !ok || !tok || !fok || len(fc.Values) > 2 {119+ return CondFormat{}, fmt.Errorf("invalid rule %q", fc.Condition)120+ }121+ f.Op = op122+ copy(f.Args[:], fc.Values)123+ f.Style = RuleStyle{Text: text, Fill: fill, Bold: fc.Bold, Italic: fc.Italic, Underline: fc.Underline, Strikethrough: fc.Strike}124+ return f, nil125+}126+127+// JSON writes the rule as a line of the file, e.g.128+// {"ranges":"D2:D20","criteria":"list","items":["Yes","No"]}.129+func (v Validation) JSON() string {130+ fv := fileValidation{Ranges: rangesText(v.Ranges), Criteria: v.Kind.String(), Reject: v.Reject, Help: v.Help}131+ switch {132+ case v.Kind == ValidList:133+ fv.Items = v.Items134+ case v.Kind == ValidRange:135+ fv.Source = v.Source136+ case v.Kind == ValidFormula:137+ fv.Values = values(v.Args, 1)138+ case v.Kind.Compares():139+ fv.Condition, fv.Values = v.Op.String(), values(v.Args, v.Op.Args())140+ }141+ raw, _ := json.Marshal(fv)142+ return string(raw)143+}144+145+// ParseValidation reads a rule written by JSON, checking it.146+func ParseValidation(line string) (Validation, error) {147+ var fv fileValidation148+ if err := json.Unmarshal([]byte(line), &fv); err != nil {149+ return Validation{}, err150+ }151+ v, err := fv.rule()152+ if err != nil {153+ return Validation{}, err154+ }155+ return v, v.Check()156+}157+158+func (fv fileValidation) rule() (Validation, error) {159+ rs, ok := ParseRanges(fv.Ranges)160+ if !ok {161+ return Validation{}, fmt.Errorf("invalid ranges %q", fv.Ranges)162+ }163+ k, ok := ParseValidKind(fv.Criteria)164+ op, opOK := ParseRuleOp(fv.Condition)165+ if !ok || !opOK || len(fv.Values) > 2 {166+ return Validation{}, fmt.Errorf("invalid validation %q %q", fv.Criteria, fv.Condition)167+ }168+ v := Validation{Ranges: rs, Kind: k, Op: op, Items: fv.Items, Source: fv.Source, Reject: fv.Reject, Help: fv.Help}169+ copy(v.Args[:], fv.Values)170+ return v, nil171+}172+173+// writeRules writes the sheet's rules, one per line, after its other174+// fields.175+func (s *Sheet) writeRules(b *bytes.Buffer, indent string) {176+ list := func(key string, lines []string) {177+ if len(lines) == 0 {178+ return179+ }180+ fmt.Fprintf(b, ",\n%s%q: [", indent, key)181+ for i, l := range lines {182+ if i > 0 {183+ b.WriteByte(',')184+ }185+ fmt.Fprintf(b, "\n%s %s", indent, l)186+ }187+ b.WriteString("\n" + indent + "]")188+ }189+ var fs, vs []string190+ for _, f := range s.rules.formats {191+ fs = append(fs, f.JSON())192+ }193+ for _, v := range s.rules.validations {194+ vs = append(vs, v.JSON())195+ }196+ list("conditionalFormats", fs)197+ list("validations", vs)198+}199+200+// readRules restores the sheet's rules from a file. A rule that doesn't201+// make sense (a later build's criteria, say) is an error, as a bad cell202+// is.203+func (s *Sheet) readRules(fcs []fileCondFormat, fvs []fileValidation) error {204+ for i, fc := range fcs {205+ f, err := fc.rule()206+ if err == nil {207+ err = f.Check()208+ }209+ if err != nil {210+ return fmt.Errorf("conditional format %d: %s", i+1, strings.TrimSuffix(err.Error(), "."))211+ }212+ s.rules.formats = append(s.rules.formats, f)213+ }214+ for i, fv := range fvs {215+ v, err := fv.rule()216+ if err == nil {217+ err = v.Check()218+ }219+ if err != nil {220+ return fmt.Errorf("data validation %d: %s", i+1, strings.TrimSuffix(err.Error(), "."))221+ }222+ s.rules.validations = append(s.rules.validations, v)223+ }224+ s.looks.reset()225+ return nil226+}
A internal/sheet/rules.go +367 -0
@@ -0,0 +1,367 @@
1+package sheet2+3+import (4+ "reflect"5+ "slices"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/formula"9+)10+11+// Rules are what a sheet says about ranges of its cells beyond their own12+// formatting: conditional formats (condfmt.go), which change how cells13+// look by their values, and data validation (validation.go), which says14+// what may be entered. Both belong to the sheet, as in Sheets: they're15+// saved with it, follow inserted and deleted rows and columns, and every16+// change to them is an undo step. They never write cells.17+18+// Color is one of the named colors rules draw with. Each is an ANSI19+// color slot, so the terminal's palette or the color scheme applies:20+// the UI maps them to theme roles, never to fixed RGB.21+type Color uint822+23+const (24+ ColorNone Color = iota25+ ColorRed26+ ColorYellow27+ ColorGreen28+ ColorCyan29+ ColorBlue30+ ColorMagenta31+ numColors32+)33+34+var colorNames = [numColors]string{"", "red", "yellow", "green", "cyan", "blue", "magenta"}35+36+// NumColors is how many colors there are, ColorNone included, for37+// tables indexed by Color.38+const NumColors = int(numColors)39+40+// Colors lists the named colors, ColorNone first, in the order the rules41+// editor offers them.42+func Colors() []Color {43+ out := make([]Color, numColors)44+ for i := range out {45+ out[i] = Color(i)46+ }47+ return out48+}49+50+// String names the color as files store it; "" for none.51+func (c Color) String() string {52+ if c >= numColors {53+ return ""54+ }55+ return colorNames[c]56+}57+58+// Title names the color for people, e.g. "Green", or "None".59+func (c Color) Title() string {60+ if s := c.String(); s != "" {61+ return strings.ToUpper(s[:1]) + s[1:]62+ }63+ return "None"64+}65+66+// ParseColor is the inverse of Color.String, ignoring case.67+func ParseColor(s string) (Color, bool) {68+ i := slices.Index(colorNames[:], strings.ToLower(strings.TrimSpace(s)))69+ return Color(max(i, 0)), i >= 070+}71+72+// RuleStyle is what a conditional format rule does to the cells it73+// matches: a text color, a fill, and text styles added to the cell's own.74+type RuleStyle struct {75+ Text, Fill Color76+ Bold, Italic, Underline, Strikethrough bool77+}78+79+// IsZero reports whether the style changes nothing.80+func (s RuleStyle) IsZero() bool { return s == RuleStyle{} }81+82+// RuleOp is a test on a cell's value, as Sheets' conditional formatting83+// and data validation offer them. Conditional formats use them all;84+// validation uses the comparisons (and RuleNone for "any date").85+type RuleOp uint886+87+const (88+ RuleNone RuleOp = iota89+ RuleEmpty90+ RuleNotEmpty91+ RuleContains92+ RuleNotContains93+ RuleStartsWith94+ RuleEndsWith95+ RuleExactly96+ RuleDateIs97+ RuleDateBefore98+ RuleDateAfter99+ RuleGreater100+ RuleGreaterEq101+ RuleLess102+ RuleLessEq103+ RuleEqual104+ RuleNotEqual105+ RuleBetween106+ RuleNotBetween107+ RuleFormula108+ numRuleOps109+)110+111+var ruleOpNames = [numRuleOps]string{"", "empty", "not_empty", "contains", "not_contains",112+ "starts_with", "ends_with", "exactly", "date_is", "date_before", "date_after",113+ "gt", "ge", "lt", "le", "eq", "ne", "between", "not_between", "formula"}114+115+var ruleOpTitles = [numRuleOps]string{"None", "Is empty", "Is not empty", "Text contains",116+ "Text does not contain", "Text starts with", "Text ends with", "Text is exactly",117+ "Date is", "Date is before", "Date is after",118+ "Greater than", "Greater than or equal to", "Less than", "Less than or equal to",119+ "Is equal to", "Is not equal to", "Is between", "Is not between", "Custom formula is"}120+121+// String names the test as files store it.122+func (op RuleOp) String() string {123+ if op >= numRuleOps {124+ return ""125+ }126+ return ruleOpNames[op]127+}128+129+// Title names the test for people, e.g. "Greater than".130+func (op RuleOp) Title() string {131+ if op >= numRuleOps {132+ return ""133+ }134+ return ruleOpTitles[op]135+}136+137+// Args is how many values the test compares with: 0, 1, or 2 for138+// between.139+func (op RuleOp) Args() int {140+ switch op {141+ case RuleNone, RuleEmpty, RuleNotEmpty:142+ return 0143+ case RuleBetween, RuleNotBetween:144+ return 2145+ }146+ return 1147+}148+149+// ParseRuleOp is the inverse of RuleOp.String.150+func ParseRuleOp(s string) (RuleOp, bool) {151+ i := slices.Index(ruleOpNames[:], s)152+ return RuleOp(max(i, 0)), i >= 0153+}154+155+// CondFormatOps lists the tests a single-color conditional format may156+// use, in the order Sheets' menu shows them.157+func CondFormatOps() []RuleOp {158+ return []RuleOp{RuleEmpty, RuleNotEmpty, RuleContains, RuleNotContains, RuleStartsWith,159+ RuleEndsWith, RuleExactly, RuleDateIs, RuleDateBefore, RuleDateAfter, RuleGreater,160+ RuleGreaterEq, RuleLess, RuleLessEq, RuleEqual, RuleNotEqual, RuleBetween,161+ RuleNotBetween, RuleFormula}162+}163+164+// CompareOps are the comparisons data validation offers for numbers,165+// dates and text lengths.166+func CompareOps() []RuleOp {167+ return []RuleOp{RuleBetween, RuleNotBetween, RuleEqual, RuleNotEqual, RuleGreater,168+ RuleGreaterEq, RuleLess, RuleLessEq}169+}170+171+// rulesState is a sheet's rules. The slices are never changed in place:172+// a change replaces them, so undo steps can keep them as they were.173+type rulesState struct {174+ formats []CondFormat175+ validations []Validation176+}177+178+func (r rulesState) equal(o rulesState) bool {179+ return reflect.DeepEqual(r.formats, o.formats) && reflect.DeepEqual(r.validations, o.validations)180+}181+182+func (r rulesState) empty() bool { return len(r.formats) == 0 && len(r.validations) == 0 }183+184+// recordRules saves the sheet's rules before their first change in the185+// open step, and drops what was worked out from them.186+func (s *Sheet) recordRules() {187+ s.looks.reset()188+ st := s.wb.hist.open189+ if st == nil {190+ return191+ }192+ if _, seen := st.rules[s]; !seen {193+ st.rules[s] = s.rules194+ }195+}196+197+// setRules replaces the sheet's rules as one undo step.198+func (s *Sheet) setRules(label string, focus Rect, r rulesState) {199+ if r.equal(s.rules) {200+ return201+ }202+ s.change(label, focus, func() {203+ s.recordRules()204+ s.rules = r205+ })206+}207+208+// rangesText writes ranges as Sheets lists them, "A1:A9,C1:C9".209+func rangesText(rs []Rect) string {210+ parts := make([]string, len(rs))211+ for i, r := range rs {212+ parts[i] = r.String()213+ }214+ return strings.Join(parts, ",")215+}216+217+// ParseRanges reads ranges written as "A1:A9,C1:C9" (spaces or commas218+// between), as the rules editor takes them.219+func ParseRanges(s string) ([]Rect, bool) {220+ fields := strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' })221+ if len(fields) == 0 {222+ return nil, false223+ }224+ out := make([]Rect, 0, len(fields))225+ for _, f := range fields {226+ r, ok := ParseRange(strings.ReplaceAll(f, "$", ""))227+ if !ok {228+ return nil, false229+ }230+ out = append(out, r)231+ }232+ return out, true233+}234+235+// RangesText writes ranges as ParseRanges reads them.236+func RangesText(rs []Rect) string { return rangesText(rs) }237+238+// inRanges reports whether a is in one of rs.239+func inRanges(rs []Rect, a Addr) bool {240+ for _, r := range rs {241+ if r.Contains(a) {242+ return true243+ }244+ }245+ return false246+}247+248+// anchor is the top-left cell of the first range, which relative249+// references in a rule's formula are written for.250+func anchor(rs []Rect) Addr {251+ if len(rs) == 0 {252+ return Addr{}253+ }254+ return rs[0].From255+}256+257+// shiftRanges moves ranges with inserted or deleted lines, as a formula's258+// range would move, dropping those deleted entirely.259+func shiftRanges(rs []Rect, rng func(Rect) (Rect, bool)) []Rect {260+ var out []Rect261+ for _, r := range rs {262+ if to, ok := rng(r); ok {263+ out = append(out, to)264+ }265+ }266+ return out267+}268+269+// shiftFormula rewrites a rule's formula (with its "=") for moved cells,270+// or returns it unchanged when it doesn't parse.271+func shiftFormula(src string, rw formula.Rewriter) string {272+ if src == "" {273+ return src274+ }275+ n, err := Parse(src)276+ if err != nil {277+ return src278+ }279+ if out, changed := formula.Rewrite(n, rw); changed {280+ return formula.Text(out)281+ }282+ return src283+}284+285+// shiftRules keeps every sheet's rules in step with rows or columns286+// inserted or deleted on s: the ranges of s's rules move, and formulas287+// and list sources follow the cells they read, on s or on s by name.288+func (s *Sheet) shiftRules(rows bool, sp formula.Span) {289+ cell, rng := formula.AxisMaps(rows, sp)290+ for _, t := range s.wb.sheets {291+ if t.rules.empty() {292+ continue293+ }294+ on := func(name string) bool {295+ if name == "" {296+ return t == s297+ }298+ return formula.SheetKey(name) == formula.SheetKey(s.name)299+ }300+ rw := formula.Relocate(on, cell, rng)301+ next := t.rules.rewritten(func(rs []Rect) []Rect {302+ if t != s {303+ return rs304+ }305+ return shiftRanges(rs, rng)306+ }, rw)307+ if !next.equal(t.rules) {308+ t.recordRules()309+ t.rules = next310+ }311+ }312+}313+314+// renameRules rewrites the formulas and list sources of every rule that315+// name the sheet with key old, to name.316+func (w *Workbook) renameRules(old, name string) {317+ rename := func(sheet string) string {318+ if sheet != "" && formula.SheetKey(sheet) == old {319+ return name320+ }321+ return sheet322+ }323+ rw := formula.Rewriter{324+ Ref: func(n formula.Ref) Node { n.Sheet = rename(n.Sheet); return n },325+ Range: func(n formula.Range) Node { n.Sheet = rename(n.Sheet); return n },326+ }327+ for _, t := range w.sheets {328+ if t.rules.empty() {329+ continue330+ }331+ next := t.rules.rewritten(func(rs []Rect) []Rect { return rs }, rw)332+ if !next.equal(t.rules) {333+ t.recordRules()334+ t.rules = next335+ }336+ }337+}338+339+// rewritten is the rules with their ranges mapped by ranges and their340+// formulas and sources rewritten by rw; rules left with no range go.341+func (r rulesState) rewritten(ranges func([]Rect) []Rect, rw formula.Rewriter) rulesState {342+ var out rulesState343+ for _, f := range r.formats {344+ f.Ranges = ranges(f.Ranges)345+ if len(f.Ranges) == 0 {346+ continue347+ }348+ if f.Op == RuleFormula {349+ f.Args[0] = shiftFormula(f.Args[0], rw)350+ }351+ out.formats = append(out.formats, f)352+ }353+ for _, v := range r.validations {354+ v.Ranges = ranges(v.Ranges)355+ if len(v.Ranges) == 0 {356+ continue357+ }358+ switch v.Kind {359+ case ValidFormula:360+ v.Args[0] = shiftFormula(v.Args[0], rw)361+ case ValidRange:362+ v.Source = strings.TrimPrefix(shiftFormula("="+v.Source, rw), "=")363+ }364+ out.validations = append(out.validations, v)365+ }366+ return out367+}
A internal/sheet/rules_test.go +312 -0
@@ -0,0 +1,312 @@
1+package sheet2+3+import (4+ "bytes"5+ "math"6+ "strings"7+ "testing"8+)9+10+func ranges(s string) []Rect {11+ rs, ok := ParseRanges(s)12+ if !ok {13+ panic(s)14+ }15+ return rs16+}17+18+func mustAdd(t *testing.T, s *Sheet, f CondFormat) {19+ t.Helper()20+ if err := s.AddCondFormat(f); err != nil {21+ t.Fatal(err)22+ }23+}24+25+var green = RuleStyle{Fill: ColorGreen}26+27+func TestCondFormatTests(t *testing.T) {28+ fixedNow(t)29+ s := sheetOf(t, map[string]string{30+ "A1": "150", "A2": "50", "A3": "", "A4": "apple pie", "A5": "Pineapple", "A6": "2026-09-25",31+ "A7": "2026-09-27", "A8": "12", "B1": "=A1*2",32+ })33+ s.SetFormat(rng("A3"), Format{Kind: FmtNumber, Decimals: 2}) // formatted but blank34+ cases := []struct {35+ op RuleOp36+ args [2]string37+ match string // cells of A1:A8 that match38+ }{39+ {RuleGreater, [2]string{"100"}, "A1 A6 A7"},40+ {RuleLessEq, [2]string{"50"}, "A2 A8"},41+ {RuleBetween, [2]string{"60", "10"}, "A2 A8"},42+ {RuleNotBetween, [2]string{"10", "60"}, "A1 A6 A7"},43+ {RuleEmpty, [2]string{}, "A3"},44+ {RuleNotEmpty, [2]string{}, "A1 A2 A4 A5 A6 A7 A8"},45+ {RuleContains, [2]string{"APPLE"}, "A4 A5"},46+ {RuleStartsWith, [2]string{"apple"}, "A4"},47+ {RuleEndsWith, [2]string{"pie"}, "A4"},48+ {RuleExactly, [2]string{"pineapple"}, "A5"},49+ {RuleDateBefore, [2]string{"today"}, "A1 A2 A6 A8"},50+ {RuleDateIs, [2]string{"tomorrow"}, "A7"},51+ {RuleDateAfter, [2]string{"2026-09-26"}, "A7"},52+ {RuleEqual, [2]string{"=A$8"}, "A8"},53+ {RuleFormula, [2]string{"=$A1>=150"}, "A1 A4 A5 A6 A7"}, // text sorts after numbers,54+ {RuleFormula, [2]string{"=B1=300"}, "A1"},55+ }56+ for _, c := range cases {57+ s.rules = rulesState{}58+ mustAdd(t, s, CondFormat{Ranges: ranges("A1:A8"), Op: c.op, Args: c.args, Style: green})59+ var got []string60+ for row := range 8 {61+ if s.Look(Addr{Row: row}).Styled {62+ got = append(got, Addr{Row: row}.String())63+ }64+ }65+ if strings.Join(got, " ") != c.match {66+ t.Errorf("%s %v: matched %v, want %s", c.op, c.args, got, c.match)67+ }68+ }69+}70+71+func TestCondFormatOrderAndScale(t *testing.T) {72+ s := sheetOf(t, map[string]string{"A1": "0", "A2": "5", "A3": "10", "A4": "x"})73+ red := RuleStyle{Text: ColorRed, Bold: true}74+ mustAdd(t, s, CondFormat{Ranges: ranges("A1:A4"), Op: RuleGreater, Args: [2]string{"7"}, Style: red})75+ mustAdd(t, s, CondFormat{Ranges: ranges("A1:A4"), Scale: []ScalePoint{76+ {Kind: PointMin, Color: ColorRed}, {Kind: PointPercentile, Value: "50", Color: ColorYellow}, {Kind: PointMax, Color: ColorGreen}}})77+ if l := s.Look(at("A3")); !l.Styled || l.Style != red || l.Scaled {78+ t.Errorf("the first rule wins: %+v", l)79+ }80+ l := s.Look(at("A2"))81+ if !l.Scaled || l.From != ColorRed || l.To != ColorYellow || l.Pos != 1 {82+ t.Errorf("midpoint: %+v", l)83+ }84+ if l := s.Look(at("A1")); !l.Scaled || l.From != ColorRed || l.Pos != 0 {85+ t.Errorf("min: %+v", l)86+ }87+ if l := s.Look(at("A4")); l.Scaled || l.Styled {88+ t.Errorf("text on a scale: %+v", l)89+ }90+ s.MoveCondFormat(1, 0)91+ if l := s.Look(at("A3")); !l.Scaled || l.Pos != 1 || l.To != ColorGreen {92+ t.Errorf("after moving the scale first: %+v", l)93+ }94+ // The looks follow edits: the scale reads the new values.95+ s.Set(at("A3"), "20")96+ if l := s.Look(at("A2")); l.Pos != 1 || l.To != ColorYellow || math.Abs(s.Look(at("A3")).Pos-1) > 1e-9 {97+ t.Errorf("after an edit: %+v", l)98+ }99+ if l := s.Look(at("A1")); l.From != ColorRed || l.To != ColorYellow {100+ t.Errorf("min segment: %+v", l)101+ }102+}103+104+func TestCondFormatCheck(t *testing.T) {105+ bad := []CondFormat{106+ {Op: RuleGreater, Args: [2]string{"1"}, Style: green},107+ {Ranges: ranges("A1"), Op: RuleGreater, Style: green},108+ {Ranges: ranges("A1"), Op: RuleGreater, Args: [2]string{"1"}},109+ {Ranges: ranges("A1"), Op: RuleFormula, Args: [2]string{"A1>1"}, Style: green},110+ {Ranges: ranges("A1"), Op: RuleFormula, Args: [2]string{"=A1>"}, Style: green},111+ {Ranges: ranges("A1"), Op: RuleDateIs, Args: [2]string{"soon"}, Style: green},112+ {Ranges: ranges("A1"), Scale: []ScalePoint{{Kind: PointMin, Color: ColorRed}}},113+ {Ranges: ranges("A1"), Scale: []ScalePoint{{Kind: PointMin, Color: ColorRed}, {Kind: PointPercent, Value: "150", Color: ColorRed}}},114+ }115+ for _, f := range bad {116+ if f.Check() == nil {117+ t.Errorf("accepted %+v", f)118+ }119+ }120+ s := New()121+ if err := s.AddCondFormat(bad[2]); err == nil || s.CanUndo() {122+ t.Error("a bad rule was added")123+ }124+}125+126+func TestRulesUndoAndFile(t *testing.T) {127+ s := sheetOf(t, map[string]string{"A1": "1"})128+ mustAdd(t, s, CondFormat{Ranges: ranges("A1:A9,C1:C9"), Op: RuleFormula, Args: [2]string{"=$B1>100"}, Style: RuleStyle{Text: ColorBlue, Italic: true}})129+ mustAdd(t, s, CondFormat{Ranges: ranges("B1:B9"), Scale: []ScalePoint{{Kind: PointNumber, Value: "0", Color: ColorRed}, {Kind: PointMax, Color: ColorGreen}}})130+ if err := s.AddValidation(Validation{Ranges: ranges("D1:D9"), Kind: ValidList, Items: []string{"Yes", "No"}, Reject: true, Help: "Pick one"}); err != nil {131+ t.Fatal(err)132+ }133+ if err := s.AddValidation(Validation{Ranges: ranges("E1:E9"), Kind: ValidNumber, Op: RuleBetween, Args: [2]string{"1", "10"}}); err != nil {134+ t.Fatal(err)135+ }136+ if s.Book().UndoLabel() != "add data validation E1:E9" {137+ t.Errorf("label %q", s.Book().UndoLabel())138+ }139+ var buf bytes.Buffer140+ if err := s.Write(&buf); err != nil {141+ t.Fatal(err)142+ }143+ for _, want := range []string{144+ `"conditionalFormats": [`,145+ `{"ranges":"A1:A9,C1:C9","condition":"formula","values":["=$B1\u003e100"],"text":"blue","italic":true}`,146+ `{"ranges":"B1:B9","scale":[{"type":"num","value":"0","color":"red"},{"type":"max","color":"green"}]}`,147+ `{"ranges":"D1:D9","criteria":"list","items":["Yes","No"],"reject":true,"help":"Pick one"}`,148+ `{"ranges":"E1:E9","criteria":"number","condition":"between","values":["1","10"]}`,149+ `"version": 2,`,150+ } {151+ if !strings.Contains(buf.String(), want) {152+ t.Errorf("file lacks %s:\n%s", want, buf.String())153+ }154+ }155+ back, err := Read(bytes.NewReader(buf.Bytes()))156+ if err != nil {157+ t.Fatal(err)158+ }159+ if !back.rules.equal(s.rules) {160+ t.Errorf("round trip:\n%+v\n%+v", back.rules, s.rules)161+ }162+ for range 4 {163+ s.Undo()164+ }165+ if !s.rules.empty() {166+ t.Errorf("undo left %+v", s.rules)167+ }168+ s.Redo()169+ if len(s.CondFormats()) != 1 {170+ t.Errorf("redo: %+v", s.rules)171+ }172+}173+174+func TestRulesFollowInsertsAndRenames(t *testing.T) {175+ w := NewBook()176+ s := w.Sheet(0)177+ lists, _ := w.AddSheet("Lists", 1)178+ mustAdd(t, s, CondFormat{Ranges: ranges("B2:B9"), Op: RuleFormula, Args: [2]string{"=$C2>Lists!$A$1"}, Style: green})179+ if err := s.AddValidation(Validation{Ranges: ranges("D2:D9"), Kind: ValidRange, Source: "Lists!A1:A5"}); err != nil {180+ t.Fatal(err)181+ }182+ s.InsertRows(0, 2)183+ s.InsertCols(0, 1)184+ lists.InsertRows(0, 1)185+ f, v := s.CondFormats()[0], s.Validations()[0]186+ if RangesText(f.Ranges) != "C4:C11" || f.Args[0] != "=$D4>Lists!$A$2" {187+ t.Errorf("format after inserts: %s %s", RangesText(f.Ranges), f.Args[0])188+ }189+ if RangesText(v.Ranges) != "E4:E11" || v.Source != "Lists!A2:A6" {190+ t.Errorf("validation after inserts: %s %s", RangesText(v.Ranges), v.Source)191+ }192+ if err := w.RenameSheet(lists, "Choices"); err != nil {193+ t.Fatal(err)194+ }195+ if f := s.CondFormats()[0]; f.Args[0] != "=$D4>Choices!$A$2" || s.Validations()[0].Source != "Choices!A2:A6" {196+ t.Errorf("after rename: %s %s", f.Args[0], s.Validations()[0].Source)197+ }198+ s.DeleteCols(2, 1)199+ if len(s.CondFormats()) != 0 {200+ t.Error("a rule whose cells were deleted stays")201+ }202+ w.Undo()203+ if len(s.CondFormats()) != 1 {204+ t.Error("undo didn't bring the rule back")205+ }206+}207+208+func TestValidation(t *testing.T) {209+ fixedNow(t)210+ s := sheetOf(t, map[string]string{"A1": "Red", "A2": "Blue", "A3": "red", "A4": "", "A5": "Green", "B1": "Blue"})211+ add := func(v Validation) {212+ t.Helper()213+ if err := s.AddValidation(v); err != nil {214+ t.Fatal(err)215+ }216+ }217+ add(Validation{Ranges: ranges("B1:B9"), Kind: ValidRange, Source: "$A$1:$A$9", Reject: true})218+ add(Validation{Ranges: ranges("C1:C9"), Kind: ValidList, Items: []string{"Yes", "No"}})219+ add(Validation{Ranges: ranges("D1:D9"), Kind: ValidNumber, Op: RuleBetween, Args: [2]string{"1", "10"}})220+ add(Validation{Ranges: ranges("E1:E9"), Kind: ValidDate, Op: RuleGreater, Args: [2]string{"2026-01-01"}})221+ add(Validation{Ranges: ranges("F1:F9"), Kind: ValidLength, Op: RuleLessEq, Args: [2]string{"3"}})222+ add(Validation{Ranges: ranges("G1:G9"), Kind: ValidFormula, Args: [2]string{"=G1<>A1"}})223+ add(Validation{Ranges: ranges("H1:H9"), Kind: ValidCheckbox})224+ if got := strings.Join(s.DropdownItems(at("B2")), ","); got != "Red,Blue,Green" {225+ t.Errorf("items %q", got)226+ }227+ cases := []struct {228+ cell, input string229+ ok bool230+ }{231+ {"B1", "green", true}, {"B1", "Purple", false}, {"C1", "yes", true}, {"C1", "maybe", false},232+ {"D1", "10", true}, {"D1", "11", false}, {"D1", "=2*3", true}, {"D1", "ten", false},233+ {"E1", "2026-03-01", true}, {"E1", "2025-12-31", false}, {"E1", "45000", false},234+ {"F1", "abc", true}, {"F1", "abcd", false}, {"G1", "Red", false}, {"G1", "Pink", true},235+ {"H1", "TRUE", true}, {"H1", "yes", false}, {"Z1", "anything", true}, {"D1", "", true},236+ }237+ for _, c := range cases {238+ err := s.CheckEntry(at(c.cell), c.input)239+ if (err == nil) != c.ok {240+ t.Errorf("%s %q: %v", c.cell, c.input, err)241+ }242+ }243+ err := s.CheckEntry(at("B1"), "Purple")244+ if err == nil || !err.Reject || err.Help != "Input must fall within specified range" {245+ t.Errorf("reject: %+v", err)246+ }247+ if err := s.CheckEntry(at("D1"), "0"); err == nil || err.Reject || err.Help != "Input must be a number between 1 and 10" {248+ t.Errorf("warn: %+v", err)249+ }250+ s.Set(at("D2"), "42")251+ s.Set(at("H2"), "TRUE")252+ if l := s.Look(at("D2")); !l.Invalid {253+ t.Errorf("invalid mark: %+v", l)254+ }255+ if l := s.Look(at("H2")); !l.Checkbox || !l.Checked {256+ t.Errorf("checkbox: %+v", l)257+ }258+ if l := s.Look(at("H3")); !l.Checkbox || l.Checked || l.Invalid {259+ t.Errorf("blank checkbox: %+v", l)260+ }261+ if l := s.Look(at("C3")); !l.Dropdown {262+ t.Errorf("dropdown: %+v", l)263+ }264+ // A rule over cells of another takes them.265+ add(Validation{Ranges: ranges("C5:D6"), Kind: ValidCheckbox})266+ if got := RangesText(s.Validations()[1].Ranges); got != "C1:C4,C7:C9" {267+ t.Errorf("list rule's cells: %s", got)268+ }269+ if got := RangesText(s.Validations()[2].Ranges); got != "D1:D4,D7:D9" {270+ t.Errorf("number rule's cells: %s", got)271+ }272+ s.ClearValidations(rng("A1:XFD1048576"))273+ if len(s.Validations()) != 0 {274+ t.Errorf("clear left %+v", s.Validations())275+ }276+}277+278+func TestRuleJSON(t *testing.T) {279+ lines := []string{280+ `{"ranges":"A1:B2","condition":"between","values":["1","2"],"fill":"yellow","underline":true}`,281+ `{"ranges":"A:A","scale":[{"type":"min","color":"red"},{"type":"percentile","value":"50","color":"yellow"},{"type":"max","color":"green"}]}`,282+ }283+ for _, l := range lines {284+ f, err := ParseCondFormat(l)285+ if err != nil || f.JSON() != l {286+ t.Errorf("%s: %v %s", l, err, f.JSON())287+ }288+ }289+ v, err := ParseValidation(`{"ranges":"C1:C9","criteria":"date","condition":"lt","values":["2026-12-31"],"reject":true}`)290+ if err != nil || v.Kind != ValidDate || v.Op != RuleLess || !v.Reject {291+ t.Errorf("%+v %v", v, err)292+ }293+ if _, err := ParseValidation(`{"ranges":"C1","criteria":"list"}`); err == nil {294+ t.Error("a list without items")295+ }296+ if _, err := ParseCondFormat(`{"ranges":"C1","condition":"gt","values":["1"],"fill":"mauve"}`); err == nil {297+ t.Error("an unknown color")298+ }299+}300+301+func TestSubtract(t *testing.T) {302+ got := RangesText(subtract(rng("A1:E5"), rng("B2:C3")))303+ if got != "A1:E1,A4:E5,A2:A3,D2:E3" {304+ t.Errorf("%s", got)305+ }306+ if got := RangesText(subtract(rng("A1:A5"), rng("C1:C5"))); got != "A1:A5" {307+ t.Errorf("disjoint: %s", got)308+ }309+ if got := subtract(rng("B2:B3"), rng("A1:C9")); len(got) != 0 {310+ t.Errorf("covered: %v", got)311+ }312+}
M internal/sheet/sheet.go +3 -0
A internal/sheet/validation.go +423 -0
@@ -0,0 +1,423 @@
1+package sheet2+3+import (4+ "errors"5+ "fmt"6+ "slices"7+ "strings"8+9+ "github.com/FelineStateMachine/012/internal/formula"10+ "github.com/FelineStateMachine/012/internal/functions"11+)12+13+// Data validation follows Sheets' Data > Data validation: a rule on14+// ranges of the sheet says what their cells may hold (an item of a15+// list, TRUE or FALSE for a checkbox, a number or date in bounds, text16+// of a length, or whatever meets a formula) and whether an entry that17+// doesn't is rejected or kept with a warning. Each cell has at most one18+// rule: adding one over cells that had another takes them from it.19+20+// ValidKind is what a validation rule checks.21+type ValidKind uint822+23+const (24+ ValidList ValidKind = iota // one of Items, picked from a dropdown25+ ValidRange // one of the values of Source, picked from a dropdown26+ ValidCheckbox // TRUE or FALSE, drawn as a checkbox27+ ValidNumber // a number meeting Op28+ ValidDate // a date meeting Op; any date with RuleNone29+ ValidLength // text whose length meets Op30+ ValidFormula // anything for which the formula Args[0] is TRUE31+ numValidKinds32+)33+34+var validNames = [numValidKinds]string{"list", "range", "checkbox", "number", "date", "length", "formula"}35+var validTitles = [numValidKinds]string{"Dropdown", "Dropdown (from a range)", "Checkbox", "Number",36+ "Date", "Text length", "Custom formula is"}37+38+func (k ValidKind) String() string {39+ if k >= numValidKinds {40+ return ""41+ }42+ return validNames[k]43+}44+45+// Title names the kind for people, e.g. "Dropdown (from a range)".46+func (k ValidKind) Title() string {47+ if k >= numValidKinds {48+ return ""49+ }50+ return validTitles[k]51+}52+53+// ParseValidKind is the inverse of ValidKind.String.54+func ParseValidKind(s string) (ValidKind, bool) {55+ i := slices.Index(validNames[:], s)56+ return ValidKind(max(i, 0)), i >= 057+}58+59+// ValidKinds lists the kinds in the order the rules editor offers them.60+func ValidKinds() []ValidKind {61+ out := make([]ValidKind, numValidKinds)62+ for i := range out {63+ out[i] = ValidKind(i)64+ }65+ return out66+}67+68+// Compares reports whether the kind compares with Op and Args.69+func (k ValidKind) Compares() bool { return k == ValidNumber || k == ValidDate || k == ValidLength }70+71+// Dropdown reports whether cells of the kind offer a list to pick from.72+func (k ValidKind) Dropdown() bool { return k == ValidList || k == ValidRange }73+74+// Validation is a data validation rule.75+type Validation struct {76+ Ranges []Rect77+ Kind ValidKind78+ // Op compares numbers, dates or lengths with Args, as typed; RuleNone79+ // with ValidDate accepts any date. With ValidFormula, Args[0] is the80+ // formula, relative to the first range's top-left cell.81+ Op RuleOp82+ Args [2]string83+ Items []string // ValidList's items84+ // Source is ValidRange's range, e.g. "A2:A9" or "Lists!A1:A20".85+ Source string86+ // Reject refuses an invalid entry; otherwise it's kept and marked.87+ Reject bool88+ // Help replaces the rule's own help text, shown on the context line.89+ Help string90+}91+92+func (v Validation) clone() Validation {93+ v.Ranges, v.Items = slices.Clone(v.Ranges), slices.Clone(v.Items)94+ return v95+}96+97+// Check reports why a rule can't be used, in words for the rules editor.98+func (v Validation) Check() error {99+ switch {100+ case len(v.Ranges) == 0:101+ return errors.New("Enter the range the rule applies to, e.g. B2:B100")102+ case v.Kind >= numValidKinds:103+ return errors.New("Choose the criteria")104+ case v.Kind == ValidList && len(v.Items) == 0:105+ return errors.New("Enter the items, separated by commas")106+ case v.Kind == ValidRange:107+ if _, _, ok := v.sourceRange(); !ok {108+ return errors.New("Enter the range the items are in, e.g. Lists!A1:A20")109+ }110+ case v.Kind == ValidFormula:111+ return checkArg(RuleFormula, strings.TrimSpace(v.Args[0]))112+ case v.Kind.Compares():113+ return v.checkBounds()114+ }115+ return nil116+}117+118+func (v Validation) checkBounds() error {119+ if v.Op == RuleNone && v.Kind == ValidDate {120+ return nil121+ }122+ if !slices.Contains(CompareOps(), v.Op) {123+ return errors.New("Choose a comparison")124+ }125+ for i := range v.Op.Args() {126+ arg := strings.TrimSpace(v.Args[i])127+ if IsFormulaEntry(arg) {128+ if err := checkArg(RuleFormula, arg); err != nil {129+ return err130+ }131+ continue132+ }133+ if _, _, ok := ParseValue(arg); !ok {134+ what := "a number"135+ if v.Kind == ValidDate {136+ what = "a date, e.g. 2026-09-30"137+ }138+ return errors.New("Enter " + what)139+ }140+ }141+ return nil142+}143+144+// sourceRange is ValidRange's source: its sheet as written ("" for the145+// rule's own) and range.146+func (v Validation) sourceRange() (string, Rect, bool) {147+ sheet, rest := SplitSheet(strings.TrimPrefix(strings.TrimSpace(v.Source), "="))148+ r, ok := ParseRange(strings.ReplaceAll(rest, "$", ""))149+ return sheet, r, ok150+}151+152+// Summary describes the rule in a few words, e.g. "Number between 1 and 10".153+func (v Validation) Summary() string {154+ switch {155+ case v.Kind == ValidList:156+ return "Dropdown: " + strings.Join(v.Items, ", ")157+ case v.Kind == ValidRange:158+ return "Dropdown from " + v.Source159+ case v.Kind == ValidFormula:160+ return "Custom formula " + v.Args[0]161+ case v.Kind.Compares():162+ return v.Kind.Title() + " " + compareText(v.Kind, v.Op, v.Args)163+ }164+ return v.Kind.Title()165+}166+167+// compareText is a comparison in words: "between 1 and 10", "after 1/1/2026".168+func compareText(k ValidKind, op RuleOp, args [2]string) string {169+ if k == ValidDate && op == RuleNone {170+ return "is a valid date"171+ }172+ words := map[RuleOp]string{RuleBetween: "between", RuleNotBetween: "not between",173+ RuleEqual: "equal to", RuleNotEqual: "not equal to", RuleGreater: "greater than",174+ RuleGreaterEq: "greater than or equal to", RuleLess: "less than", RuleLessEq: "less than or equal to"}175+ if k == ValidDate {176+ words[RuleEqual], words[RuleNotEqual] = "on", "not on"177+ words[RuleGreater], words[RuleGreaterEq] = "after", "on or after"178+ words[RuleLess], words[RuleLessEq] = "before", "on or before"179+ }180+ w := words[op]181+ if op.Args() == 2 {182+ return w + " " + args[0] + " and " + args[1]183+ }184+ return w + " " + args[0]185+}186+187+// HelpText is what the context line says about a cell under the rule:188+// the rule's own help text, or Sheets' message for its criteria.189+func (v Validation) HelpText() string {190+ if h := strings.TrimSpace(v.Help); h != "" {191+ return h192+ }193+ switch v.Kind {194+ case ValidList:195+ return "Input must be an item on the specified list"196+ case ValidRange:197+ return "Input must fall within specified range"198+ case ValidCheckbox:199+ return "Input must be TRUE or FALSE"200+ case ValidNumber:201+ return "Input must be a number " + compareText(v.Kind, v.Op, v.Args)202+ case ValidDate:203+ if v.Op == RuleNone {204+ return "Input must be a valid date"205+ }206+ return "Input must be a date " + compareText(v.Kind, v.Op, v.Args)207+ case ValidLength:208+ return "Input must be text whose length is " + compareText(v.Kind, v.Op, v.Args)209+ }210+ return "Input must satisfy the formula " + v.Args[0]211+}212+213+// Validations returns the sheet's validation rules.214+func (s *Sheet) Validations() []Validation {215+ out := make([]Validation, len(s.rules.validations))216+ for i, v := range s.rules.validations {217+ out[i] = v.clone()218+ }219+ return out220+}221+222+// Validation returns the rule of the cell at a, if it has one.223+func (s *Sheet) Validation(a Addr) (Validation, bool) {224+ if i := s.validationIndex(a); i >= 0 {225+ return s.rules.validations[i].clone(), true226+ }227+ return Validation{}, false228+}229+230+func (s *Sheet) validationIndex(a Addr) int {231+ for i, v := range s.rules.validations {232+ if inRanges(v.Ranges, a) {233+ return i234+ }235+ }236+ return -1237+}238+239+// AddValidation adds a rule, taking its cells from the rules they had,240+// as one undo step.241+func (s *Sheet) AddValidation(v Validation) error {242+ return s.putValidation(-1, v, "add data validation "+rangesText(v.Ranges))243+}244+245+// SetValidation replaces rule i, as one undo step.246+func (s *Sheet) SetValidation(i int, v Validation) error {247+ if i < 0 || i >= len(s.rules.validations) {248+ return errors.New("That rule was removed")249+ }250+ return s.putValidation(i, v, "edit data validation "+rangesText(v.Ranges))251+}252+253+// putValidation stores v in place of rule i (-1 adds it), taking its254+// cells from the others.255+func (s *Sheet) putValidation(i int, v Validation, label string) error {256+ if err := v.Check(); err != nil {257+ return err258+ }259+ v = v.clone()260+ r := s.rules261+ r.validations = nil262+ for j, o := range s.rules.validations {263+ if j == i {264+ r.validations = append(r.validations, v)265+ continue266+ }267+ for _, cut := range v.Ranges {268+ o.Ranges = subtractAll(o.Ranges, cut)269+ }270+ if len(o.Ranges) > 0 {271+ r.validations = append(r.validations, o)272+ }273+ }274+ if i < 0 {275+ r.validations = append(r.validations, v)276+ }277+ s.setRules(label, v.Ranges[0], r)278+ return nil279+}280+281+// DeleteValidation removes rule i.282+func (s *Sheet) DeleteValidation(i int) {283+ if i < 0 || i >= len(s.rules.validations) {284+ return285+ }286+ v := s.rules.validations[i]287+ r := s.rules288+ r.validations = slices.Delete(slices.Clone(r.validations), i, i+1)289+ s.setRules("remove data validation "+rangesText(v.Ranges), v.Ranges[0], r)290+}291+292+// ClearValidations takes the cells of cr out of every rule, as Sheets'293+// "Remove rule" does for a selection.294+func (s *Sheet) ClearValidations(cr Rect) {295+ r := s.rules296+ r.validations = nil297+ for _, v := range s.rules.validations {298+ v.Ranges = subtractAll(v.Ranges, cr)299+ if len(v.Ranges) > 0 {300+ r.validations = append(r.validations, v)301+ }302+ }303+ s.setRules("remove data validation from "+cr.String(), cr, r)304+}305+306+// LoadValidations adds rules as a loader does: without recording undo.307+// Rules that don't check are left out and counted.308+func (s *Sheet) LoadValidations(vs []Validation) (skipped int) {309+ for _, v := range vs {310+ if v.Check() != nil {311+ skipped++312+ continue313+ }314+ s.rules.validations = append(s.rules.validations, v.clone())315+ }316+ s.looks.reset()317+ return skipped318+}319+320+// InvalidEntry is an entry a cell's validation doesn't accept.321+type InvalidEntry struct {322+ Addr Addr323+ Help string // what the rule wants, e.g. "Input must be a number between 1 and 10"324+ Reject bool // the rule refuses it; otherwise it may be kept, marked invalid325+}326+327+func (e *InvalidEntry) Error() string {328+ return fmt.Sprintf("The data you entered in %s violates the data validation rules set on this cell. %s", e.Addr, e.Help)329+}330+331+// CheckEntry reports whether the cell at a would accept input: nil when332+// it has no rule or input meets it (blanks always do), or an333+// *InvalidEntry. A formula is checked by the value it computes.334+func (s *Sheet) CheckEntry(a Addr, input string) *InvalidEntry {335+ i := s.validationIndex(a)336+ if i < 0 || input == "" {337+ return nil338+ }339+ v := s.rules.validations[i]340+ val, f := s.entryValue(a, input)341+ if f.IsZero() {342+ f = s.DisplayFormat(a)343+ }344+ if v.Kind == ValidFormula {345+ // The formula reads the entry in its own cell.346+ s.looks.fresh(s)347+ rd := s.wb.lookupOn(s, func(t *Sheet, b Addr) Value {348+ if t == s && b == a {349+ return val350+ }351+ return t.Value(b)352+ })353+ n, err := Parse(strings.TrimSpace(v.Args[0]))354+ if err == nil {355+ from := anchor(v.Ranges)356+ n, _ = formula.Rewrite(n, formula.Shift(a.Col-from.Col, a.Row-from.Row))357+ if truthy(functions.EvalAt(n, rd.lib, a)) {358+ return nil359+ }360+ }361+ } else if s.looks.check(s, i, a, val, f) {362+ return nil363+ }364+ return &InvalidEntry{Addr: a, Help: v.HelpText(), Reject: v.Reject}365+}366+367+// entryValue is what input would compute to at a, and the format it368+// implies, without storing it.369+func (s *Sheet) entryValue(a Addr, input string) (Value, Format) {370+ n, f, err := classify(input)371+ switch {372+ case err != nil:373+ return ErrValue, f374+ case n == nil:375+ return Value{Kind: Text, Str: strings.TrimPrefix(input, "'")}, f376+ }377+ return s.looks.eval(s, n, a), f378+}379+380+// DropdownItems lists what a dropdown cell offers: the rule's items, or381+// the distinct values shown in its range, in order, blanks left out.382+func (s *Sheet) DropdownItems(a Addr) []string {383+ v, ok := s.Validation(a)384+ switch {385+ case !ok:386+ return nil387+ case v.Kind == ValidList:388+ return v.Items389+ case v.Kind == ValidRange:390+ return s.sourceItems(v)391+ }392+ return nil393+}394+395+// maxDropdown caps the items a dropdown from a range lists.396+const maxDropdown = 1000397+398+// sourceItems reads the distinct values shown in a rule's source range.399+func (s *Sheet) sourceItems(v Validation) []string {400+ name, r, ok := v.sourceRange()401+ t := s402+ if name != "" {403+ t = s.wb.resolve(s, name)404+ }405+ if !ok || t == nil {406+ return nil407+ }408+ seen := map[string]bool{}409+ var out []string410+ for a := range t.cells.inRange(r) {411+ text := strings.TrimSpace(t.ShownText(a))412+ key := strings.ToLower(text)413+ if text == "" || seen[key] {414+ continue415+ }416+ seen[key] = true417+ out = append(out, text)418+ if len(out) == maxDropdown {419+ break420+ }421+ }422+ return out423+}
M internal/sheet/workbook.go +6 -0