Conditional formats gain the tests that compare a cell with the rest of its ranges (top or bottom N or N percent, above or below average, duplicate or unique values), worked out once per recalculation as a color scale is; date rules take periods (this, last or next week, month or year, the past week, month or year); and two rule types that draw their own: data bars (from zero for positive numbers when the shortest point is automatic) and icon sets of arrows, circles, symbols and rating bars, with thresholds, reversed or without the values. Validation: a checkbox may hold values of its own; a dropdown shows an arrow, a chip or plain text. All of it is in .012 files (dataBar, iconSet, display, checkbox items) and in XLSX both ways: top10, aboveAverage, duplicateValues and uniqueValues, timePeriod where Excel names the period (else an expression), dataBar and iconSet (traffic lights, signs and quarters as circles, flags as symbols), showDropDown for plain dropdowns; Excel 2010's copy of a data bar is no longer counted as left out. Cut on one sheet and pasted on another, cells take their rules along (MoveTo), as they do on their sheet. Workbook.Try runs a change that can be taken back whole, inside a macro's open step too, so pastes and fills a macro runs are checked against validation as the keys' are, and a rejected one fails the script without undoing the rest. Undo's restore moves to historyrestore.go.
- commit
- b602e6f5589b2c2d3ba82e7ed2ca08130175c067
- parent
- cbbd3f9989e2136794904b257c71a4b94159a98f
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 2.2 of 3, Jev's estimate
@@ -165,7 +165,9 @@ line:
165165 ```json166166 "conditionalFormats": [167167 {"ranges":"A2:A6","condition":"formula","values":["=$D2"],"text":"green","strikethrough":true},168- {"ranges":"C2:C6","scale":[{"type":"min","color":"green"},{"type":"percentile","value":"50","color":"yellow"},{"type":"max","color":"red"}]}168+ {"ranges":"C2:C6","scale":[{"type":"min","color":"green"},{"type":"percentile","value":"50","color":"yellow"},{"type":"max","color":"red"}]},169+ {"ranges":"E2:E6","dataBar":{"color":"blue","min":{"type":"min"},"max":{"type":"max"}}},170+ {"ranges":"F2:F6","iconSet":{"icons":"arrows","points":[{"type":"percent","value":"33"},{"type":"percent","value":"67"}]}}169171 ],170172 "validations": [171173 {"ranges":"B2:B6","criteria":"list","items":["Ann","Bo","Cy"],"help":"Pick who does it"},@@ -178,17 +180,29 @@ line:
178180 - A single-color rule has a `condition` (`empty`, `not_empty`, `contains`,179181 `not_contains`, `starts_with`, `ends_with`, `exactly`, `date_is`,180182 `date_before`, `date_after`, `gt`, `ge`, `lt`, `le`, `eq`, `ne`,181- `between`, `not_between`, `formula`), its `values` as typed, and its182- style: `text` and `fill` colors (`red`, `yellow`, `green`, `cyan`,183- `blue`, `magenta`) and `bold`, `italic`, `underline`, `strikethrough`.183+ `between`, `not_between`, `formula`, and those comparing a cell with184+ the rest: `top`, `bottom`, `top_percent`, `bottom_percent`,185+ `above_average`, `below_average`, `duplicate`, `unique`), its `values`186+ as typed (a date rule's may be a period: `this week`), and its style:187+ `text` and `fill` colors (`red`, `yellow`, `green`, `cyan`, `blue`,188+ `magenta`) and `bold`, `italic`, `underline`, `strikethrough`.184189 - A color scale has `scale`: two or three points, each a `type` (`min`,185190 `max`, `num`, `percent`, `percentile`), a `value` where it takes one,186191 and a `color`.192+- A data bar has `dataBar`: its `color`, its `min` and `max` points as a193+ scale's, and `barOnly` to hide the values. An icon set has `iconSet`:194+ its `icons` (`arrows`, `circles`, `symbols`, `rating`), the `points`195+ where each icon after the first starts, `reverse` for the other way196+ round and `iconOnly` to hide the values. Older builds leave both out.187197 - A validation rule has `criteria` (`list`, `range`, `checkbox`,188198 `number`, `date`, `length`, `formula`) with its `items`, `source`189199 (`Lists!A1:A20`), or `condition` (`between`, `not_between`, `eq`, `ne`,190200 `gt`, `ge`, `lt`, `le`; none for any date) and `values`; `reject` refuses191- invalid entries, and `help` replaces the rule's own help text.201+ invalid entries, and `help` replaces the rule's own help text. A202+ checkbox's `items`, when it has them, are its checked and unchecked203+ values (`["Yes", "No"]`); a dropdown's `display` is `chip` or `plain`204+ when it doesn't show the arrow. Older builds draw every dropdown with205+ its arrow, and a checkbox with values of its own as invalid entries.192206 193207 A line of either list is also what Add conditional format rule and Add194208 data validation rule take (in the palette, and as `run(...,@@ -119,7 +119,12 @@ var textRules = map[sheet.RuleOp]struct{ typ, op, formula string }{
119119 // cfRule writes rule f as a <cfRule> of priority p, or reports false120120 // when Excel can't take it.121121 func (w *xlsxWriter) cfRule(f sheet.CondFormat, p int) (string, bool) {122- if f.IsScale() {122+ switch {123+ case f.IsBar():124+ return dataBarXML(f, p), true125+ case f.IsIcons():126+ return iconSetXML(f, p)127+ case f.IsScale():123128 return colorScaleXML(f.Scale, p), true124129 }125130 cell := excelRect(sheet.Rect{From: f.Ranges[0].From, To: f.Ranges[0].From})@@ -143,8 +148,12 @@ func (w *xlsxWriter) cfRule(f sheet.CondFormat, p int) (string, bool) {
143148 formulas(fmt.Sprintf(t.formula, cell, excelText(arg))) + "</cfRule>", true144149 case op == sheet.RuleExactly && !sheet.IsFormulaEntry(arg):145150 return fmt.Sprintf(head, "cellIs") + ` operator="equal">` + formulas(excelText(arg)) + "</cfRule>", true151+ case (op == sheet.RuleDateIs || op == sheet.RuleDateBefore || op == sheet.RuleDateAfter) && sheet.IsPeriod(arg):152+ return periodRule(head, cell, op, arg)146153 case op == sheet.RuleDateIs || op == sheet.RuleDateBefore || op == sheet.RuleDateAfter:147154 return w.dateRule(head, cell, op, arg)155+ case op.Ranks():156+ return rankRule(head, op, arg), true148157 case op == sheet.RuleFormula:149158 fx, ok := toExcelFormula(arg, w.renamed)150159 return fmt.Sprintf(head, "expression") + ">" + formulas(fx) + "</cfRule>", ok@@ -214,6 +223,12 @@ func (w *xlsxWriter) validation(v sheet.Validation) (string, bool) {
214223 fs = []string{excelText(list)}215224 case sheet.ValidCheckbox:216225 fs = []string{`"TRUE,FALSE"`}226+ if on, off, custom := v.CheckboxValues(); custom {227+ // Excel has no checkbox of other values: a list of them.228+ list := strings.Trim(on+","+off, ",")229+ ok = !strings.ContainsAny(on+off, `,"`)230+ fs = []string{excelText(list)}231+ }217232 case sheet.ValidRange:218233 name, r, rok := v.SourceRange()219234 ref, _ := strings.CutPrefix(excelRange("x", r), sheet.QuoteSheet("x")+"!")@@ -247,6 +262,9 @@ func (w *xlsxWriter) validation(v sheet.Validation) (string, bool) {
247262 if op != "" {248263 fmt.Fprintf(&b, ` operator="%s"`, op)249264 }265+ if v.Kind.Dropdown() && v.Display == sheet.DropPlain {266+ b.WriteString(` showDropDown="1"`) // Excel's name for hiding the arrow267+ }250268 style := "stop"251269 if !v.Reject {252270 style = "warning"@@ -169,7 +169,7 @@ func TestXLSXRulesImport(t *testing.T) {
169169 `<sheetData><row r="1"><c r="A1"><v>5</v></c></row></sheetData>` +170170 `<conditionalFormatting sqref="A1:A10"><cfRule type="dataBar" priority="4"><dataBar><cfvo type="min"/><cfvo type="max"/><color rgb="FF638EC6"/></dataBar></cfRule>` +171171 `<cfRule type="cellIs" dxfId="0" priority="2" operator="lessThan"><formula>10</formula></cfRule>` +172- `<cfRule type="iconSet" priority="5"><iconSet><cfvo type="percent" val="0"/></iconSet></cfRule></conditionalFormatting>` +172+ `<cfRule type="iconSet" priority="5"><iconSet iconSet="3Arrows"><cfvo type="percent" val="0"/><cfvo type="percent" val="33"/><cfvo type="percent" val="67"/></iconSet></cfRule></conditionalFormatting>` +173173 `<conditionalFormatting sqref="B1:B10 D1"><cfRule type="expression" dxfId="1" priority="1"><formula>INT(B1)<DATE(2026,1,5)</formula></cfRule>` +174174 `<cfRule type="expression" dxfId="2" priority="3"><formula>B1>0</formula></cfRule>` +175175 `<cfRule type="colorScale" priority="6"><colorScale><cfvo type="min"/><cfvo type="max"/><color theme="0"/><color rgb="FF57BB8A"/></colorScale></cfRule>` +@@ -180,7 +180,8 @@ func TestXLSXRulesImport(t *testing.T) {
180180 `<dataValidation type="list" sqref="G1"><formula1>Choices</formula1></dataValidation></dataValidations>` +181181 `<extLst><ext uri="{CCE6A557-97BC-4b89-ADB6-D9C93CAAB3DF}"><x14:dataValidations count="1"><x14:dataValidation type="list" allowBlank="1" showErrorMessage="1">` +182182 `<x14:formula1><xm:f>Lists!$A$1:$A$5</xm:f></x14:formula1><xm:sqref>H1:H9</xm:sqref></x14:dataValidation></x14:dataValidations></ext>` +183- `<ext uri="{78C0D931-6437-407d-A8EE-F0AAD7539E65}"><x14:conditionalFormattings><x14:conditionalFormatting><x14:cfRule type="dataBar" id="{1}"/><xm:sqref>A1:A10</xm:sqref></x14:conditionalFormatting></x14:conditionalFormattings></ext></extLst></worksheet>`183+ `<ext uri="{78C0D931-6437-407d-A8EE-F0AAD7539E65}"><x14:conditionalFormattings><x14:conditionalFormatting><x14:cfRule type="dataBar" id="{1}"/><xm:sqref>A1:A10</xm:sqref></x14:conditionalFormatting>` +184+ `<x14:conditionalFormatting><x14:cfRule type="iconSet" id="{2}"><x14:iconSet iconSet="3Stars"/></x14:cfRule><xm:sqref>C1:C5</xm:sqref></x14:conditionalFormatting></x14:conditionalFormattings></ext></extLst></worksheet>`184185 path := writeParts(t, t.TempDir(), "excel.xlsx", p)185186 got, err := Import(context.Background(), path, Options{})186187 if err != nil {@@ -190,7 +191,10 @@ func TestXLSXRulesImport(t *testing.T) {
190191 wantF := []string{191192 `{"ranges":"B1:B10,D1","condition":"date_before","values":["2026-01-05"],"text":"blue","bold":true}`,192193 `{"ranges":"A1:A10","condition":"lt","values":["10"],"text":"red","fill":"red"}`,194+ `{"ranges":"A1:A10","dataBar":{"color":"blue","min":{"type":"min"},"max":{"type":"max"}}}`,195+ `{"ranges":"A1:A10","iconSet":{"icons":"arrows","points":[{"type":"percent","value":"33"},{"type":"percent","value":"67"}]}}`,193196 `{"ranges":"B1:B10,D1","scale":[{"type":"min","color":"yellow"},{"type":"max","color":"green"}]}`,197+ `{"ranges":"B1:B10,D1","condition":"date_is","values":["last week"],"text":"red","fill":"red"}`,194198 }195199 if f := formatLines(s); !reflect.DeepEqual(f, wantF) {196200 t.Errorf("conditional formats:\n%s\nwant\n%s", strings.Join(f, "\n"), strings.Join(wantF, "\n"))@@ -204,7 +208,7 @@ func TestXLSXRulesImport(t *testing.T) {
204208 t.Errorf("validations:\n%s\nwant\n%s", strings.Join(v, "\n"), strings.Join(wantV, "\n"))205209 }206210 wantNotes := []string{207- "5 conditional formats left out: data bars (1), dates in periods other than today, tomorrow and yesterday (1), icon sets (1), in Excel 2010's extension (data bars, icon sets) (1), without a text color, fill or text style 012 draws (1)",211+ "2 conditional formats left out: in Excel 2010's extension (its own icon sets and the like) (1), without a text color, fill or text style 012 draws (1)",208212 "2 data validation rules left out: lists from named ranges or formulas (1), time rules (1)",209213 }210214 if !reflect.DeepEqual(got.Notes, wantNotes) {@@ -82,7 +82,7 @@ func (bk *xlsxBook) loadRules(s *sheet.Sheet, r *xlsxSheetReader) {
8282 bk.skips.skip(false, "that 012 can't check")8383 }8484 for range r.extRules {85- bk.skips.skip(true, "in Excel 2010's extension (data bars, icon sets)")85+ bk.skips.skip(true, "in Excel 2010's extension (its own icon sets and the like)")8686 }8787 }8888 @@ -109,9 +109,7 @@ var cfOps = func() map[string]sheet.RuleOp {
109109 }()110110 111111 // unsupported names the conditional formats 012 has no rule for.112-var unsupported = map[string]string{"dataBar": "data bars", "iconSet": "icon sets", "top10": "top or bottom values",113- "aboveAverage": "above or below average", "duplicateValues": "duplicate values", "uniqueValues": "unique values",114- "containsErrors": "errors", "notContainsErrors": "errors"}112+var unsupported = map[string]string{"containsErrors": "errors", "notContainsErrors": "errors"}115113 116114 // condFormat is an Excel rule as 012's, or why it was left out.117115 func (bk *xlsxBook) condFormat(c xlsxCF) (sheet.CondFormat, string) {@@ -141,10 +139,17 @@ func (bk *xlsxBook) condFormat(c xlsxCF) (sheet.CondFormat, string) {
141139 case "notContainsBlanks":142140 f.Op = sheet.RuleNotEmpty143141 case "timePeriod":144- if c.period != "today" && c.period != "tomorrow" && c.period != "yesterday" {145- return f, "dates in periods other than today, tomorrow and yesterday"142+ word, ok := periodWord(c.period)143+ if !ok {144+ return f, "dates in periods 012 doesn't know"146145 }147- f.Op, f.Args[0] = sheet.RuleDateIs, c.period146+ f.Op, f.Args[0] = sheet.RuleDateIs, word147+ case "top10", "aboveAverage", "duplicateValues", "uniqueValues":148+ return rankRuleOf(f, c)149+ case "dataBar":150+ return barRule(f, c)151+ case "iconSet":152+ return iconRule(f, c)148153 case "expression":149154 if op, day, ok := dateExpression(first()); ok {150155 f.Op, f.Args[0] = op, day@@ -254,6 +259,9 @@ func validationOf(d xlsxDV) (sheet.Validation, string) {
254259 v := sheet.Validation{Ranges: rs, Reject: d.showErr && d.style == "stop", Help: cmp.Or(d.prompt, d.err)}255260 switch k, ok := validKinds[d.typ]; {256261 case d.typ == "list":262+ if d.hideArrow {263+ v.Display = sheet.DropPlain264+ }257265 return listRule(v, d.formulas[0])258266 case d.typ == "none" || d.typ == "":259267 return v, "any"@@ -0,0 +1,280 @@
1+package fileio2+3+import (4+ "fmt"5+ "strconv"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// The rules Excel 2007 has a type of its own for, both ways:12+//13+// 012 Excel14+// top, bottom (percent) values top10 with rank, percent and bottom15+// above, below average aboveAverage (aboveAverage="0" for below)16+// duplicate, unique values duplicateValues, uniqueValues17+// date is in a period timePeriod where Excel has the period18+// (weeks, months, the past week), else an19+// expression on INT(cell)20+// data bar dataBar: two cfvo and a color21+// icon set iconSet: 3Arrows, 5Quarters, 4Rating ...22+//23+// Coming in, traffic lights, signs and quarters read as circles, flags24+// and the other symbols as symbols, and gray arrows as arrows. Rules25+// only Excel 2010's extension holds (its own icon sets, data bars'26+// extra settings) are left out, counted in the import's note, but for27+// the extension's copy of a data bar the main part also holds.28+29+// rankRule writes a top, bottom, average or duplicates rule.30+func rankRule(head string, op sheet.RuleOp, arg string) string {31+ switch op {32+ case sheet.RuleAboveAverage:33+ return fmt.Sprintf(head, "aboveAverage") + "/>"34+ case sheet.RuleBelowAverage:35+ return fmt.Sprintf(head, "aboveAverage") + ` aboveAverage="0"/>`36+ case sheet.RuleDuplicate:37+ return fmt.Sprintf(head, "duplicateValues") + "/>"38+ case sheet.RuleUnique:39+ return fmt.Sprintf(head, "uniqueValues") + "/>"40+ }41+ attrs := fmt.Sprintf(` rank="%s"`, strings.TrimSpace(arg))42+ if op == sheet.RuleTopPercent || op == sheet.RuleBottomPercent {43+ attrs += ` percent="1"`44+ }45+ if op == sheet.RuleBottom || op == sheet.RuleBottomPercent {46+ attrs += ` bottom="1"`47+ }48+ return fmt.Sprintf(head, "top10") + attrs + "/>"49+}50+51+// excelPeriods are the periods Excel's timePeriod names, with 012's52+// words for them.53+var excelPeriods = map[string]string{"today": "today", "yesterday": "yesterday", "tomorrow": "tomorrow",54+ "past week": "last7Days", "this week": "thisWeek", "last week": "lastWeek", "next week": "nextWeek",55+ "this month": "thisMonth", "last month": "lastMonth", "next month": "nextMonth"}56+57+// periodBounds are Excel formulas for the first and last days of a58+// period, and false for a word that isn't one.59+func periodBounds(word string) (lo, hi string, ok bool) {60+ word = strings.Join(strings.Fields(strings.ToLower(word)), " ")61+ word = strings.TrimPrefix(strings.TrimPrefix(word, "in the "), "the ")62+ switch word {63+ case "today":64+ return "TODAY()", "TODAY()", true65+ case "tomorrow":66+ return "TODAY()+1", "TODAY()+1", true67+ case "yesterday":68+ return "TODAY()-1", "TODAY()-1", true69+ case "past week", "last 7 days":70+ return "TODAY()-6", "TODAY()", true71+ case "past month":72+ return "EDATE(TODAY(),-1)", "TODAY()", true73+ case "past year":74+ return "EDATE(TODAY(),-12)", "TODAY()", true75+ }76+ which, unit, _ := strings.Cut(word, " ")77+ step, known := map[string]int{"this": 0, "last": -1, "next": 1}[which]78+ if !known {79+ return "", "", false80+ }81+ k := func(n int) string {82+ if n == 0 {83+ return ""84+ }85+ return fmt.Sprintf("%+d", n)86+ }87+ switch unit {88+ case "week":89+ lo = "TODAY()-WEEKDAY(TODAY())+1" + k(7*step)90+ return lo, lo + "+6", true91+ case "month":92+ return "DATE(YEAR(TODAY()),MONTH(TODAY())" + k(step) + ",1)", "DATE(YEAR(TODAY()),MONTH(TODAY())" + k(step+1) + ",0)", true93+ case "year":94+ return "DATE(YEAR(TODAY())" + k(step) + ",1,1)", "DATE(YEAR(TODAY())" + k(step) + ",12,31)", true95+ }96+ return "", "", false97+}98+99+// periodRule writes a date rule on a period: a timePeriod where Excel100+// has one for "Date is", else an expression.101+func periodRule(head, cell string, op sheet.RuleOp, word string) (string, bool) {102+ lo, hi, ok := periodBounds(word)103+ if !ok {104+ return "", false105+ }106+ day := "INT(" + cell + ")"107+ var fx string108+ switch op {109+ case sheet.RuleDateBefore:110+ fx = day + "<" + lo111+ case sheet.RuleDateAfter:112+ fx = day + ">" + hi113+ default:114+ fx = "AND(" + day + ">=" + lo + "," + day + "<=" + hi + ")"115+ if p := excelPeriods[strings.Join(strings.Fields(strings.ToLower(word)), " ")]; p != "" {116+ return fmt.Sprintf(head, "timePeriod") + fmt.Sprintf(` timePeriod="%s"><formula>%s</formula></cfRule>`, p, escapeXML(fx, false)), true117+ }118+ }119+ return fmt.Sprintf(head, "expression") + "><formula>" + escapeXML(fx, false) + "</formula></cfRule>", true120+}121+122+// cfvoXML writes a point of a data bar or icon set.123+func cfvoXML(p sheet.ScalePoint) string {124+ s := fmt.Sprintf(`<cfvo type="%s"`, p.Kind)125+ if p.Kind.TakesValue() {126+ s += fmt.Sprintf(` val="%s"`, escapeXML(strings.TrimSpace(p.Value), true))127+ }128+ return s + "/>"129+}130+131+// dataBarXML writes a data bar of priority p.132+func dataBarXML(f sheet.CondFormat, p int) string {133+ var b strings.Builder134+ fmt.Fprintf(&b, `<cfRule type="dataBar" priority="%d"><dataBar`, p)135+ if f.BarOnly {136+ b.WriteString(` showValue="0"`)137+ }138+ b.WriteString(">")139+ for _, pt := range f.Scale {140+ b.WriteString(cfvoXML(pt))141+ }142+ fmt.Fprintf(&b, `<color rgb="%s"/></dataBar></cfRule>`, ruleRGB[f.Bar][2])143+ return b.String()144+}145+146+// excelIconSets are Excel's icon sets for 012's, by size.147+var excelIconSets = map[sheet.IconSet]map[int]string{148+ sheet.IconsArrows: {3: "3Arrows", 4: "4Arrows", 5: "5Arrows"},149+ sheet.IconsCircles: {3: "3TrafficLights1", 4: "4TrafficLights", 5: "5Quarters"},150+ sheet.IconsSymbols: {3: "3Symbols"},151+ sheet.IconsRating: {4: "4Rating", 5: "5Rating"},152+}153+154+// iconSetOf is 012's set for an Excel icon set, by its name.155+func iconSetOf(name string) (sheet.IconSet, int, bool) {156+ if name == "" {157+ name = "3TrafficLights1" // Excel's default158+ }159+ n, err := strconv.Atoi(name[:1])160+ if err != nil {161+ return 0, 0, false162+ }163+ rest := name[1:]164+ switch {165+ case strings.HasPrefix(rest, "Arrows"):166+ return sheet.IconsArrows, n, true167+ case strings.HasPrefix(rest, "TrafficLights"), rest == "Signs", rest == "Quarters", rest == "RedToBlack":168+ return sheet.IconsCircles, n, true169+ case strings.HasPrefix(rest, "Symbols"), rest == "Flags":170+ return sheet.IconsSymbols, n, n == 3171+ case rest == "Rating":172+ return sheet.IconsRating, n, true173+ }174+ return 0, 0, false175+}176+177+// iconSetXML writes an icon set of priority p: a first point at the178+// lowest value, then the rule's thresholds.179+func iconSetXML(f sheet.CondFormat, p int) (string, bool) {180+ name := excelIconSets[f.Icons][len(f.Scale)+1]181+ if name == "" {182+ return "", false183+ }184+ var b strings.Builder185+ fmt.Fprintf(&b, `<cfRule type="iconSet" priority="%d"><iconSet iconSet="%s"`, p, name)186+ if f.Reverse {187+ b.WriteString(` reverse="1"`)188+ }189+ if f.BarOnly {190+ b.WriteString(` showValue="0"`)191+ }192+ b.WriteString(`><cfvo type="percent" val="0"/>`)193+ for _, pt := range f.Scale {194+ b.WriteString(cfvoXML(pt))195+ }196+ return b.String() + "</iconSet></cfRule>", true197+}198+199+// pointOf reads a data bar's or icon set's cfvo.200+func pointOf(v [2]string) (sheet.ScalePoint, bool) {201+ kind, ok := sheet.ParsePointKind(map[string]string{"min": "min", "max": "max", "num": "num",202+ "percent": "percent", "percentile": "percentile", "autoMin": "min", "autoMax": "max"}[v[0]])203+ return sheet.ScalePoint{Kind: kind, Value: v[1]}, ok204+}205+206+// barRule reads a data bar.207+func barRule(f sheet.CondFormat, c xlsxCF) (sheet.CondFormat, string) {208+ if len(c.cfvo) != 2 || len(c.colors) == 0 {209+ return f, "data bars 012 can't read"210+ }211+ for _, v := range c.cfvo {212+ p, ok := pointOf(v)213+ if !ok {214+ return f, "data bars set by formulas"215+ }216+ f.Scale = append(f.Scale, p)217+ }218+ f.Bar, f.BarOnly, f.Style = c.colors[0].scaleColor(), c.hideValue, sheet.RuleStyle{}219+ return f, ""220+}221+222+// iconRule reads an icon set; its first point is the lowest value's.223+func iconRule(f sheet.CondFormat, c xlsxCF) (sheet.CondFormat, string) {224+ set, n, ok := iconSetOf(c.iconSet)225+ if !ok || n != len(c.cfvo) || !contains(set.Sizes(), n) {226+ return f, "icon sets 012 has no icons for"227+ }228+ for _, v := range c.cfvo[1:] {229+ p, ok := pointOf(v)230+ if !ok {231+ return f, "icon sets set by formulas"232+ }233+ f.Scale = append(f.Scale, p)234+ }235+ f.Icons, f.Reverse, f.BarOnly, f.Style = set, c.reverse, c.hideValue, sheet.RuleStyle{}236+ return f, ""237+}238+239+func contains(ns []int, n int) bool {240+ for _, m := range ns {241+ if m == n {242+ return true243+ }244+ }245+ return false246+}247+248+// rankRuleOf reads a top10, aboveAverage, duplicateValues or249+// uniqueValues rule.250+func rankRuleOf(f sheet.CondFormat, c xlsxCF) (sheet.CondFormat, string) {251+ switch c.typ {252+ case "duplicateValues":253+ f.Op = sheet.RuleDuplicate254+ case "uniqueValues":255+ f.Op = sheet.RuleUnique256+ case "aboveAverage":257+ if c.stdDev != 0 || c.equalAverage {258+ return f, "above or below average by deviations, or equal to it"259+ }260+ f.Op = sheet.RuleAboveAverage261+ if !c.aboveAverage {262+ f.Op = sheet.RuleBelowAverage263+ }264+ default: // top10265+ f.Op = map[[2]bool]sheet.RuleOp{{false, false}: sheet.RuleTop, {false, true}: sheet.RuleTopPercent,266+ {true, false}: sheet.RuleBottom, {true, true}: sheet.RuleBottomPercent}[[2]bool{c.bottom, c.percent}]267+ f.Args[0] = strconv.Itoa(c.rank)268+ }269+ return f, ""270+}271+272+// periodOf reads a timePeriod as 012's word for it.273+func periodWord(period string) (string, bool) {274+ for word, p := range excelPeriods {275+ if p == period {276+ return word, true277+ }278+ }279+ return "", false280+}@@ -0,0 +1,94 @@
1+package fileio2+3+import (4+ "reflect"5+ "strings"6+ "testing"7+8+ "github.com/xuri/excelize/v2"9+)10+11+// Top and bottom values, averages, duplicates, periods Excel names,12+// data bars and icon sets go out as Excel's own rules and come back the13+// same; periods Excel has no name for, and date rules before or after a14+// period, come back as custom formulas that test the same days.15+func TestXLSXNewRulesRoundTrip(t *testing.T) {16+ src := months(t)17+ formats := []string{18+ `{"ranges":"B2:B5","condition":"top","values":["2"],"fill":"green"}`,19+ `{"ranges":"B2:B5","condition":"bottom_percent","values":["25"],"fill":"red"}`,20+ `{"ranges":"B2:B5","condition":"top_percent","values":["10"],"bold":true}`,21+ `{"ranges":"B2:B5","condition":"bottom","values":["1"],"italic":true}`,22+ `{"ranges":"B2:B5","condition":"above_average","text":"blue"}`,23+ `{"ranges":"B2:B5","condition":"below_average","text":"red"}`,24+ `{"ranges":"C2:C5","condition":"duplicate","fill":"yellow"}`,25+ `{"ranges":"C2:C5","condition":"unique","underline":true}`,26+ `{"ranges":"A2:A5","condition":"date_is","values":["this week"],"fill":"cyan"}`,27+ `{"ranges":"A2:A5","condition":"date_is","values":["past week"],"fill":"cyan"}`,28+ `{"ranges":"A2:A5","condition":"date_is","values":["next month"],"fill":"cyan"}`,29+ `{"ranges":"B2:B5","dataBar":{"color":"blue","min":{"type":"min"},"max":{"type":"max"}}}`,30+ `{"ranges":"B2:B5","dataBar":{"color":"green","min":{"type":"num","value":"0"},"max":{"type":"percentile","value":"90"},"barOnly":true}}`,31+ `{"ranges":"B2:B5","iconSet":{"icons":"arrows","points":[{"type":"percent","value":"33"},{"type":"percent","value":"67"}]}}`,32+ `{"ranges":"B2:B5","iconSet":{"icons":"arrows","points":[{"type":"percent","value":"25"},{"type":"percent","value":"50"},{"type":"percent","value":"75"}],"reverse":true}}`,33+ `{"ranges":"B2:B5","iconSet":{"icons":"circles","points":[{"type":"percent","value":"20"},{"type":"percent","value":"40"},{"type":"percent","value":"60"},{"type":"percent","value":"80"}],"iconOnly":true}}`,34+ `{"ranges":"B2:B5","iconSet":{"icons":"symbols","points":[{"type":"num","value":"100"},{"type":"num","value":"1000"}]}}`,35+ `{"ranges":"B2:B5","iconSet":{"icons":"rating","points":[{"type":"percentile","value":"25"},{"type":"percentile","value":"50"},{"type":"percentile","value":"75"}]}}`,36+ }37+ mustLoad(t, src, formats, nil)38+ formats = formatLines(src)39+ name, got := exportImport(t, src)40+ if len(got.Notes) > 0 {41+ t.Errorf("notes %q", got.Notes)42+ }43+ if f := formatLines(got.Sheet.Book().Sheet(0)); !reflect.DeepEqual(f, formats) {44+ t.Errorf("conditional formats:\n%s\nwant\n%s", strings.Join(f, "\n"), strings.Join(formats, "\n"))45+ }46+ x, err := excelize.OpenFile(name)47+ if err != nil {48+ t.Fatal(err)49+ }50+ defer x.Close()51+ if _, err := x.GetConditionalFormats("Sheet1"); err != nil {52+ t.Errorf("excelize: %v", err)53+ }54+55+ // Periods Excel has no name for.56+ other := months(t)57+ mustLoad(t, other, []string{58+ `{"ranges":"A2:A5","condition":"date_is","values":["last year"],"fill":"cyan"}`,59+ `{"ranges":"A2:A5","condition":"date_before","values":["this month"],"fill":"cyan"}`,60+ }, nil)61+ _, got = exportImport(t, other)62+ want := []string{63+ "=AND(INT(A2)>=DATE(YEAR(TODAY())-1,1,1),INT(A2)<=DATE(YEAR(TODAY())-1,12,31))",64+ "=INT(A2)<DATE(YEAR(TODAY()),MONTH(TODAY()),1)",65+ }66+ var args []string67+ for _, f := range got.Sheet.CondFormats() {68+ args = append(args, f.Op.String()+" "+f.Args[0])69+ }70+ if !reflect.DeepEqual(args, []string{"formula " + want[0], "formula " + want[1]}) {71+ t.Errorf("periods as formulas:\n%s\nwant\n%s", strings.Join(args, "\n"), strings.Join(want, "\n"))72+ }73+}74+75+// A dropdown shown as plain text goes out with Excel's arrow hidden; a76+// checkbox of its own values goes out as a list of them, which comes77+// back as a dropdown, as Excel has no such checkbox.78+func TestXLSXDropdownDisplayAndCheckboxValues(t *testing.T) {79+ src := months(t)80+ mustLoad(t, src, nil, []string{81+ `{"ranges":"D2:D5","criteria":"list","items":["a","b"],"display":"plain"}`,82+ `{"ranges":"E2:E5","criteria":"list","items":["a","b"],"display":"chip"}`,83+ `{"ranges":"F2:F5","criteria":"checkbox","items":["Yes","No"]}`,84+ })85+ _, got := exportImport(t, src)86+ want := []string{87+ `{"ranges":"D2:D5","criteria":"list","items":["a","b"],"display":"plain"}`,88+ `{"ranges":"E2:E5","criteria":"list","items":["a","b"]}`,89+ `{"ranges":"F2:F5","criteria":"list","items":["Yes","No"]}`,90+ }91+ if v := validationLines(got.Sheet); !reflect.DeepEqual(v, want) {92+ t.Errorf("validations:\n%s\nwant\n%s", strings.Join(v, "\n"), strings.Join(want, "\n"))93+ }94+}@@ -177,12 +177,19 @@ type xlsxCF struct {
177177 formulas []string178178 cfvo [][2]string // type and val179179 colors []xlsxColor180+ // top10's, aboveAverage's, dataBar's and iconSet's settings; see181+ // xlsxrulesmore.go.182+ rank, stdDev int183+ percent, bottom bool184+ aboveAverage, equalAverage bool185+ iconSet string186+ reverse, hideValue bool180187 }181188 182189 // xlsxDV is a <dataValidation>, of the main part or the extension.183190 type xlsxDV struct {184191 typ, op, style, prompt, err, sqref string185- showErr bool192+ showErr, hideArrow bool186193 formulas [2]string187194 }188195 @@ -201,12 +208,34 @@ func (r *xlsxSheetReader) readRuleElement(se xml.StartElement) (bool, error) {
201208 case se.Name.Local == "dataValidation":202209 return true, r.readValidation(se)203210 case se.Name.Local == "conditionalFormatting":204- r.extRules++ // Excel 2010's data bars, icon sets and the like205- return true, r.x.skip()211+ return true, r.readExtRules()206212 }207213 return false, nil208214 }209215 216+// readExtRules counts the rules of a <conditionalFormatting> of Excel217+// 2010's extension, which 012 leaves out: its own icon sets and the218+// like. A data bar there is the extension's copy of one the main part219+// holds too, with settings 012 doesn't draw, so it isn't counted.220+func (r *xlsxSheetReader) readExtRules() error {221+ for depth := r.x.depth; ; {222+ t, err := r.x.next()223+ if err != nil {224+ return eofAsUnexpected(err)225+ }226+ switch t := t.(type) {227+ case xml.EndElement:228+ if r.x.depth < depth {229+ return nil230+ }231+ case xml.StartElement:232+ if t.Name.Local == "cfRule" && attrOr(t, "type", "") != "dataBar" {233+ r.extRules++234+ }235+ }236+ }237+}238+210239 func (r *xlsxSheetReader) readCondFormatting(se xml.StartElement) error {211240 ref, _ := attr(se, "sqref")212241 var cur *xlsxCF@@ -224,7 +253,9 @@ func (r *xlsxSheetReader) readCondFormatting(se xml.StartElement) error {
224253 if t.Name.Local == "cfRule" && len(r.cfs) < maxRules {225254 r.cfs = append(r.cfs, xlsxCF{sqref: ref, typ: attrOr(t, "type", ""), op: attrOr(t, "operator", ""),226255 text: attrOr(t, "text", ""), period: attrOr(t, "timePeriod", ""), dxf: intAttr(t, "dxfId", -1),227- priority: intAttr(t, "priority", len(r.cfs)+1)})256+ priority: intAttr(t, "priority", len(r.cfs)+1), rank: intAttr(t, "rank", 10), stdDev: intAttr(t, "stdDev", 0),257+ percent: boolAttr(t, "percent", false), bottom: boolAttr(t, "bottom", false),258+ aboveAverage: boolAttr(t, "aboveAverage", true), equalAverage: boolAttr(t, "equalAverage", false)})228259 cur = &r.cfs[len(r.cfs)-1]229260 } else if err := r.cfPart(t, cur); err != nil {230261 return err@@ -233,10 +264,19 @@ func (r *xlsxSheetReader) readCondFormatting(se xml.StartElement) error {
233264 }234265 }235266 236-// cfPart reads an element inside a <cfRule>: a formula, or a color237-// scale's point or color. Each rule keeps at most three of each.267+// cfPart reads an element inside a <cfRule>: a formula, a color scale's,268+// data bar's or icon set's point or color, or the bar or set itself.269+// Each rule keeps at most three formulas and colors and five points.238270 func (r *xlsxSheetReader) cfPart(t xml.StartElement, cur *xlsxCF) error {239271 switch t.Name.Local {272+ case "dataBar":273+ if cur != nil {274+ cur.hideValue = !boolAttr(t, "showValue", true)275+ }276+ case "iconSet":277+ if cur != nil {278+ cur.iconSet, cur.reverse, cur.hideValue = attrOr(t, "iconSet", ""), boolAttr(t, "reverse", false), !boolAttr(t, "showValue", true)279+ }240280 case "formula":241281 var err error242282 if r.buf, err = r.x.text(r.buf[:0]); err != nil {@@ -246,7 +286,7 @@ func (r *xlsxSheetReader) cfPart(t xml.StartElement, cur *xlsxCF) error {
246286 cur.formulas = append(cur.formulas, string(r.buf))247287 }248288 case "cfvo":249- if cur != nil && len(cur.cfvo) < 3 {289+ if cur != nil && len(cur.cfvo) < 5 {250290 cur.cfvo = append(cur.cfvo, [2]string{attrOr(t, "type", ""), attrOr(t, "val", "")})251291 }252292 case "color":@@ -260,7 +300,7 @@ func (r *xlsxSheetReader) cfPart(t xml.StartElement, cur *xlsxCF) error {
260300 func (r *xlsxSheetReader) readValidation(se xml.StartElement) error {261301 dv := xlsxDV{typ: attrOr(se, "type", "none"), op: attrOr(se, "operator", "between"), style: attrOr(se, "errorStyle", "stop"),262302 prompt: attrOr(se, "prompt", ""), err: attrOr(se, "error", ""), sqref: attrOr(se, "sqref", ""),263- showErr: boolAttr(se, "showErrorMessage", false)}303+ showErr: boolAttr(se, "showErrorMessage", false), hideArrow: boolAttr(se, "showDropDown", false)}264304 for depth := r.x.depth; ; {265305 t, err := r.x.next()266306 if err != nil {@@ -0,0 +1,234 @@
1+package sheet2+3+import (4+ "errors"5+ "math"6+ "slices"7+ "strings"8+)9+10+// Data bars and icon sets, as Excel's conditional formatting draws them:11+// a bar across each number's cell as long as the number is far along12+// from the rule's shortest point to its longest, or an icon by which of13+// its thresholds the number reaches. Both keep their points in the14+// rule's Scale, as a color scale does: a bar its shortest and longest,15+// an icon set the threshold of each icon after the first.16+17+// IconSet is a set of icons, drawn in a terminal's own glyphs so they18+// read without color: arrows, circles filling up, symbols and bars19+// rising. Each set comes in the sizes Excel has it in.20+type IconSet uint821+22+const (23+ IconsNone IconSet = iota24+ IconsArrows25+ IconsCircles26+ IconsSymbols27+ IconsRating28+ numIconSets29+)30+31+var iconSetNames = [numIconSets]string{"", "arrows", "circles", "symbols", "rating"}32+var iconSetTitles = [numIconSets]string{"None", "Arrows", "Circles", "Symbols", "Rating"}33+34+// iconGlyphs are each set's icons by size, lowest first.35+var iconGlyphs = [numIconSets]map[int][]string{36+ IconsArrows: {3: {"↓", "→", "↑"}, 4: {"↓", "↘", "↗", "↑"}, 5: {"↓", "↘", "→", "↗", "↑"}},37+ IconsCircles: {3: {"○", "◑", "●"}, 4: {"○", "◔", "◕", "●"}, 5: {"○", "◔", "◑", "◕", "●"}},38+ IconsSymbols: {3: {"✗", "!", "✓"}},39+ IconsRating: {4: {"▂", "▄", "▆", "█"}, 5: {"▁", "▂", "▄", "▆", "█"}},40+}41+42+func (i IconSet) String() string {43+ if i >= numIconSets {44+ return ""45+ }46+ return iconSetNames[i]47+}48+49+// Title names the set for people, e.g. "Arrows".50+func (i IconSet) Title() string {51+ if i >= numIconSets {52+ return ""53+ }54+ return iconSetTitles[i]55+}56+57+// ParseIconSet is the inverse of IconSet.String.58+func ParseIconSet(s string) (IconSet, bool) {59+ i := slices.Index(iconSetNames[:], s)60+ return IconSet(max(i, 0)), i >= 061+}62+63+// IconSets lists the sets in the order the rules editor offers them.64+func IconSets() []IconSet { return []IconSet{IconsArrows, IconsCircles, IconsSymbols, IconsRating} }65+66+// Sizes are how many icons the set comes with.67+func (i IconSet) Sizes() []int {68+ if i == IconsNone || i >= numIconSets {69+ return nil70+ }71+ var out []int72+ for n := 3; n <= 5; n++ {73+ if _, ok := iconGlyphs[i][n]; ok {74+ out = append(out, n)75+ }76+ }77+ return out78+}79+80+// Glyphs are the set's n icons, lowest first.81+func (i IconSet) Glyphs(n int) []string {82+ if i >= numIconSets {83+ return nil84+ }85+ return iconGlyphs[i][n]86+}87+88+// IconColor is the color icon k of n draws in: the lowest red, the89+// highest green and those between yellow, as Excel colors arrows and90+// symbols, or blue for every bar of a rating.91+func (i IconSet) IconColor(k, n int) Color {92+ switch {93+ case i == IconsRating:94+ return ColorBlue95+ case k == 0:96+ return ColorRed97+ case k == n-1:98+ return ColorGreen99+ }100+ return ColorYellow101+}102+103+// IsBar reports whether the rule is a data bar.104+func (f CondFormat) IsBar() bool { return f.Bar != ColorNone }105+106+// IsIcons reports whether the rule is an icon set.107+func (f CondFormat) IsIcons() bool { return f.Icons != IconsNone }108+109+// checkBar checks a data bar: its two points and color.110+func checkBar(f CondFormat) error {111+ if len(f.Scale) != 2 {112+ return errors.New("A data bar has a shortest and a longest point")113+ }114+ if f.Bar >= numColors {115+ return errors.New("Choose the bar's color")116+ }117+ return checkPoints(f.Scale)118+}119+120+// checkIcons checks an icon set: a size it comes in, and a threshold121+// for each icon after the first, rising.122+func checkIcons(f CondFormat) error {123+ n := len(f.Scale) + 1124+ if !slices.Contains(f.Icons.Sizes(), n) {125+ return errors.New("The " + strings.ToLower(f.Icons.Title()) + " come in " + sizesText(f.Icons.Sizes()))126+ }127+ return checkPoints(f.Scale)128+}129+130+func sizesText(ns []int) string {131+ parts := make([]string, len(ns))132+ for i, n := range ns {133+ parts[i] = string(rune('0' + n))134+ }135+ return strings.Join(parts, " or ") + " icons"136+}137+138+// checkPoints checks points that take values: a number, or a percent or139+// percentile from 0 to 100.140+func checkPoints(ps []ScalePoint) error {141+ for _, p := range ps {142+ if p.Kind >= numPointKinds {143+ return errors.New("Unknown kind of point")144+ }145+ if !p.Kind.TakesValue() {146+ continue147+ }148+ n, _, ok := ParseValue(strings.TrimSpace(p.Value))149+ switch {150+ case !ok:151+ return errors.New("Enter a number for the " + strings.ToLower(p.Kind.Title()))152+ case p.Kind != PointNumber && (n < 0 || n > 100):153+ return errors.New(p.Kind.Title() + " goes from 0 to 100")154+ }155+ }156+ return nil157+}158+159+// barStats is a data bar's or icon set's points among the values of its160+// ranges.161+type barStats struct {162+ ok bool163+ at []float64164+}165+166+func (c *looksCache) bar(s *Sheet, i int) *barStats {167+ if st, ok := c.bars[i]; ok {168+ return st169+ }170+ f := s.rules.formats[i]171+ keep := slices.ContainsFunc(f.Scale, func(p ScalePoint) bool { return p.Kind == PointPercentile })172+ lo, hi, n := math.Inf(1), math.Inf(-1), 0173+ var vals []float64174+ for _, r := range f.Ranges {175+ for _, v := range s.cells.valuesIn(r) {176+ if v.Kind != Number {177+ continue178+ }179+ lo, hi, n = min(lo, v.Num), max(hi, v.Num), n+1180+ if keep {181+ vals = append(vals, v.Num)182+ }183+ }184+ }185+ st := &barStats{ok: n > 0}186+ if st.ok {187+ for k, p := range f.Scale {188+ at := p.at(vals, lo, hi)189+ // A bar's automatic shortest starts at zero for positive190+ // numbers, as Excel's does, so a bar's length reads as the191+ // number's size.192+ if f.IsBar() && k == 0 && p.Kind == PointMin {193+ at = min(lo, 0)194+ }195+ if f.IsBar() && k == 1 && p.Kind == PointMax {196+ at = max(hi, 0)197+ }198+ st.at = append(st.at, at)199+ }200+ }201+ c.bars[i] = st202+ return st203+}204+205+// barLook is how far along a data bar's cell of value v is drawn, from206+// 0 to 1, and whether it has a bar.207+func (st *barStats) barLook(v Value) (float64, bool) {208+ if !st.ok || v.Kind != Number || len(st.at) != 2 {209+ return 0, false210+ }211+ lo, hi := st.at[0], st.at[1]212+ if hi <= lo {213+ return 1, true214+ }215+ return min(max((v.Num-lo)/(hi-lo), 0), 1), true216+}217+218+// icon is which of n icons the value v gets: the last threshold it219+// reaches, reversed when the set is.220+func (st *barStats) icon(v Value, reverse bool) (int, bool) {221+ if !st.ok || v.Kind != Number {222+ return 0, false223+ }224+ k := 0225+ for i, at := range st.at {226+ if v.Num >= at {227+ k = i + 1228+ }229+ }230+ if reverse {231+ k = len(st.at) - k232+ }233+ return k, true234+}@@ -5,9 +5,6 @@ import (
55 "fmt"66 "slices"77 "strings"8-9- "github.com/FelineStateMachine/012/internal/numfmt"10- "github.com/FelineStateMachine/012/internal/value"118 )129 1310 // Conditional formats follow Sheets' Format > Conditional formatting: a@@ -28,8 +25,20 @@ type CondFormat struct {
2825 Args [2]string2926 Style RuleStyle3027 // Scale, when set, makes the rule a color scale of 2 or 3 points,31- // lowest first, and Op, Args and Style mean nothing.28+ // lowest first, and Op, Args and Style mean nothing. A data bar's29+ // and an icon set's points are kept here too (condbars.go).3230 Scale []ScalePoint31+ // Bar, when set, makes the rule a data bar in that color, from its32+ // first point to its second.33+ Bar Color34+ // Icons, when set, makes the rule an icon set, one icon more than35+ // its points, which are the thresholds of the icons after the first.36+ Icons IconSet37+ // Reverse gives the icons the other way round, the lowest icon to38+ // the highest values.39+ Reverse bool40+ // BarOnly shows a data bar or icon without the cell's value.41+ BarOnly bool3342 }3443 3544 // ScalePoint is a point of a color scale: where it is among the values,@@ -85,12 +94,20 @@ func PointKinds() []PointKind {
8594 }8695 8796 // IsScale reports whether the rule is a color scale.88-func (f CondFormat) IsScale() bool { return len(f.Scale) > 0 }97+func (f CondFormat) IsScale() bool { return len(f.Scale) > 0 && !f.IsBar() && !f.IsIcons() }98+8999 90100 // Summary describes the rule in a few words, e.g. "Greater than 100".91101 func (f CondFormat) Summary() string {92- if f.IsScale() {102+ switch {103+ case f.IsBar():104+ return "Data bar"105+ case f.IsIcons():106+ return "Icon set: " + strings.ToLower(f.Icons.Title())107+ case f.IsScale():93108 return "Color scale"109+ case f.Op.Ranks():110+ return rankSummary(f.Op, f.Args[0])94111 }95112 switch f.Op.Args() {96113 case 1:@@ -112,7 +129,12 @@ func (f CondFormat) Check() error {
112129 if len(f.Ranges) == 0 {113130 return errors.New("Enter the range the rule applies to, e.g. A2:A100")114131 }115- if f.IsScale() {132+ switch {133+ case f.IsBar():134+ return checkBar(f)135+ case f.IsIcons():136+ return checkIcons(f)137+ case f.IsScale():116138 return checkScale(f.Scale)117139 }118140 if !slices.Contains(CondFormatOps(), f.Op) {@@ -141,9 +163,11 @@ func checkArg(op RuleOp, arg string) error {
141163 return fmt.Errorf("Formula: %w", err)142164 }143165 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")166+ if _, _, ok := datePeriod(arg); !ok {167+ return errors.New("Enter a date, e.g. 2026-09-30, or a period: today, past week, this month, last year ...")146168 }169+ case op.Ranks():170+ return checkRank(op, arg)147171 case op == RuleBetween || op == RuleNotBetween:148172 if _, _, ok := ParseValue(arg); !ok {149173 return errors.New("Enter a number")@@ -179,22 +203,6 @@ func checkScale(ps []ScalePoint) error {
179203 return nil180204 }181205 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-198206 // CondFormats returns the sheet's conditional format rules, in the order199207 // they're tried.200208 func (s *Sheet) CondFormats() []CondFormat {@@ -0,0 +1,222 @@
1+package sheet2+3+import (4+ "math"5+ "strings"6+ "testing"7+)8+9+// matching lists the cells of A1:A<n> a sheet's rules style.10+func matching(s *Sheet, n int) string {11+ var got []string12+ for row := range n {13+ if s.Look(Addr{Row: row}).Styled {14+ got = append(got, Addr{Row: row}.String())15+ }16+ }17+ return strings.Join(got, " ")18+}19+20+// Top and bottom values, above and below average, duplicates and21+// uniques compare a cell with the rest of the rule's cells.22+func TestRankingRules(t *testing.T) {23+ s := sheetOf(t, map[string]string{24+ "A1": "10", "A2": "40", "A3": "30", "A4": "40", "A5": "Apple", "A6": "apple", "A7": "pear", "A8": "",25+ })26+ for _, c := range []struct {27+ op RuleOp28+ arg string29+ match string30+ }{31+ {RuleTop, "2", "A2 A4"}, // ties count32+ {RuleTop, "3", "A2 A3 A4"},33+ {RuleBottom, "1", "A1"},34+ {RuleTopPercent, "50", "A2 A4"},35+ {RuleBottomPercent, "10", "A1"}, // at least one36+ {RuleTop, "99", "A1 A2 A3 A4"},37+ {RuleAboveAverage, "", "A2 A4"}, // the mean is 3038+ {RuleBelowAverage, "", "A1"},39+ {RuleDuplicate, "", "A2 A4 A5 A6"},40+ {RuleUnique, "", "A1 A3 A7"},41+ } {42+ s.rules = rulesState{}43+ mustAdd(t, s, CondFormat{Ranges: ranges("A1:A8"), Op: c.op, Args: [2]string{c.arg}, Style: green})44+ if got := matching(s, 8); got != c.match {45+ t.Errorf("%s %s: matched %q, want %q", c.op, c.arg, got, c.match)46+ }47+ }48+ for _, bad := range []CondFormat{49+ {Ranges: ranges("A1:A8"), Op: RuleTop, Args: [2]string{"0"}, Style: green},50+ {Ranges: ranges("A1:A8"), Op: RuleTopPercent, Args: [2]string{"101"}, Style: green},51+ {Ranges: ranges("A1:A8"), Op: RuleBottom, Args: [2]string{"x"}, Style: green},52+ } {53+ if bad.Check() == nil {54+ t.Errorf("%s %q passed", bad.Op, bad.Args[0])55+ }56+ }57+ f := CondFormat{Ranges: ranges("A1"), Op: RuleTopPercent, Args: [2]string{"10"}, Style: green}58+ if f.Summary() != "Top 10%" {59+ t.Errorf("summary %q", f.Summary())60+ }61+}62+63+// Date rules take periods: this, last or next week, month or year, and64+// the past week, month or year. 2026-09-26 is a Saturday.65+func TestDatePeriods(t *testing.T) {66+ fixedNow(t)67+ s := sheetOf(t, map[string]string{68+ "A1": "2026-09-19", "A2": "2026-09-20", "A3": "2026-09-26", "A4": "2026-09-27", "A5": "2026-10-15",69+ "A6": "2026-08-31", "A7": "2025-12-31", "A8": "2026-01-01",70+ })71+ for _, c := range []struct {72+ op RuleOp73+ arg string74+ match string75+ }{76+ {RuleDateIs, "this week", "A2 A3"},77+ {RuleDateIs, "last week", "A1"},78+ {RuleDateIs, "next week", "A4"},79+ {RuleDateIs, "this month", "A1 A2 A3 A4"},80+ {RuleDateIs, "Next month", "A5"},81+ {RuleDateIs, "last month", "A6"},82+ {RuleDateIs, "this year", "A1 A2 A3 A4 A5 A6 A8"},83+ {RuleDateIs, "last year", "A7"},84+ {RuleDateIs, "past week", "A2 A3"},85+ {RuleDateIs, "in the past month", "A1 A2 A3 A6"},86+ {RuleDateBefore, "this week", "A1 A6 A7 A8"},87+ {RuleDateAfter, "this month", "A5"},88+ {RuleDateIs, "today", "A3"},89+ } {90+ s.rules = rulesState{}91+ mustAdd(t, s, CondFormat{Ranges: ranges("A1:A8"), Op: c.op, Args: [2]string{c.arg}, Style: green})92+ if got := matching(s, 8); got != c.match {93+ t.Errorf("%s %q: matched %q, want %q", c.op, c.arg, got, c.match)94+ }95+ }96+ if !IsPeriod("last month") || IsPeriod("2026-09-01") || IsPeriod("soon") {97+ t.Error("IsPeriod")98+ }99+ if (CondFormat{Ranges: ranges("A1"), Op: RuleDateIs, Args: [2]string{"soon"}, Style: green}).Check() == nil {100+ t.Error("a period that isn't one passed")101+ }102+}103+104+// A data bar is as long as its number is far from the shortest point,105+// zero for positive numbers, to the longest; an icon set gives each106+// number the icon of the last threshold it reaches.107+func TestDataBarsAndIcons(t *testing.T) {108+ s := sheetOf(t, map[string]string{"A1": "0", "A2": "25", "A3": "50", "A4": "100", "A5": "x"})109+ mustAdd(t, s, CondFormat{Ranges: ranges("A2:A5"), Bar: ColorBlue,110+ Scale: []ScalePoint{{Kind: PointMin}, {Kind: PointMax}}})111+ for cell, want := range map[string]float64{"A2": 0.25, "A3": 0.5, "A4": 1} {112+ l := s.Look(at(cell))113+ if !l.Bar || l.BarColor != ColorBlue || math.Abs(l.BarLen-want) > 1e-9 {114+ t.Errorf("%s: %+v, want a bar %v long", cell, l, want)115+ }116+ }117+ if s.Look(at("A5")).Bar {118+ t.Error("text has a bar")119+ }120+ s.rules = rulesState{}121+ icons := CondFormat{Ranges: ranges("A1:A4"), Icons: IconsArrows,122+ Scale: []ScalePoint{{Kind: PointPercent, Value: "33"}, {Kind: PointPercent, Value: "67"}}}123+ mustAdd(t, s, icons)124+ for cell, want := range map[string]string{"A1": "↓", "A2": "↓", "A3": "→", "A4": "↑"} {125+ if l := s.Look(at(cell)); l.Icon != want {126+ t.Errorf("%s: icon %q, want %q", cell, l.Icon, want)127+ }128+ }129+ if l := s.Look(at("A4")); l.IconColor != ColorGreen || l.ValueHidden {130+ t.Errorf("A4: %+v", l)131+ }132+ icons.Reverse, icons.BarOnly = true, true133+ s.SetCondFormat(0, icons)134+ if l := s.Look(at("A4")); l.Icon != "↓" || l.IconColor != ColorRed || !l.ValueHidden {135+ t.Errorf("reversed A4: %+v", l)136+ }137+ for _, bad := range []CondFormat{138+ {Ranges: ranges("A1"), Icons: IconsSymbols, Scale: []ScalePoint{{Kind: PointPercent, Value: "50"}}},139+ {Ranges: ranges("A1"), Icons: IconsArrows, Scale: []ScalePoint{{Kind: PointPercent, Value: "150"}, {Kind: PointNumber, Value: "2"}}},140+ {Ranges: ranges("A1"), Bar: ColorRed, Scale: []ScalePoint{{Kind: PointMin}}},141+ } {142+ if bad.Check() == nil {143+ t.Errorf("%+v passed", bad)144+ }145+ }146+}147+148+// Data bars and icon sets are lines of the file as the other rules are.149+func TestBarAndIconJSON(t *testing.T) {150+ for _, line := range []string{151+ `{"ranges":"B2:B9","dataBar":{"color":"blue","min":{"type":"num","value":"0"},"max":{"type":"percentile","value":"90"},"barOnly":true}}`,152+ `{"ranges":"C2:C9","iconSet":{"icons":"rating","points":[{"type":"percent","value":"20"},{"type":"percent","value":"40"},{"type":"percent","value":"60"},{"type":"percent","value":"80"}],"reverse":true,"iconOnly":true}}`,153+ `{"ranges":"D2:D9","condition":"top_percent","values":["10"],"fill":"green"}`,154+ `{"ranges":"E2:E9","condition":"date_is","values":["last month"],"bold":true}`,155+ } {156+ f, err := ParseCondFormat(line)157+ if err != nil {158+ t.Errorf("%s: %v", line, err)159+ continue160+ }161+ if got := f.JSON(); got != line {162+ t.Errorf("read and written:\n%s\nwant\n%s", got, line)163+ }164+ }165+ for _, bad := range []string{166+ `{"ranges":"B2","dataBar":{"color":"","min":{"type":"min"},"max":{"type":"max"}}}`,167+ `{"ranges":"B2","iconSet":{"icons":"stars","points":[]}}`,168+ `{"ranges":"B2","iconSet":{"icons":"symbols","points":[{"type":"percent","value":"50"}]}}`,169+ } {170+ if _, err := ParseCondFormat(bad); err == nil {171+ t.Errorf("%s read", bad)172+ }173+ }174+}175+176+// A checkbox may hold values of its own: checked when a cell shows the177+// first, unchecked for the second or a blank, invalid otherwise.178+func TestCustomCheckbox(t *testing.T) {179+ s := sheetOf(t, map[string]string{"A1": "Yes", "A2": "no", "A3": "", "A4": "TRUE", "A5": "maybe"})180+ v := Validation{Ranges: ranges("A1:A5"), Kind: ValidCheckbox, Items: []string{"Yes", "No"}}181+ if err := s.AddValidation(v); err != nil {182+ t.Fatal(err)183+ }184+ for cell, want := range map[string][2]bool{"A1": {true, false}, "A2": {false, false}, "A3": {false, false}, "A4": {false, true}, "A5": {false, true}} {185+ l := s.Look(at(cell))186+ if !l.Checkbox || l.Checked != want[0] || l.Invalid != want[1] {187+ t.Errorf("%s: %+v, want checked %v invalid %v", cell, l, want[0], want[1])188+ }189+ }190+ if s.CheckboxInput(at("A1"), true) != "Yes" || s.CheckboxInput(at("A1"), false) != "No" {191+ t.Error("toggled values")192+ }193+ if bad := s.CheckEntry(at("A3"), "maybe"); bad == nil || bad.Help != "Input must be Yes or No" {194+ t.Errorf("entry check %+v", bad)195+ }196+ for _, bad := range []Validation{197+ {Ranges: ranges("B1"), Kind: ValidCheckbox, Items: []string{"", "No"}},198+ {Ranges: ranges("B1"), Kind: ValidCheckbox, Items: []string{"x", "X"}},199+ {Ranges: ranges("B1"), Kind: ValidCheckbox, Items: []string{"a", "b", "c"}},200+ } {201+ if bad.Check() == nil {202+ t.Errorf("%q passed", bad.Items)203+ }204+ }205+ for _, line := range []string{206+ `{"ranges":"A1:A5","criteria":"checkbox","items":["Yes","No"]}`,207+ `{"ranges":"B1:B5","criteria":"list","items":["a","b"],"display":"chip"}`,208+ `{"ranges":"C1:C5","criteria":"range","source":"A1:A5","display":"plain"}`,209+ } {210+ v, err := ParseValidation(line)211+ if err != nil || v.JSON() != line {212+ t.Errorf("%s read as %s, %v", line, v.JSON(), err)213+ }214+ }215+ if _, err := ParseValidation(`{"ranges":"A1","criteria":"list","items":["a"],"display":"bubble"}`); err == nil {216+ t.Error("an unknown display read")217+ }218+ s.AddValidation(Validation{Ranges: ranges("B1"), Kind: ValidList, Items: []string{"a"}, Display: DropChip})219+ if l := s.Look(at("B1")); !l.Dropdown || l.Display != DropChip {220+ t.Errorf("chip look %+v", l)221+ }222+}@@ -0,0 +1,87 @@
1+package sheet2+3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/012/internal/numfmt"7+ "github.com/FelineStateMachine/012/internal/value"8+)9+10+// Date rules ("Date is", "is before", "is after") take a date or a11+// period as Sheets and Excel name them: today, tomorrow, yesterday, the12+// past week, month or year, and this, last or next week, month or year.13+// Weeks run Sunday to Saturday, as Excel's this week does. A period is14+// a range of days: a date is in it, before its first day or after its15+// last.16+17+// Periods lists the words a date rule takes, in the order the rules18+// editor's hint gives them.19+func Periods() []string {20+ return []string{"today", "tomorrow", "yesterday", "past week", "past month", "past year",21+ "this week", "last week", "next week", "this month", "last month", "next month",22+ "this year", "last year", "next year"}23+}24+25+// datePeriod reads a date rule's value: a date as typed, a number of26+// days, or a period, as the first and last days it covers.27+func datePeriod(arg string) (lo, hi float64, ok bool) {28+ today := float64(int64(numfmt.SerialOf(value.Now())))29+ word := strings.Join(strings.Fields(strings.ToLower(arg)), " ")30+ word = strings.TrimPrefix(strings.TrimPrefix(word, "in the "), "the ")31+ switch word {32+ case "today":33+ return today, today, true34+ case "tomorrow":35+ return today + 1, today + 1, true36+ case "yesterday":37+ return today - 1, today - 1, true38+ case "past week", "last 7 days":39+ return today - 6, today, true40+ case "past month":41+ return monthsBefore(today, 1), today, true42+ case "past year":43+ return monthsBefore(today, 12), today, true44+ }45+ if which, unit, found := strings.Cut(word, " "); found {46+ step, known := map[string]int{"this": 0, "last": -1, "next": 1}[which]47+ if lo, hi, ok := periodOf(today, unit, step); known && ok {48+ return lo, hi, true49+ }50+ }51+ n, _, ok := ParseValue(strings.TrimSpace(arg))52+ n = float64(int64(n))53+ return n, n, ok54+}55+56+// periodOf is the week, month or year step periods away from the one57+// holding day.58+func periodOf(day float64, unit string, step int) (lo, hi float64, ok bool) {59+ y, m, _ := numfmt.Civil(int64(day))60+ switch unit {61+ case "week":62+ lo = day - float64(numfmt.Weekday(int64(day))) + float64(7*step)63+ return lo, lo + 6, true64+ case "month":65+ y, m = numfmt.AddMonths(y, m, step)66+ return numfmt.DateSerial(y, m, 1), numfmt.DateSerial(y, m, numfmt.DaysIn(y, m)), true67+ case "year":68+ return numfmt.DateSerial(y+step, 1, 1), numfmt.DateSerial(y+step, 12, 31), true69+ }70+ return 0, 0, false71+}72+73+// monthsBefore is the day n months before day, on the last day of that74+// month when it's shorter.75+func monthsBefore(day float64, n int) float64 {76+ y, m, d := numfmt.Civil(int64(day))77+ y, m = numfmt.AddMonths(y, m, -n)78+ return numfmt.DateSerial(y, m, min(d, numfmt.DaysIn(y, m)))79+}80+81+// IsPeriod reports whether a date rule's value names a period (or a day82+// relative to today) rather than a date.83+func IsPeriod(arg string) bool {84+ _, _, ok := datePeriod(arg)85+ _, _, isDate := ParseValue(strings.TrimSpace(arg))86+ return ok && !isDate87+}@@ -0,0 +1,145 @@
1+package sheet2+3+import (4+ "errors"5+ "math"6+ "strconv"7+ "strings"8+)9+10+// Rules that compare a cell with the other cells of the rule's ranges,11+// as Excel's top 10, above average and duplicate values: the numbers in12+// the top or bottom N (or N percent), those above or below their13+// average, and values that appear more than once, or once. What they14+// compare with is worked out once per recalculation, reading the values15+// the ranges hold, as a color scale does.16+17+// rankStats is what a ranking rule compares a cell's value with.18+type rankStats struct {19+ cut float64 // top and bottom: the Nth value; average: the mean20+ ok bool // the ranges hold a number to compare with21+ count map[string]int // duplicates and uniques: how often each value appears22+}23+24+func (c *looksCache) rank(s *Sheet, i int) *rankStats {25+ if st, ok := c.ranks[i]; ok {26+ return st27+ }28+ f := s.rules.formats[i]29+ st := &rankStats{}30+ switch f.Op {31+ case RuleDuplicate, RuleUnique:32+ st.count = map[string]int{}33+ for _, r := range f.Ranges {34+ for _, v := range s.cells.valuesIn(r) {35+ if k, ok := dupKey(v); ok {36+ st.count[k]++37+ }38+ }39+ }40+ default:41+ var vals []float6442+ for _, r := range f.Ranges {43+ for _, v := range s.cells.valuesIn(r) {44+ if v.Kind == Number {45+ vals = append(vals, v.Num)46+ }47+ }48+ }49+ st.cut, st.ok = rankCut(f.Op, f.Args[0], vals)50+ }51+ c.ranks[i] = st52+ return st53+}54+55+// rankCut is what op compares with among vals: the Nth largest or56+// smallest value, or the mean. vals is reordered.57+func rankCut(op RuleOp, arg string, vals []float64) (float64, bool) {58+ if len(vals) == 0 {59+ return 0, false60+ }61+ if op == RuleAboveAverage || op == RuleBelowAverage {62+ sum := 0.063+ for _, v := range vals {64+ sum += v65+ }66+ return sum / float64(len(vals)), true67+ }68+ n, _ := strconv.ParseFloat(strings.TrimSpace(arg), 64)69+ if op == RuleTopPercent || op == RuleBottomPercent {70+ n = math.Floor(float64(len(vals)) * n / 100) // as Excel: at least one71+ }72+ k := min(max(int(n), 1), len(vals))73+ if op == RuleTop || op == RuleTopPercent {74+ return nth(vals, len(vals)-k), true75+ }76+ return nth(vals, k-1), true77+}78+79+// dupKey is what makes two values the same for duplicates: a number's80+// value, or text ignoring case, as COUNTIF compares them. Blanks and81+// errors have none.82+func dupKey(v Value) (string, bool) {83+ switch v.Kind {84+ case Number:85+ return "n" + strconv.FormatFloat(v.Num, 'g', -1, 64), true86+ case Bool:87+ return "b" + strconv.FormatFloat(v.Num, 'g', -1, 64), true88+ case Text:89+ if v.Str == "" {90+ return "", false91+ }92+ return "t" + strings.ToLower(v.Str), true93+ }94+ return "", false95+}96+97+// match reports whether a cell's value v meets ranking rule op.98+func (st *rankStats) match(op RuleOp, v Value) bool {99+ switch op {100+ case RuleDuplicate, RuleUnique:101+ k, ok := dupKey(v)102+ return ok && (st.count[k] > 1) == (op == RuleDuplicate)103+ }104+ if !st.ok || v.Kind != Number {105+ return false106+ }107+ switch op {108+ case RuleTop, RuleTopPercent:109+ return v.Num >= st.cut110+ case RuleBottom, RuleBottomPercent:111+ return v.Num <= st.cut112+ case RuleAboveAverage:113+ return v.Num > st.cut114+ }115+ return v.Num < st.cut116+}117+118+// checkRank checks the N of a top or bottom rule: a count from 1 to119+// 1000, or a percent from 1 to 100, as Excel takes them.120+func checkRank(op RuleOp, arg string) error {121+ n, err := strconv.Atoi(strings.TrimSpace(arg))122+ switch {123+ case (op == RuleTopPercent || op == RuleBottomPercent) && (err != nil || n < 1 || n > 100):124+ return errors.New("Enter a percent from 1 to 100")125+ case err != nil || n < 1 || n > 1000:126+ return errors.New("Enter how many values, from 1 to 1000")127+ }128+ return nil129+}130+131+// rankSummary describes a ranking rule: "Top 10", "Bottom 5%".132+func rankSummary(op RuleOp, arg string) string {133+ arg = strings.TrimSpace(arg)134+ switch op {135+ case RuleTop:136+ return "Top " + arg137+ case RuleBottom:138+ return "Bottom " + arg139+ case RuleTopPercent:140+ return "Top " + arg + "%"141+ case RuleBottomPercent:142+ return "Bottom " + arg + "%"143+ }144+ return op.Title()145+}@@ -373,73 +373,7 @@ func (w *Workbook) swap(undo bool) (Change, bool) {
373373 st = h.redo[len(h.redo)-1]374374 h.redo = h.redo[:len(h.redo)-1]375375 }376- // The inverse keeps the step's ID: on the redo stack, an ID names the377- // state the step leads back to.378- inv := newStep(st.label, st.sheet, st.focus)379- inv.id = st.id380- // The sheet list goes first, so cells land on attached sheets.381- if st.sheets != nil {382- inv.sheets = w.sheetList()383- w.setSheets(st.sheets)384- }385- var changed []loc386- for s, img := range st.cells {387- img.each(func(a Addr, c *Cell) {388- inv.keep(s, a)389- s.place(a, c)390- changed = append(changed, loc{s, a})391- })392- }393- for k, width := range st.widths {394- inv.widths[k] = k.s.widths[k.col]395- k.s.setWidth(k.col, width)396- }397- for k, h := range st.heights {398- inv.keepHeight(k, k.s.heights[k.row])399- k.s.setHeight(k.row, h)400- }401- for k, l := range st.lines {402- inv.lines[k] = k.s.line(k.row, k.n)403- k.s.setLine(k.row, k.n, l)404- changed = append(changed, k.s.lineChanged(k.row, k.n)...)405- }406- for k, n := range st.names {407- inv.names[k] = w.namePtr(k)408- changed = append(changed, w.putName(k, n)...)409- }410- for s, charts := range st.charts {411- inv.charts[s] = s.charts412- s.charts = slices.Clone(charts)413- }414- for s, p := range st.pivots {415- inv.pivots[s] = s.pivot.def416- s.pivot.def, s.pivot.stale = p, true417- }418- for s, r := range st.rules {419- inv.rules[s] = s.rules420- s.rules = r421- s.looks.reset()422- }423- if st.settings != nil {424- cur := w.settings425- inv.settings, w.settings = &cur, *st.settings426- // Every formula computes differently in the other arithmetic.427- w.structural = w.structural || cur.decimal != w.decimal428- }429- if st.macros != nil {430- cur := w.macros431- inv.macros, w.macros = &cur, slices.Clone(*st.macros)432- }433- for s, v := range st.views {434- cur := s.view435- inv.views[s] = &cur436- s.view = *v437- s.hidden.valid = false438- if !slices.Equal(cur.merges, v.merges) {439- s.version++440- s.respill(Rect{To: Addr{Col: MaxCols - 1, Row: MaxRows - 1}})441- }442- }376+ inv, changed := w.restore(st)443377 w.recalcSwapped(changed)444378 if undo {445379 h.redo = append(h.redo, inv)@@ -145,3 +145,39 @@ func TestLoadIsNotUndoable(t *testing.T) {
145145 t.Error("RecalcAll kept history")146146 }147147 }148+149+// Try takes a change back whole, on its own as Discard does, or inside150+// a step another change opened (a macro's run), which goes on and still151+// undoes as one.152+func TestTryTakesBack(t *testing.T) {153+ s := New()154+ w := s.Book()155+ s.Set(at("A1"), "1")156+ kept, err := w.Try(func() error { return s.Set(at("A2"), "2") }, func() bool { return true })157+ if kept || err != nil || s.Filled(at("A2")) || s.Value(at("A1")).Num != 1 {158+ t.Errorf("alone: kept %v %v, A2 %v", kept, err, s.Value(at("A2")))159+ }160+ // Taking back nothing leaves the step before alone.161+ w.Try(func() error { return nil }, func() bool { return true })162+ if s.Value(at("A1")).Num != 1 {163+ t.Error("an empty try took back the step before")164+ }165+ end := w.Begin(Change{Label: "macro", Sheet: s})166+ s.Set(at("B1"), "=A1+A3")167+ w.Try(func() error { return s.Set(at("A3"), "5") }, func() bool { return s.Value(at("B1")).Num == 6 })168+ if s.Filled(at("A3")) {169+ t.Error("inside a step: A3 kept")170+ }171+ kept, _ = w.Try(func() error { return s.Set(at("A4"), "4") }, func() bool { return false })172+ if !kept {173+ t.Error("inside a step: A4 taken back")174+ }175+ end()176+ if s.Value(at("B1")).Num != 1 || s.Value(at("A4")).Num != 4 {177+ t.Errorf("after the step: B1 %v A4 %v", s.Value(at("B1")), s.Value(at("A4")))178+ }179+ s.Undo()180+ if s.Filled(at("B1")) || s.Filled(at("A4")) || s.Value(at("A1")).Num != 1 {181+ t.Error("the step didn't undo as one")182+ }183+}@@ -0,0 +1,170 @@
1+package sheet2+3+import "slices"4+5+// restore puts back the before-images a step holds and returns the step6+// that would put back what they replaced, keeping st's ID (on the redo7+// stack an ID names the state the step leads back to), and the cells8+// whose values may have changed. Undo and redo swap steps with it; a9+// change taken back inside another's step (Try) restores its own.10+func (w *Workbook) restore(st *step) (inv *step, changed []loc) {11+ inv = newStep(st.label, st.sheet, st.focus)12+ inv.id = st.id13+ // The sheet list goes first, so cells land on attached sheets.14+ if st.sheets != nil {15+ inv.sheets = w.sheetList()16+ w.setSheets(st.sheets)17+ }18+ for s, img := range st.cells {19+ img.each(func(a Addr, c *Cell) {20+ inv.keep(s, a)21+ s.place(a, c)22+ changed = append(changed, loc{s, a})23+ })24+ }25+ for k, width := range st.widths {26+ inv.widths[k] = k.s.widths[k.col]27+ k.s.setWidth(k.col, width)28+ }29+ for k, h := range st.heights {30+ inv.keepHeight(k, k.s.heights[k.row])31+ k.s.setHeight(k.row, h)32+ }33+ for k, l := range st.lines {34+ inv.lines[k] = k.s.line(k.row, k.n)35+ k.s.setLine(k.row, k.n, l)36+ changed = append(changed, k.s.lineChanged(k.row, k.n)...)37+ }38+ for k, n := range st.names {39+ inv.names[k] = w.namePtr(k)40+ changed = append(changed, w.putName(k, n)...)41+ }42+ w.restoreSheetParts(st, inv)43+ return inv, changed44+}45+46+// restoreSheetParts is restore's part for what a sheet keeps whole: its47+// charts, pivot, rules and view, and the workbook's settings and macros.48+func (w *Workbook) restoreSheetParts(st, inv *step) {49+ for s, charts := range st.charts {50+ inv.charts[s] = s.charts51+ s.charts = slices.Clone(charts)52+ }53+ for s, p := range st.pivots {54+ inv.pivots[s] = s.pivot.def55+ s.pivot.def, s.pivot.stale = p, true56+ }57+ for s, r := range st.rules {58+ inv.rules[s] = s.rules59+ s.rules = r60+ s.looks.reset()61+ }62+ if st.settings != nil {63+ cur := w.settings64+ inv.settings, w.settings = &cur, *st.settings65+ // Every formula computes differently in the other arithmetic.66+ w.structural = w.structural || cur.decimal != w.decimal67+ }68+ if st.macros != nil {69+ cur := w.macros70+ inv.macros, w.macros = &cur, slices.Clone(*st.macros)71+ }72+ for s, v := range st.views {73+ cur := s.view74+ inv.views[s] = &cur75+ s.view = *v76+ s.hidden.valid = false77+ if !slices.Equal(cur.merges, v.merges) {78+ s.version++79+ s.respill(Rect{To: Addr{Col: MaxCols - 1, Row: MaxRows - 1}})80+ }81+ }82+}83+84+// Try runs fn, a change, so that it can be taken back whole: after fn85+// and a recalculation, takeBack says whether to. Outside any step, fn's86+// change is a step of its own and taking it back discards it, as87+// Discard does; inside a step another change opened (a macro's run),88+// what fn changed is put back within that step, which goes on. It89+// reports whether fn's change stayed, and fn's error. A paste or fill90+// checked against validation after the fact runs through it.91+func (w *Workbook) Try(fn func() error, takeBack func() bool) (bool, error) {92+ h := &w.hist93+ if h.open == nil {94+ before := w.StateID()95+ err := fn()96+ if err == nil && w.StateID() != before && takeBack() {97+ w.Discard()98+ return false, nil99+ }100+ return true, err101+ }102+ outer := h.open103+ h.open = newStep(outer.label, outer.sheet, outer.focus)104+ err := fn()105+ child := h.open106+ h.open = outer107+ w.Settle()108+ if err == nil && takeBack() {109+ _, changed := w.restore(child)110+ w.recalcSwapped(changed)111+ return false, nil112+ }113+ outer.join(child)114+ return true, err115+}116+117+// join adds to st the before-images of child, a step taken inside it,118+// that st doesn't hold: st began earlier, so its own come first.119+func (st *step) join(child *step) {120+ for s, img := range child.cells {121+ img.each(func(a Addr, c *Cell) {122+ mine := st.cells[s]123+ if mine == nil {124+ mine = &image{}125+ st.cells[s] = mine126+ st.cellBytes += imageBytes127+ }128+ switch {129+ case mine.seen(a):130+ case c == nil:131+ st.cellBytes += mine.put(a, slot{}, true)132+ default:133+ st.cellBytes += mine.keepRich(a, c)134+ }135+ })136+ }137+ joinMap(st.widths, child.widths)138+ if child.heights != nil {139+ if st.heights == nil {140+ st.heights = map[rowKey]int{}141+ }142+ joinMap(st.heights, child.heights)143+ }144+ joinMap(st.lines, child.lines)145+ joinMap(st.names, child.names)146+ joinMap(st.views, child.views)147+ joinMap(st.charts, child.charts)148+ joinMap(st.pivots, child.pivots)149+ joinMap(st.rules, child.rules)150+ st.sheets = cmpOrPtr(st.sheets, child.sheets)151+ st.settings = cmpOrPtr(st.settings, child.settings)152+ st.macros = cmpOrPtr(st.macros, child.macros)153+}154+155+// joinMap adds child's entries to st where st has none.156+func joinMap[K comparable, V any](st, child map[K]V) {157+ for k, v := range child {158+ if _, ok := st[k]; !ok {159+ st[k] = v160+ }161+ }162+}163+164+// cmpOrPtr is a, or b when a is nil.165+func cmpOrPtr[T any](a, b *T) *T {166+ if a != nil {167+ return a168+ }169+ return b170+}@@ -23,11 +23,24 @@ type Look struct {
2323 Scaled bool2424 From, To Color2525 Pos float6426+ // Bar is set when a data bar draws across the cell, BarLen of the27+ // way (0 to 1), in BarColor.28+ Bar bool29+ BarColor Color30+ BarLen float6431+ // Icon, when set, is the icon an icon set draws at the cell's left,32+ // in IconColor.33+ Icon string34+ IconColor Color35+ // ValueHidden is set when a data bar or icon shows without the36+ // cell's value.37+ ValueHidden bool2638 2739 Checkbox bool // a checkbox, checked when Checked2840 Checked bool29- Dropdown bool // offers a list to pick from30- Invalid bool // holds what its validation rule doesn't accept41+ Dropdown bool // offers a list to pick from42+ Display DropDisplay // how it shows that it does43+ Invalid bool // holds what its validation rule doesn't accept3144 }3245 3346 // Looks are worked out per cell as the screen asks for them, and kept@@ -42,6 +55,8 @@ type looksCache struct {
4255 cells map[Addr]Look4356 tests map[int]*ruleTest // compiled conditional formats by index4457 scales map[int]*scaleStats // color scales' points by index58+ bars map[int]*barStats // data bars' and icon sets' points by index59+ ranks map[int]*rankStats // what ranking rules compare with, by index4560 lists map[int]map[string]bool // dropdown sources' values by validation index4661 rd *reader4762 }@@ -57,7 +72,7 @@ func (c *looksCache) fresh(s *Sheet) {
5772 return5873 }5974 *c = looksCache{wb: s.wb, gen: s.wb.gen, valid: true, cells: map[Addr]Look{}, tests: map[int]*ruleTest{},60- scales: map[int]*scaleStats{}, lists: map[int]map[string]bool{}}75+ scales: map[int]*scaleStats{}, lists: map[int]map[string]bool{}, bars: map[int]*barStats{}, ranks: map[int]*rankStats{}}6176 }6277 6378 // HasRules reports whether the sheet has conditional formats or data@@ -86,30 +101,53 @@ func (c *looksCache) look(s *Sheet, a Addr) Look {
86101 var l Look87102 v := s.Value(a)88103 for i, f := range s.rules.formats {89- if !inRanges(f.Ranges, a) {90- continue91- }92- if f.IsScale() {93- if pos, from, to, ok := c.scale(s, i).place(v); ok {94- l.Scaled, l.Pos, l.From, l.To = true, pos, from, to95- break96- }97- continue98- }99- if c.test(s, i).match(c, s, a, v) {100- l.Styled, l.Style = true, f.Style104+ if inRanges(f.Ranges, a) && c.apply(s, i, f, a, v, &l) {101105 break102106 }103107 }104108 if i := s.validationIndex(a); i >= 0 {105- k := s.rules.validations[i].Kind106- l.Checkbox, l.Dropdown = k == ValidCheckbox, k.Dropdown()107- l.Checked = l.Checkbox && v.Kind == Bool && v.Num != 0109+ r := s.rules.validations[i]110+ l.Checkbox, l.Dropdown, l.Display = r.Kind == ValidCheckbox, r.Kind.Dropdown(), r.Display111+ l.Checked = l.Checkbox && r.isChecked(v, s.ShownText(a))108112 l.Invalid = s.cells.filledAt(a) && !c.check(s, i, a, v, s.DisplayFormat(a))109113 }110114 return l111115 }112116 117+// apply sets what rule i (f) draws on the cell at a, of value v, on l,118+// reporting whether it applies there.119+func (c *looksCache) apply(s *Sheet, i int, f CondFormat, a Addr, v Value, l *Look) bool {120+ switch {121+ case f.IsBar():122+ n, ok := c.bar(s, i).barLook(v)123+ if ok {124+ l.Bar, l.BarLen, l.BarColor, l.ValueHidden = true, n, f.Bar, f.BarOnly125+ }126+ return ok127+ case f.IsIcons():128+ k, ok := c.bar(s, i).icon(v, f.Reverse)129+ if ok {130+ n := len(f.Scale) + 1131+ l.Icon, l.IconColor, l.ValueHidden = f.Icons.Glyphs(n)[k], f.Icons.IconColor(k, n), f.BarOnly132+ }133+ return ok134+ case f.IsScale():135+ pos, from, to, ok := c.scale(s, i).place(v)136+ if ok {137+ l.Scaled, l.Pos, l.From, l.To = true, pos, from, to138+ }139+ return ok140+ case f.Op.Ranks():141+ if !c.rank(s, i).match(f.Op, v) {142+ return false143+ }144+ case !c.test(s, i).match(c, s, a, v):145+ return false146+ }147+ l.Styled, l.Style = true, f.Style148+ return true149+}150+113151 // reader reads current values for rules' formulas.114152 func (c *looksCache) reader(s *Sheet) *reader {115153 if c.rd == nil {@@ -175,7 +213,8 @@ func (t *ruleTest) parse(op RuleOp, args [2]string) {
175213 case IsFormulaEntry(arg):176214 t.args[k], _ = Parse(arg)177215 case op == RuleDateIs || op == RuleDateBefore || op == RuleDateAfter:178- t.nums[k], t.isNum[k] = dateArg(arg)216+ t.nums[0], t.nums[1], t.isNum[0] = datePeriod(arg)217+ t.isNum[1] = t.isNum[0]179218 default:180219 n, _, ok := ParseValue(arg)181220 t.nums[k], t.isNum[k] = n, ok@@ -216,12 +255,18 @@ func (t *ruleTest) match(c *looksCache, s *Sheet, a Addr, v Value) bool {
216255 return false217256 }218257 switch t.op {219- case RuleDateIs:220- return float64(int64(x)) == float64(int64(lo.Num))221- case RuleDateBefore:222- return float64(int64(x)) < float64(int64(lo.Num))223- case RuleDateAfter:224- return float64(int64(x)) > float64(int64(lo.Num))258+ case RuleDateIs, RuleDateBefore, RuleDateAfter:259+ first, last, day := float64(int64(lo.Num)), float64(int64(lo.Num)), float64(int64(x))260+ if t.args[0] == nil && t.isNum[1] {261+ last = t.nums[1] // a period's last day262+ }263+ switch t.op {264+ case RuleDateIs:265+ return day >= first && day <= last266+ case RuleDateBefore:267+ return day < first268+ }269+ return day > last225270 }226271 hi := t.arg(c, s, 1, a)227272 return hi.Kind == Number && compareNum(t.op, x, lo.Num, hi.Num)@@ -326,7 +371,7 @@ func (c *looksCache) check(s *Sheet, i int, a Addr, v Value, f Format) bool {
326371 case ValidRange:327372 return v.Kind == Empty || c.list(s, i)[strings.ToLower(strings.TrimSpace(FormatText(v, f)))]328373 case ValidCheckbox:329- return v.Kind == Bool || v.Kind == Empty374+ return r.checkboxValid(v, FormatText(v, f))330375 case ValidFormula:331376 n, err := Parse(strings.TrimSpace(r.Args[0]))332377 return err == nil && truthy(c.evalFrom(s, n, anchor(r.Ranges), a))@@ -67,6 +67,7 @@ func (s *Sheet) MoveTo(dst *Sheet, src Rect, to Addr) (Rect, error) {
6767 mv.apply(moved, readers)6868 dst.moveFormats(s, &f, src, d)6969 dst.moveMerges(s, src, d)70+ mv.moveRules() // rulemove.go7071 })7172 return d, nil7273 }@@ -13,7 +13,9 @@ import (
1313 //1414 // "conditionalFormats": [1515 // {"ranges": "B2:B20", "condition": "gt", "values": ["100"], "fill": "green", "bold": true},16-// {"ranges": "C2:C20", "scale": [{"type": "min", "color": "red"}, {"type": "max", "color": "green"}]}16+// {"ranges": "C2:C20", "scale": [{"type": "min", "color": "red"}, {"type": "max", "color": "green"}]},17+// {"ranges": "D2:D20", "dataBar": {"color": "blue", "min": {"type": "min"}, "max": {"type": "max"}}},18+// {"ranges": "E2:E20", "iconSet": {"icons": "arrows", "points": [{"type": "percent", "value": "33"}, {"type": "percent", "value": "67"}]}}1719 // ],1820 // "validations": [1921 // {"ranges": "D2:D20", "criteria": "list", "items": ["Yes", "No"], "reject": true}@@ -33,6 +35,10 @@ type fileCondFormat struct {
3335 Underline bool `json:"underline,omitempty"`3436 Strike bool `json:"strikethrough,omitempty"`3537 Scale []fileScaled `json:"scale,omitempty"`38+ // Data bars and icon sets need no version bump: earlier builds ignore39+ // them, losing the rule, as they lose rules they don't know.40+ DataBar *fileBar `json:"dataBar,omitempty"`41+ IconSet *fileIcons `json:"iconSet,omitempty"`3642 }3743 3844 type fileScaled struct {@@ -41,6 +47,42 @@ type fileScaled struct {
4147 Color string `json:"color"`4248 }4349 50+// filePoint is a data bar's or icon set's point.51+type filePoint struct {52+ Type string `json:"type"`53+ Value string `json:"value,omitempty"`54+}55+56+type fileBar struct {57+ Color string `json:"color"`58+ Min filePoint `json:"min"`59+ Max filePoint `json:"max"`60+ BarOnly bool `json:"barOnly,omitempty"`61+}62+63+type fileIcons struct {64+ Icons string `json:"icons"`65+ Points []filePoint `json:"points"`66+ Reverse bool `json:"reverse,omitempty"`67+ IconOnly bool `json:"iconOnly,omitempty"`68+}69+70+func filePointOf(p ScalePoint) filePoint {71+ fp := filePoint{Type: p.Kind.String()}72+ if p.Kind.TakesValue() {73+ fp.Value = p.Value74+ }75+ return fp76+}77+78+func (fp filePoint) point() (ScalePoint, error) {79+ k, ok := ParsePointKind(fp.Type)80+ if !ok {81+ return ScalePoint{}, fmt.Errorf("invalid point %q", fp.Type)82+ }83+ return ScalePoint{Kind: k, Value: fp.Value}, nil84+}85+4486 type fileValidation struct {4587 Ranges string `json:"ranges"`4688 Criteria string `json:"criteria"`@@ -50,6 +92,9 @@ type fileValidation struct {
5092 Source string `json:"source,omitempty"`5193 Reject bool `json:"reject,omitempty"`5294 Help string `json:"help,omitempty"`95+ // Display needs no version bump: earlier builds draw every dropdown96+ // with its arrow.97+ Display string `json:"display,omitempty"` // "chip" or "plain"5398 }5499 55100 // values lists the first n of args, as files keep them.@@ -64,7 +109,15 @@ func values(args [2]string, n int) []string {
64109 // {"ranges":"B2:B20","condition":"gt","values":["100"],"fill":"green"}.65110 func (f CondFormat) JSON() string {66111 fc := fileCondFormat{Ranges: rangesText(f.Ranges)}67- if f.IsScale() {112+ switch {113+ case f.IsBar() && len(f.Scale) == 2:114+ fc.DataBar = &fileBar{Color: f.Bar.String(), Min: filePointOf(f.Scale[0]), Max: filePointOf(f.Scale[1]), BarOnly: f.BarOnly}115+ case f.IsIcons():116+ fc.IconSet = &fileIcons{Icons: f.Icons.String(), Reverse: f.Reverse, IconOnly: f.BarOnly}117+ for _, p := range f.Scale {118+ fc.IconSet.Points = append(fc.IconSet.Points, filePointOf(p))119+ }120+ case f.IsScale():68121 for _, p := range f.Scale {69122 sp := fileScaled{Type: p.Kind.String(), Color: p.Color.String()}70123 if p.Kind.TakesValue() {@@ -72,7 +125,7 @@ func (f CondFormat) JSON() string {
72125 }73126 fc.Scale = append(fc.Scale, sp)74127 }75- } else {128+ default:76129 st := f.Style77130 fc.Condition, fc.Values = f.Op.String(), values(f.Args, f.Op.Args())78131 fc.Text, fc.Fill = st.Text.String(), st.Fill.String()@@ -101,6 +154,12 @@ func (fc fileCondFormat) rule() (CondFormat, error) {
101154 return CondFormat{}, fmt.Errorf("invalid ranges %q", fc.Ranges)102155 }103156 f := CondFormat{Ranges: rs}157+ switch {158+ case fc.DataBar != nil:159+ return fc.DataBar.rule(f)160+ case fc.IconSet != nil:161+ return fc.IconSet.rule(f)162+ }104163 for _, sp := range fc.Scale {105164 k, ok := ParsePointKind(sp.Type)106165 c, cok := ParseColor(sp.Color)@@ -124,12 +183,42 @@ func (fc fileCondFormat) rule() (CondFormat, error) {
124183 return f, nil125184 }126185 186+func (fb *fileBar) rule(f CondFormat) (CondFormat, error) {187+ c, ok := ParseColor(fb.Color)188+ lo, err1 := fb.Min.point()189+ hi, err2 := fb.Max.point()190+ if !ok || c == ColorNone || err1 != nil || err2 != nil {191+ return CondFormat{}, fmt.Errorf("invalid data bar %q", fb.Color)192+ }193+ f.Bar, f.Scale, f.BarOnly = c, []ScalePoint{lo, hi}, fb.BarOnly194+ return f, nil195+}196+197+func (fi *fileIcons) rule(f CondFormat) (CondFormat, error) {198+ set, ok := ParseIconSet(fi.Icons)199+ if !ok || set == IconsNone || len(fi.Points) > 4 {200+ return CondFormat{}, fmt.Errorf("invalid icon set %q", fi.Icons)201+ }202+ f.Icons, f.Reverse, f.BarOnly = set, fi.Reverse, fi.IconOnly203+ for _, fp := range fi.Points {204+ p, err := fp.point()205+ if err != nil {206+ return CondFormat{}, err207+ }208+ f.Scale = append(f.Scale, p)209+ }210+ return f, nil211+}212+127213 // JSON writes the rule as a line of the file, e.g.128214 // {"ranges":"D2:D20","criteria":"list","items":["Yes","No"]}.129215 func (v Validation) JSON() string {130216 fv := fileValidation{Ranges: rangesText(v.Ranges), Criteria: v.Kind.String(), Reject: v.Reject, Help: v.Help}217+ if v.Kind.Dropdown() {218+ fv.Display = v.Display.String()219+ }131220 switch {132- case v.Kind == ValidList:221+ case v.Kind == ValidList, v.Kind == ValidCheckbox:133222 fv.Items = v.Items134223 case v.Kind == ValidRange:135224 fv.Source = v.Source@@ -162,10 +251,11 @@ func (fv fileValidation) rule() (Validation, error) {
162251 }163252 k, ok := ParseValidKind(fv.Criteria)164253 op, opOK := ParseRuleOp(fv.Condition)165- if !ok || !opOK || len(fv.Values) > 2 {254+ d, dOK := ParseDropDisplay(fv.Display)255+ if !ok || !opOK || !dOK || len(fv.Values) > 2 {166256 return Validation{}, fmt.Errorf("invalid validation %q %q", fv.Criteria, fv.Condition)167257 }168- v := Validation{Ranges: rs, Kind: k, Op: op, Items: fv.Items, Source: fv.Source, Reject: fv.Reject, Help: fv.Help}258+ v := Validation{Ranges: rs, Kind: k, Op: op, Items: fv.Items, Source: fv.Source, Reject: fv.Reject, Help: fv.Help, Display: d}169259 copy(v.Args[:], fv.Values)170260 return v, nil171261 }@@ -6,10 +6,10 @@ import (
66 "github.com/FelineStateMachine/012/internal/formula"77 )88 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.9+// Conditional formats and validation move with cut and paste, as in10+// Sheets: the cut cells take their rules to where they land, on the same11+// sheet or another, the cells they land on lose theirs, and formulas in12+// the rules follow the cells they read.1313 1414 // moveRules moves the sheet's rules with the cells in src, landing on1515 // dst; cell and rng are the move's, for the rules' formulas.@@ -70,31 +70,134 @@ type rulePart struct {
7070 // away): the cells that stay, outside both, and those that move. split7171 // keeps the two apart, for a rule with a relative formula.7272 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)} }73+ keptPart, movedPart := splitMoved(rs, src, dst, d)74+ kept, moved := keptPart.ranges, movedPart.ranges8575 switch {8676 case len(moved) == 0 && len(kept) == 0:8777 return nil8878 case len(moved) == 0:89- return []rulePart{keptPart()}79+ return []rulePart{keptPart}9080 case len(kept) == 0:91- return []rulePart{movedPart()}81+ return []rulePart{movedPart}9282 case split:93- return []rulePart{keptPart(), movedPart()}83+ return []rulePart{keptPart, movedPart}9484 }9585 return []rulePart{{ranges: append(kept, moved...)}}9686 }9787 88+// splitMoved is the part of ranges rs that stays when src moves to dst89+// (d away), outside both, and the part that moves, each with the shift90+// from rs's first cell to where the part's first cell was.91+func splitMoved(rs []Rect, src, dst Rect, d Addr) (kept, moved rulePart) {92+ for _, r := range rs {93+ for _, k := range subtract(r, src) {94+ kept.ranges = append(kept.ranges, subtract(k, dst)...)95+ }96+ if in, ok := overlap(r, src); ok {97+ moved.ranges = append(moved.ranges, Rect{From: plus(in.From, d), To: plus(in.To, d)})98+ }99+ }100+ from := anchor(rs)101+ kept.shift = minus(anchor(kept.ranges), from)102+ moved.shift = minus(minus(anchor(moved.ranges), d), from)103+ return kept, moved104+}105+106+// moveRules moves the rules of the cells in src to the sheet they land107+// on: they leave the source sheet's rules, the cells they land on leave108+// the destination's, and their parts of the source's rules join the109+// destination's, each rule after the destination's own, as a copy's110+// rules would. Formulas in every sheet's rules follow the cells they111+// read, as the formulas in cells do, and the moved rules' name the112+// source sheet for cells that stayed behind.113+func (mv sheetMove) moveRules() {114+ var landed rulesState115+ for _, t := range mv.from.wb.sheets {116+ if t.rules.empty() {117+ continue118+ }119+ next := mv.splitRules(t, &landed)120+ if t == mv.to {121+ next.formats = append(next.formats, landed.formats...)122+ next.validations = append(next.validations, landed.validations...)123+ landed = rulesState{}124+ }125+ if !next.equal(t.rules) {126+ t.recordRules()127+ t.rules = next128+ }129+ }130+ if !landed.empty() { // the destination had no rules of its own131+ mv.to.recordRules()132+ mv.to.rules = landed133+ }134+}135+136+// splitRules is t's rules after the move: what stays on t, rewritten for137+// the cells that moved, with the parts that move added to landed.138+func (mv sheetMove) splitRules(t *Sheet, landed *rulesState) rulesState {139+ d := minus(mv.d.From, mv.src.From)140+ nowhere := Rect{From: Addr{Col: -1, Row: -1}, To: Addr{Col: -1, Row: -1}}141+ src, dst := nowhere, nowhere142+ switch t {143+ case mv.from:144+ src = mv.src145+ case mv.to:146+ dst = mv.d147+ }148+ rw, away := mv.rewrite(t, t), mv.rewrite(mv.from, mv.to)149+ var next rulesState150+ for _, f := range t.rules.formats {151+ kept, moved := splitMoved(f.Ranges, src, dst, d)152+ if len(kept.ranges) > 0 {153+ next.formats = append(next.formats, movedFormat(f, kept, rw))154+ }155+ if len(moved.ranges) > 0 {156+ landed.formats = append(landed.formats, movedFormat(f, moved, away))157+ }158+ }159+ for _, v := range t.rules.validations {160+ kept, moved := splitMoved(v.Ranges, src, dst, d)161+ if len(kept.ranges) > 0 {162+ next.validations = append(next.validations, movedValidation(v, kept, rw))163+ }164+ if len(moved.ranges) > 0 {165+ landed.validations = append(landed.validations, movedValidation(v, moved, away))166+ }167+ }168+ return next169+}170+171+// movedFormat is f over part p's ranges, its formula written for their172+// first cell and rewritten by rw.173+func movedFormat(f CondFormat, p rulePart, rw formula.Rewriter) CondFormat {174+ g := f.clone()175+ g.Ranges = p.ranges176+ if f.Op == RuleFormula {177+ g.Args[0] = reanchor(f.Args[0], p.shift, rw)178+ } else {179+ for k := range f.Op.Args() {180+ if IsFormulaEntry(strings.TrimSpace(f.Args[k])) {181+ g.Args[k] = reanchor(f.Args[k], p.shift, rw)182+ }183+ }184+ }185+ return g186+}187+188+// movedValidation is movedFormat for a validation rule.189+func movedValidation(v Validation, p rulePart, rw formula.Rewriter) Validation {190+ w := v.clone()191+ w.Ranges = p.ranges192+ switch v.Kind {193+ case ValidFormula:194+ w.Args[0] = reanchor(v.Args[0], p.shift, rw)195+ case ValidRange:196+ w.Source = strings.TrimPrefix(shiftFormula("="+v.Source, rw), "=")197+ }198+ return w199+}200+98201 // reanchor rewrites a rule's formula (with its "=") for a first cell99202 // shift away, then for the move (rw).100203 func reanchor(src string, shift Addr, rw formula.Rewriter) string {@@ -73,3 +73,55 @@ func TestInvalidInAndDiscard(t *testing.T) {
7373 t.Error("still invalid after discarding")7474 }7575 }76+77+// Cut on one sheet and pasted on another, the cells take their rules78+// along: they leave the source's rules, the cells they land on lose79+// theirs, and a moved formula reads the cells it read, naming the sheet80+// it came from.81+func TestRulesMoveToAnotherSheet(t *testing.T) {82+ s := New()83+ for r := range 4 {84+ s.Set(Addr{Row: r}, "5")85+ }86+ mustAdd(t, s, CondFormat{Ranges: ranges("A1:A4"), Op: RuleGreater, Args: [2]string{"2"}, Style: green})87+ mustAdd(t, s, CondFormat{Ranges: ranges("A1:A4"), Op: RuleFormula, Args: [2]string{"=$B1>0"}, Style: green})88+ if err := s.AddValidation(Validation{Ranges: ranges("A2:A3"), Kind: ValidList, Items: []string{"5"}}); err != nil {89+ t.Fatal(err)90+ }91+ other, err := s.Book().AddSheet("Other", 1)92+ if err != nil {93+ t.Fatal(err)94+ }95+ mustAdd(t, other, CondFormat{Ranges: ranges("C1:C2"), Op: RuleEmpty, Style: green})96+ mustAdd(t, other, CondFormat{Ranges: ranges("D1"), Op: RuleEqual, Args: [2]string{"=C1"}, Style: green})97+ if _, err := s.MoveTo(other, rect("A2:A3"), at("C1")); err != nil {98+ t.Fatal(err)99+ }100+ fs := s.CondFormats()101+ if len(fs) != 2 || RangesText(fs[0].Ranges) != "A1,A4" || RangesText(fs[1].Ranges) != "A1,A4" || fs[1].Args[0] != "=$B1>0" {102+ t.Errorf("source formats %+v", fs)103+ }104+ if len(s.Validations()) != 0 {105+ t.Errorf("source validations %+v", s.Validations())106+ }107+ ofs := other.CondFormats()108+ if len(ofs) != 3 {109+ t.Fatalf("destination formats %+v", ofs)110+ }111+ if RangesText(ofs[0].Ranges) != "D1" || RangesText(ofs[1].Ranges) != "C1:C2" || ofs[1].Op != RuleGreater {112+ t.Errorf("destination %s %s", RangesText(ofs[0].Ranges), RangesText(ofs[1].Ranges))113+ }114+ if ofs[2].Args[0] != "=Sheet1!$B2>0" {115+ t.Errorf("moved formula %q", ofs[2].Args[0])116+ }117+ if vs := other.Validations(); len(vs) != 1 || RangesText(vs[0].Ranges) != "C1:C2" {118+ t.Errorf("destination validations %+v", vs)119+ }120+ if !other.Look(at("C1")).Styled {121+ t.Error("the moved rule doesn't draw")122+ }123+ s.Book().Undo()124+ if len(s.CondFormats()) != 2 || RangesText(s.CondFormats()[0].Ranges) != "A1:A4" || len(other.CondFormats()) != 2 || len(s.Validations()) != 1 {125+ t.Errorf("undo: %+v %+v", s.CondFormats(), other.CondFormats())126+ }127+}@@ -105,18 +105,32 @@ const (
105105 RuleBetween106106 RuleNotBetween107107 RuleFormula108+ // The tests of a cell against the others in the rule's ranges, as109+ // Excel's top 10, above average and duplicate values rules (see110+ // condrank.go).111+ RuleTop112+ RuleBottom113+ RuleTopPercent114+ RuleBottomPercent115+ RuleAboveAverage116+ RuleBelowAverage117+ RuleDuplicate118+ RuleUnique108119 numRuleOps109120 )110121 111122 var ruleOpNames = [numRuleOps]string{"", "empty", "not_empty", "contains", "not_contains",112123 "starts_with", "ends_with", "exactly", "date_is", "date_before", "date_after",113- "gt", "ge", "lt", "le", "eq", "ne", "between", "not_between", "formula"}124+ "gt", "ge", "lt", "le", "eq", "ne", "between", "not_between", "formula",125+ "top", "bottom", "top_percent", "bottom_percent", "above_average", "below_average", "duplicate", "unique"}114126 115127 var ruleOpTitles = [numRuleOps]string{"None", "Is empty", "Is not empty", "Text contains",116128 "Text does not contain", "Text starts with", "Text ends with", "Text is exactly",117129 "Date is", "Date is before", "Date is after",118130 "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"}131+ "Is equal to", "Is not equal to", "Is between", "Is not between", "Custom formula is",132+ "Top values", "Bottom values", "Top percent", "Bottom percent", "Above average", "Below average",133+ "Duplicate values", "Unique values"}120134 121135 // String names the test as files store it.122136 func (op RuleOp) String() string {@@ -138,7 +152,7 @@ func (op RuleOp) Title() string {
138152 // between.139153 func (op RuleOp) Args() int {140154 switch op {141- case RuleNone, RuleEmpty, RuleNotEmpty:155+ case RuleNone, RuleEmpty, RuleNotEmpty, RuleAboveAverage, RuleBelowAverage, RuleDuplicate, RuleUnique:142156 return 0143157 case RuleBetween, RuleNotBetween:144158 return 2@@ -162,9 +176,15 @@ func CondFormatOps() []RuleOp {
162176 return []RuleOp{RuleEmpty, RuleNotEmpty, RuleContains, RuleNotContains, RuleStartsWith,163177 RuleEndsWith, RuleExactly, RuleDateIs, RuleDateBefore, RuleDateAfter, RuleGreater,164178 RuleGreaterEq, RuleLess, RuleLessEq, RuleEqual, RuleNotEqual, RuleBetween,165- RuleNotBetween, RuleFormula}179+ RuleNotBetween, RuleTop, RuleBottom, RuleTopPercent, RuleBottomPercent, RuleAboveAverage,180+ RuleBelowAverage, RuleDuplicate, RuleUnique, RuleFormula}166181 }167182 183+// Ranks reports whether the test compares a cell with the rest of the184+// rule's cells (top values, above average, duplicates) rather than with185+// values of its own.186+func (op RuleOp) Ranks() bool { return op >= RuleTop && op < numRuleOps }187+168188 // CompareOps are the comparisons data validation offers for numbers,169189 // dates and text lengths.170190 func CompareOps() []RuleOp {@@ -87,6 +87,10 @@ type Validation struct {
8787 Reject bool8888 // Help replaces the rule's own help text, shown on the context line.8989 Help string90+ // Display is how a dropdown's cells show it (validstyle.go). A91+ // checkbox's Items, when it has any, are its checked and unchecked92+ // values.93+ Display DropDisplay9094 }9195 9296 func (v Validation) clone() Validation {@@ -103,6 +107,8 @@ func (v Validation) Check() error {
103107 return errors.New("Choose the criteria")104108 case v.Kind == ValidList && len(v.Items) == 0:105109 return errors.New("Enter the items, separated by commas")110+ case v.Kind == ValidCheckbox:111+ return v.checkCheckbox()106112 case v.Kind == ValidRange:107113 if _, _, ok := v.SourceRange(); !ok {108114 return errors.New("Enter the range the items are in, e.g. Lists!A1:A20")@@ -160,10 +166,20 @@ func (v Validation) Summary() string {
160166 return "Custom formula " + v.Args[0]161167 case v.Kind.Compares():162168 return v.Kind.Title() + " " + compareText(v.Kind, v.Op, v.Args)169+ case v.Kind == ValidCheckbox && len(v.Items) > 0:170+ on, off, _ := v.CheckboxValues()171+ return "Checkbox: " + on + " or " + cmpOr(off, "blank")163172 }164173 return v.Kind.Title()165174 }166175 176+func cmpOr(s, alt string) string {177+ if s == "" {178+ return alt179+ }180+ return s181+}182+167183 // compareText is a comparison in words: "between 1 and 10", "after 1/1/2026".168184 func compareText(k ValidKind, op RuleOp, args [2]string) string {169185 if k == ValidDate && op == RuleNone {@@ -196,7 +212,11 @@ func (v Validation) HelpText() string {
196212 case ValidRange:197213 return "Input must fall within specified range"198214 case ValidCheckbox:199- return "Input must be TRUE or FALSE"215+ on, off, _ := v.CheckboxValues()216+ if off == "" {217+ return "Input must be " + on + " or blank"218+ }219+ return "Input must be " + on + " or " + off200220 case ValidNumber:201221 return "Input must be a number " + compareText(v.Kind, v.Op, v.Args)202222 case ValidDate:@@ -0,0 +1,114 @@
1+package sheet2+3+import (4+ "errors"5+ "slices"6+ "strings"7+)8+9+// How dropdowns and checkboxes look, as Sheets' Data validation offers10+// it: a dropdown's cells show an arrow, their value as a chip, or plain11+// text; a checkbox may enter values of its own for checked and12+// unchecked rather than TRUE and FALSE.13+14+// DropDisplay is how a dropdown's cells show that they have one.15+type DropDisplay uint816+17+const (18+ DropArrow DropDisplay = iota // ▾ at the cell's right19+ DropChip // the value on a chip, with its ▾, as Sheets draws them20+ DropPlain // the value alone; Alt+Down still opens the list21+ numDropDisplays22+)23+24+var dropNames = [numDropDisplays]string{"", "chip", "plain"}25+var dropTitles = [numDropDisplays]string{"Arrow", "Chip", "Plain text"}26+27+func (d DropDisplay) String() string {28+ if d >= numDropDisplays {29+ return ""30+ }31+ return dropNames[d]32+}33+34+// Title names the display for people, e.g. "Chip".35+func (d DropDisplay) Title() string {36+ if d >= numDropDisplays {37+ return ""38+ }39+ return dropTitles[d]40+}41+42+// ParseDropDisplay is the inverse of DropDisplay.String.43+func ParseDropDisplay(s string) (DropDisplay, bool) {44+ i := slices.Index(dropNames[:], s)45+ return DropDisplay(max(i, 0)), i >= 046+}47+48+// DropDisplays lists the displays in the order the rules editor offers49+// them.50+func DropDisplays() []DropDisplay { return []DropDisplay{DropChip, DropArrow, DropPlain} }51+52+// CheckboxValues are what a checkbox rule's cells hold checked and53+// unchecked: TRUE and FALSE, or its own values (Items), the unchecked54+// one "" for a blank cell.55+func (v Validation) CheckboxValues() (checked, unchecked string, custom bool) {56+ if v.Kind != ValidCheckbox || len(v.Items) == 0 {57+ return "TRUE", "FALSE", false58+ }59+ if len(v.Items) > 1 {60+ unchecked = v.Items[1]61+ }62+ return v.Items[0], unchecked, true63+}64+65+// checkCheckbox checks a checkbox's own values: a checked value, and an66+// unchecked one that differs from it.67+func (v Validation) checkCheckbox() error {68+ switch on, off, custom := v.CheckboxValues(); {69+ case !custom:70+ return nil71+ case len(v.Items) > 2:72+ return errors.New("A checkbox has a checked and an unchecked value")73+ case strings.TrimSpace(on) == "":74+ return errors.New("Enter the value a checked box holds")75+ case strings.EqualFold(strings.TrimSpace(on), strings.TrimSpace(off)):76+ return errors.New("The checked and unchecked values must differ")77+ }78+ return nil79+}80+81+// isChecked reports whether a checkbox cell of value v, shown as shown,82+// is checked by its rule.83+func (v Validation) isChecked(val Value, shown string) bool {84+ on, _, custom := v.CheckboxValues()85+ if !custom {86+ return val.Kind == Bool && val.Num != 087+ }88+ return strings.EqualFold(strings.TrimSpace(shown), strings.TrimSpace(on))89+}90+91+// checkboxValid reports whether a checkbox cell may hold v, shown as92+// shown: its checked or unchecked value, or a blank.93+func (v Validation) checkboxValid(val Value, shown string) bool {94+ if val.Kind == Empty {95+ return true96+ }97+ on, off, custom := v.CheckboxValues()98+ if !custom {99+ return val.Kind == Bool100+ }101+ shown = strings.TrimSpace(shown)102+ return strings.EqualFold(shown, strings.TrimSpace(on)) || off != "" && strings.EqualFold(shown, strings.TrimSpace(off))103+}104+105+// CheckboxInput is what checking (on) or unchecking the checkbox at a106+// enters: TRUE or FALSE, or its rule's own values.107+func (s *Sheet) CheckboxInput(a Addr, on bool) string {108+ v, _ := s.Validation(a)109+ checked, unchecked, _ := v.CheckboxValues()110+ if on {111+ return checked112+ }113+ return unchecked114+}@@ -60,29 +60,36 @@ func (m *Model) paste(values bool) tea.Cmd {
6060 m.note = "Nothing to paste: copy with Ctrl+C first"6161 return nil6262 }63- var r, moved sheet.Rect64- var err error65- if c.cut && !values {66- if !c.sheet.Live() {63+ var moved sheet.Rect64+ if c.cut && !values && !c.sheet.Live() {65+ m.copied = clipboard{}66+ m.note = "Nothing to move: the cut cells' sheet was deleted"67+ return nil68+ }69+ write := func() (sheet.Rect, error) {70+ if c.cut && !values {71+ moved = c.clip.MoveRange(m.cur)6772 m.copied = clipboard{}68- m.note = "Nothing to move: the cut cells' sheet was deleted"69- return nil73+ return c.sheet.MoveTo(m.sheet, moved, m.cur)7074 }71- moved = c.clip.MoveRange(m.cur)72- r, err = c.sheet.MoveTo(m.sheet, moved, m.cur)73- m.copied = clipboard{}74- } else {75- r, err = m.sheet.Paste(c.clip, m.selection(), values)7675 m.copied.keep = true76+ return m.sheet.Paste(c.clip, m.selection(), values)7777 }78- if err != nil {79- m.fail(err.Error())80- return nil81- }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 nil78+ // Cells moved take their rules along (sheet.Move, sheet.MoveTo),79+ // so what's pasted meets the rules where it lands only when copied.80+ check := !c.cut || values81+ var r sheet.Rect82+ if check {83+ var ok bool84+ if r, ok = m.writeChecked("Paste", write); !ok {85+ return nil86+ }87+ } else {88+ var err error89+ if r, err = write(); err != nil {90+ m.fail(err.Error())91+ return nil92+ }8693 }8794 m.selectRect(r)8895 switch {@@ -164,22 +171,24 @@ func (m *Model) pasteText(content string) bool {
164171 if m.refuseEdit(r, false) {165172 return true166173 }167- m.sheet.Batch(sheet.Change{Label: "paste into " + r.String(), Focus: r}, func() error {168- for i, row := range rows {169- for j := range width {170- var v string171- if j < len(row) {172- v = row[j]173- }174- a := sheet.Addr{Col: r.From.Col + j, Row: r.From.Row + i}175- if m.sheet.Set(a, m.storedEntry(v)) != nil {176- m.sheet.Set(a, "'"+v) // a broken formula stays as text174+ _, ok := m.writeChecked("Paste", func() (sheet.Rect, error) {175+ return r, m.sheet.Batch(sheet.Change{Label: "paste into " + r.String(), Focus: r}, func() error {176+ for i, row := range rows {177+ for j := range width {178+ var v string179+ if j < len(row) {180+ v = row[j]181+ }182+ a := sheet.Addr{Col: r.From.Col + j, Row: r.From.Row + i}183+ if m.sheet.Set(a, m.storedEntry(v)) != nil {184+ m.sheet.Set(a, "'"+v) // a broken formula stays as text185+ }177186 }178187 }179- }180- return nil188+ return nil189+ })181190 })182- if !m.checkWritten(r, "Paste") {191+ if !ok {183192 return true184193 }185194 m.selectRect(r)@@ -134,12 +134,7 @@ func (g *grid) selectRect(r sheet.Rect) {
134134 }135135 136136 func (m *Model) fill(fn func(sheet.Rect) (sheet.Rect, error)) tea.Cmd {137- r, err := fn(m.selection())138- if err != nil {139- m.fail(err.Error())140- return nil141- }142- m.checkWritten(r, "Fill")137+ m.writeChecked("Fill", func() (sheet.Rect, error) { return fn(m.selection()) })143138 return nil144139 }145140 @@ -87,13 +87,12 @@ func (m *Model) finishFill() {
8787 }8888 m.recordFlush()8989 span := m.spans.Start("fill", slog.Int("cells", (dst.To.Row-dst.From.Row+1)*(dst.To.Col-dst.From.Col+1)))90- got, err := m.sheet.FillSeries(src, dst)91- span.Fail(err)92- if err != nil {93- m.fail(err.Error())94- return95- }96- if !m.checkWritten(got, "Fill") {90+ got, ok := m.writeChecked("Fill", func() (sheet.Rect, error) {91+ got, err := m.sheet.FillSeries(src, dst)92+ span.Fail(err)93+ return got, err94+ })95+ if !ok {9796 return9897 }9998 m.changed = true@@ -121,7 +121,9 @@ func (h scriptHost) Enter(text string, fill bool, origin string) error {
121121 }122122 }123123 if fill {124- return m.sheet.FillEntry(m.selection(), m.cur, text)124+ r := m.selection()125+ m.writeChecked("Fill", func() (sheet.Rect, error) { return r, m.sheet.FillEntry(r, m.cur, text) })126+ return h.failed()125127 }126128 if bad := m.sheet.CheckEntry(m.cur, text); bad != nil && bad.Reject {127129 return bad@@ -136,11 +138,21 @@ func (h scriptHost) Enter(text string, fill bool, origin string) error {
136138 func (h scriptHost) PasteText(text string) error {137139 m := h.m138140 if !m.pasteText(text) {139- if err := m.sheet.Set(m.cur, strings.TrimSpace(text)); err != nil {141+ entry := strings.TrimSpace(text)142+ if bad := m.sheet.CheckEntry(m.cur, entry); bad != nil && bad.Reject {143+ return fmt.Errorf("Paste undone: %w", bad)144+ }145+ if err := m.sheet.Set(m.cur, entry); err != nil {140146 return err141147 }142148 }143- if m.mode == modeError {149+ return h.failed()150+}151+152+// failed is the script's error for what a command just reported to the153+// user in ERROR mode, if it did.154+func (h scriptHost) failed() error {155+ if h.m.mode == modeError {144156 return h.failure()145157 }146158 return nil@@ -258,9 +270,9 @@ func (h scriptHost) Fill(to string, rows, cols int) error {
258270 if !dst.From.Valid() || !dst.To.Valid() {259271 return errors.New("the fill goes off the sheet")260272 }261- got, err := m.sheet.FillSeries(src, dst)262- if err != nil {263- return err273+ got, ok := m.writeChecked("Fill", func() (sheet.Rect, error) { return m.sheet.FillSeries(src, dst) })274+ if !ok {275+ return h.failed()264276 }265277 m.selectRect(got)266278 return nil@@ -182,10 +182,6 @@ func (m *Model) pickItem(a sheet.Addr, item string) {
182182 func (m *Model) toggleCheckboxes() tea.Cmd {183183 s := m.sheet184184 on := !s.Look(m.cur).Checked185- input := "FALSE"186- if on {187- input = "TRUE"188- }189185 targets := []sheet.Addr{m.cur}190186 r := m.selection()191187 if used, ok := s.UsedRange(); r.AllRows() || r.AllCols() {@@ -210,7 +206,7 @@ func (m *Model) toggleCheckboxes() tea.Cmd {
210206 }211207 err := s.Batch(sheet.Change{Label: label, Focus: r, Sheet: s}, func() error {212208 for _, a := range targets {213- if err := s.Set(a, input); err != nil {209+ if err := s.Set(a, s.CheckboxInput(a, on)); err != nil {214210 return err215211 }216212 }@@ -259,27 +255,47 @@ func (m *Model) checkEntry(input string) (warn string, ok bool) {
259255 return bad.Error(), true260256 }261257 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())258+// writeChecked runs a paste or fill (what), which returns the range it259+// wrote, and checks what it wrote against the cells' validation, as260+// Sheets does: when a rule that rejects fails, the whole change is261+// taken back and ERROR mode says why; when only rules that warn fail,262+// it stays, the cells are marked, and the context line says how many.263+// Inside a macro's run, taking it back leaves the rest of the run as it264+// is (sheet.Workbook.Try). It reports the range and whether the change265+// stayed.266+func (m *Model) writeChecked(what string, write func() (sheet.Rect, error)) (sheet.Rect, bool) {267+ s := m.sheet268+ var r sheet.Rect269+ var bad []*sheet.InvalidEntry270+ var refused *sheet.InvalidEntry271+ kept, err := m.book().Try(func() error {272+ var err error273+ r, err = write()274+ return err275+ }, func() bool {276+ if !s.HasRules() {276277 return false277278 }278- }279- if len(bad) > 0 {279+ bad = s.InvalidIn(r)280+ for _, b := range bad {281+ if b.Reject {282+ refused = b283+ return true284+ }285+ }286+ return false287+ })288+ switch {289+ case err != nil:290+ m.fail(err.Error())291+ return r, false292+ case !kept:293+ m.fail(what + " undone: " + refused.Error())294+ return r, false295+ case len(bad) > 0:280296 m.warn = invalidNote(bad)281297 }282- return true298+ return r, true283299 }284300 285301 // checkEntryFill handles the cells an entry filled (Ctrl+Enter) that fail@@ -86,3 +86,35 @@ func TestRulesMoveWithCutAndPaste(t *testing.T) {
8686 t.Errorf("a move was refused: %s", screen(m))8787 }8888 }89+90+// A macro's pastes and fills meet validation as the keys' do: a rule91+// that rejects takes that paste or fill back and fails the script with92+// the reason, leaving what the script did before it; a rule that warns93+// keeps it.94+func TestValidationOnMacroPastesAndFills(t *testing.T) {95+ m := newModel()96+ mustValidate(t, m, `{"ranges":"B1:B9","criteria":"number","condition":"between","values":["1","10"],"reject":true}`)97+ script(t, m, `set("A1", 5)98+select("B1:B3")99+enter("=A1*2", fill=True)`)100+ if !strings.Contains(m.warn, "Fill undone: Invalid entry in B2") || input(m, "B1") != "" || m.sheet.Value(addr("A1")).Num != 5 {101+ t.Errorf("fill: warn %q, B1 %q, A1 %v", m.warn, input(m, "B1"), m.sheet.Value(addr("A1")))102+ }103+ m = newModel()104+ mustValidate(t, m, `{"ranges":"B1:B9","criteria":"number","condition":"between","values":["1","10"],"reject":true}`)105+ script(t, m, `set("A1", 5)106+set("A2", 50)107+set_formula("B1", "A1*2")108+select("B1")109+fill(rows=2)`)110+ if !strings.Contains(m.warn, "Fill undone: Invalid entry in B2") || input(m, "B2") != "" || input(m, "B1") != "=A1*2" {111+ t.Errorf("series: warn %q, B2 %q", m.warn, input(m, "B2"))112+ }113+ m = newModel()114+ mustValidate(t, m, `{"ranges":"B1:B9","criteria":"number","condition":"between","values":["1","10"],"reject":true}`)115+ script(t, m, `select("B1")116+paste_text("3\n30")`)117+ if !strings.Contains(m.warn, "Paste undone") || input(m, "B1") != "" {118+ t.Errorf("paste: warn %q, B1 %q", m.warn, input(m, "B1"))119+ }120+}