From adcb66697c422374e6da431cf4b9e16fa58013e0 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:29:23 +0100 Subject: [PATCH 1/4] Data validation and conditional formats: anchor relative formulas on the top-left cell of all areas (#3152) Excel writes the relative formulas of a multi-area rule for the top-left cell of the whole sqref (lowest row, lowest column), whatever order the areas are listed in. GridCraft anchored them on the first area listed, so a validation on `H62:H68 H26:H27 H93:H95` with `MOD($H26,$F26)=0` checked row 26 - 36 at H26 and refused valid input. Add `RangeRef::top_left` and use it in validation checks, list sources, pasting a validation, conditional format lookups and the formulas the xlsx writer generates for text, blank and error rules. --- crates/core/src/addr.rs | 16 ++++++++++++++ crates/engine/src/cf.rs | 2 +- crates/engine/src/cmd/data/validation.rs | 6 +++--- crates/engine/src/cmd/edit/paste_special.rs | 2 +- crates/engine/src/tests.rs | 24 +++++++++++++++++++++ crates/xlsx/src/sheet_write/rules.rs | 2 +- crates/xlsx/src/tests/roundtrip.rs | 18 ++++++++++++++++ 7 files changed, 64 insertions(+), 6 deletions(-) diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs index 9d10ea87c..c8ddf7460 100644 --- a/crates/core/src/addr.rs +++ b/crates/core/src/addr.rs @@ -188,6 +188,12 @@ impl RangeRef { end: CellRef::new(self.end.row.max(o.end.row), self.end.col.max(o.end.col)), } } + /// The top-left cell of a list of areas: the lowest row and the lowest column across all of + /// them, whatever order they are listed in. Excel writes the relative formulas of a + /// multi-area validation or conditional format for this cell. `None` for no areas. + pub fn top_left(ranges: &[RangeRef]) -> Option { + ranges.iter().copied().reduce(|a, b| a.union(&b)).map(|r| r.start) + } /// Row-major iterator over the cells. Callers must bound huge ranges themselves. pub fn iter(&self) -> impl Iterator + '_ { let (r0, r1, c0, c1) = (self.start.row, self.end.row, self.start.col, self.end.col); @@ -307,6 +313,16 @@ mod tests { Ok(()) } + #[test] + fn top_left_spans_every_area_in_any_order() { + let areas: Vec = ["H62:H68", "H26:H27", "H93:H95"].iter().filter_map(|a| RangeRef::parse(a)).collect(); + assert_eq!(RangeRef::top_left(&areas), CellRef::parse("H26")); + // Lowest row and lowest column, even when they come from different areas. + let areas: Vec = ["C5:D6", "E2", "B9"].iter().filter_map(|a| RangeRef::parse(a)).collect(); + assert_eq!(RangeRef::top_left(&areas), CellRef::parse("B2")); + assert_eq!(RangeRef::top_left(&[]), None); + } + #[test] fn sheet_quoting_follows_excel() { for quoted in [ diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs index 4c9325e36..38f5bb1fe 100644 --- a/crates/engine/src/cf.rs +++ b/crates/engine/src/cf.rs @@ -244,7 +244,7 @@ impl CfCache { let v = sh.value(c); let no_stats = RuleStats::default(); for (idx, cf) in rules { - let first = cf.ranges.first().map(|r| r.start).unwrap_or(c); + let first = RangeRef::top_left(&cf.ranges).unwrap_or(c); let st: &RuleStats = match stats_need(&cf.rule) { Some(need) => &*self.stats.entry(idx).or_insert_with(|| RuleStats::build(sh, &cf.ranges, need)), None => &no_stats, diff --git a/crates/engine/src/cmd/data/validation.rs b/crates/engine/src/cmd/data/validation.rs index ce0d7e3b8..ff8a93d61 100644 --- a/crates/engine/src/cmd/data/validation.rs +++ b/crates/engine/src/cmd/data/validation.rs @@ -156,7 +156,7 @@ pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec { }; return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(); } - let at = dv.ranges.first().map(|r| r.start).unwrap_or_default(); + let at = RangeRef::top_left(&dv.ranges).unwrap_or_default(); match gridcraft_calc::evaluate(wb, sheet, at, f) { Value::Array(a) => a.data.iter().filter(|v| !v.is_empty()).map(Value::display).collect(), v if !v.is_empty() && !v.is_error() => vec![v.display()], @@ -175,9 +175,9 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) - return None; } let v = gridcraft_core::parse::parse_input(input, wb.date_system).value; - // The formulas are written for the first cell of the range and see the value being entered, + // The formulas are written for the top-left cell of all the areas and see the value being entered, // as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5). - let first = dv.ranges.first().map(|r| r.start).unwrap_or(at); + let first = RangeRef::top_left(&dv.ranges).unwrap_or(at); let entered; let wb = if sh.value(at) == v { wb diff --git a/crates/engine/src/cmd/edit/paste_special.rs b/crates/engine/src/cmd/edit/paste_special.rs index c9e296ba5..9506b4abf 100644 --- a/crates/engine/src/cmd/edit/paste_special.rs +++ b/crates/engine/src/cmd/edit/paste_special.rs @@ -248,7 +248,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]); if let Some(mut dv) = rule { // Relative references move with the rule, as pasted formulas do. - let first = dv.ranges.first().map_or(sc, |r| r.start); + let first = RangeRef::top_left(&dv.ranges).unwrap_or(sc); dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest); dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest)); dv.ranges = vec![RangeRef::new(dest, dest)]; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5afcbf9fa..f3d08f863 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2743,6 +2743,30 @@ fn validation_formulas_are_relative_and_see_the_new_value() { assert!(!ok(&mut s, "C1", "50")); } +#[test] +fn multi_area_rule_formulas_are_relative_to_the_top_left_cell_of_all_areas() { + // Issue #3152: Excel writes the formula for the top-left cell of all the areas (H26), whatever + // order they are listed in, not for the first area listed (H62). + let mut s = s(); + s.execute("range.setValues", json!({"range": "F26:F27", "values": [[4], [5]]})).unwrap(); + s.execute("data.validation", json!({"range": "H62:H68,H26:H27,H93:H95", "type": "custom", "formula1": "=MOD($H26,$F26)=0"})).unwrap(); + let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true; + assert!(ok(&mut s, "H26", "4")); + assert!(ok(&mut s, "H26", "8")); + assert!(!ok(&mut s, "H26", "3")); + assert!(ok(&mut s, "H27", "10")); + assert!(!ok(&mut s, "H27", "4")); + + // Conditional formats anchor on the same cell: `=D2>0` on D5:D6,D2:D3 checks D2 at D2. + s.execute("range.setValues", json!({"range": "D2:D6", "values": [[1], [0], [0], [1], [0]]})).unwrap(); + s.execute("home.conditionalFormat", json!({"range": "D5:D6,D2:D3", "rule": {"type": "expression", "formula": "=D2>0", "preset": "greenFill"}})) + .unwrap(); + let wb = s.doc().unwrap().wb.clone(); + let mut cache = crate::cf::CfCache::new(); + let mut styled = |c: &str| cache.look(&wb, 0, CellRef::parse(c).unwrap()).and_then(|l| l.style).is_some(); + assert_eq!([styled("D2"), styled("D3"), styled("D5"), styled("D6")], [true, false, true, false]); +} + #[test] fn samples_build() { for (name, _) in crate::sample::SAMPLES { diff --git a/crates/xlsx/src/sheet_write/rules.rs b/crates/xlsx/src/sheet_write/rules.rs index 821ec4cef..584c28c01 100644 --- a/crates/xlsx/src/sheet_write/rules.rs +++ b/crates/xlsx/src/sheet_write/rules.rs @@ -53,7 +53,7 @@ pub(super) fn x14_cfvo(v: &CfValueKind) -> String { } pub(super) fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String { - let tl = cf.ranges.first().map(|r| r.start.a1()).unwrap_or_else(|| "A1".into()); + let tl = RangeRef::top_left(&cf.ranges).map(|c| c.a1()).unwrap_or_else(|| "A1".into()); let stop = if cf.stop_if_true { " stopIfTrue=\"1\"" } else { "" }; let mut dxf = |st: &Style| dxf_id(&mut out.dxfs, st); let f = |x: &str| format!("{}", esc(&text_to_file(x))); diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 1cbd845c9..e194a307d 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -1678,3 +1678,21 @@ fn workbook_view_writer_clamps_out_of_range_model_values() { assert_eq!(back.view.tab_ratio, 1000); } } + +#[test] +fn generated_rule_formulas_use_the_top_left_cell_of_every_area() { + // Issue #3152: the areas aren't in top-to-bottom order; the formula is written for C2, not C5. + let mut wb = Workbook::new(); + let red = Box::new(Style::default()); + wb.sheet_mut(0).unwrap().cond_formats.push(CondFormat { + ranges: vec![rr("C5:C6"), rr("C2:C3")], + rule: CfRule::ContainsText { text: "x".into(), style: red }, + priority: 1, + stop_if_true: false, + }); + let bytes = write_xlsx(&wb).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut xml = String::new(); + z.by_name("xl/worksheets/sheet1.xml").unwrap().read_to_string(&mut xml).unwrap(); + assert!(xml.contains("NOT(ISERROR(SEARCH(\"x\",C2)))"), "{xml}"); +} From fb6dc688bc7ceb2fd14484cb8a754ff78f523e13 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:18:50 +0100 Subject: [PATCH 2/4] Rewrite rule formulas for the new top-left cell when cells are removed from a rule Clearing validation from cells (or setting a new rule, or pasting a validation over them) and Clear Rules on selected cells shrink a rule's areas. When that removes the rule's top-left cell, the relative formulas were left written for the old cell, so the cells left checked other cells: clearing A1 off a custom `A1>10` on A1:A9 made A2 check A1. Rewrite the relative references of validation formulas, and of conditional format expressions and comparison values, for the new top-left cell. Absolute references and literal lists stay as they are. --- crates/engine/src/cmd/data/validation.rs | 7 ++++ crates/engine/src/cmd/insert.rs | 19 ++++++++++ crates/engine/src/tests.rs | 48 ++++++++++++++++++++++++ crates/xlsx/src/sheet_write/rules.rs | 2 + 4 files changed, 76 insertions(+) diff --git a/crates/engine/src/cmd/data/validation.rs b/crates/engine/src/cmd/data/validation.rs index ff8a93d61..58c445225 100644 --- a/crates/engine/src/cmd/data/validation.rs +++ b/crates/engine/src/cmd/data/validation.rs @@ -38,11 +38,18 @@ fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec { } /// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges. +/// When the rule's top-left cell goes, its relative formulas are rewritten for the new top-left +/// cell, so the cells left keep checking the same cells. pub(in super::super) fn remove_validation_cells(validations: &mut Vec, cuts: &[RangeRef]) { for dv in validations.iter_mut() { + let before = RangeRef::top_left(&dv.ranges); for cut in cuts { dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect(); } + if let (Some(from), Some(to)) = (before, RangeRef::top_left(&dv.ranges)) { + dv.f1 = move_rule_formula(&dv.f1, from, to); + dv.f2 = dv.f2.take().map(|f| move_rule_formula(&f, from, to)); + } } validations.retain(|d| !d.ranges.is_empty()); } diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 59b085213..1d763bb94 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1730,7 +1730,13 @@ fn clear_cf(s: &mut Session, p: &Json) -> Result { sh.cond_formats.clear(); } else { for cf in sh.cond_formats.iter_mut() { + let before = RangeRef::top_left(&cf.ranges); cf.ranges.retain(|r| !ranges.iter().any(|x| x.intersects(r))); + // The relative formulas are written for the top-left cell: when it goes, they're + // rewritten for the new one, so the areas left keep reading the same cells. + if let (Some(from), Some(to)) = (before, RangeRef::top_left(&cf.ranges)) { + rebase_cf_formulas(&mut cf.rule, from, to); + } } sh.cond_formats.retain(|c| !c.ranges.is_empty()); } @@ -1738,6 +1744,19 @@ fn clear_cf(s: &mut Session, p: &Json) -> Result { }) } +/// Moves the relative references of a rule's per-cell formulas written for `from` to `to`. +fn rebase_cf_formulas(rule: &mut CfRule, from: CellRef, to: CellRef) { + let mv = |f: &str| crate::cmd::data::move_rule_formula(f, from, to); + match rule { + CfRule::CellIs { a, b, .. } => { + *a = mv(a); + *b = b.as_deref().map(mv); + } + CfRule::Expression { formula, .. } => *formula = mv(formula), + _ => {} + } +} + fn manage_cf(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; if p.get("delete").is_none() && p.get("moveUp").is_none() && p.get("moveDown").is_none() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3d08f863..09cd58eda 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2767,6 +2767,54 @@ fn multi_area_rule_formulas_are_relative_to_the_top_left_cell_of_all_areas() { assert_eq!([styled("D2"), styled("D3"), styled("D5"), styled("D6")], [true, false, true, false]); } +#[test] +fn removing_a_rules_top_left_cell_rewrites_its_formulas_for_the_new_one() { + let mut s = s(); + let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true; + let f1 = |s: &Session| s.doc().unwrap().wb.active().unwrap().validations[0].f1.clone(); + + // One area: clearing A1 off A1:A9 leaves A2:A9, whose cells still check themselves. + s.execute("data.validation", json!({"range": "A1:A9", "type": "custom", "formula1": "=A1>10"})).unwrap(); + s.execute("data.validation", json!({"range": "A1", "clear": true})).unwrap(); + assert_eq!(f1(&s), "A2>10"); + assert!(ok(&mut s, "A2", "20")); + assert!(!ok(&mut s, "A2", "5")); + s.execute("data.validation", json!({"range": "A1:A9", "clear": true})).unwrap(); + + // Several areas: clearing H26 moves the top-left cell to H27, where `$H27,$F27` is checked. + s.execute("range.setValues", json!({"range": "F26:F27", "values": [[4], [5]]})).unwrap(); + s.execute("data.validation", json!({"range": "H62:H68,H26:H27", "type": "custom", "formula1": "=MOD($H26,$F26)=0"})).unwrap(); + s.execute("data.validation", json!({"range": "H26", "clear": true})).unwrap(); + assert_eq!(f1(&s), "MOD($H27,$F27)=0"); + assert!(ok(&mut s, "H27", "10")); + assert!(!ok(&mut s, "H27", "4")); + // Absolute and literal formulas don't move. + s.execute("data.validation", json!({"range": "J1:J3", "type": "list", "formula1": "=$B$1:$B$2"})).unwrap(); + s.execute("data.validation", json!({"range": "J1", "clear": true})).unwrap(); + let dv = s.doc().unwrap().wb.active().unwrap().validations.iter().find(|d| d.ranges[0].a1() == "J2:J3").unwrap().f1.clone(); + assert_eq!(dv, "$B$1:$B$2"); + + // Conditional formats: Clear Rules on D2 drops the D2:D3 area, and D5:D6 keeps reading itself. + s.execute("range.setValues", json!({"range": "D2:D6", "values": [[1], [0], [0], [0], [1]]})).unwrap(); + s.execute("home.conditionalFormat", json!({"range": "D5:D6,D2:D3", "rule": {"type": "expression", "formula": "=D2>0", "preset": "greenFill"}})) + .unwrap(); + s.execute("range.setValues", json!({"range": "E2:E6", "values": [[1], [0], [0], [0], [1]]})).unwrap(); + s.execute("range.setValues", json!({"range": "G2:G6", "values": [[0], [9], [9], [9], [0]]})).unwrap(); + s.execute( + "home.conditionalFormat", + json!({"range": "E5:E6,E2:E3", "rule": {"type": "cellIs", "operator": "greater", "value": "=G2", "preset": "lightRedFill"}}), + ) + .unwrap(); + s.execute("home.clearRules", json!({"range": "D2,E2"})).unwrap(); + let rules: Vec = s.doc().unwrap().wb.active().unwrap().cond_formats.iter().map(|c| c.rule.clone()).collect(); + assert!(rules.iter().any(|r| matches!(r, gridcraft_model::CfRule::Expression { formula, .. } if formula == "D5>0")), "{rules:?}"); + assert!(rules.iter().any(|r| matches!(r, gridcraft_model::CfRule::CellIs { a, .. } if a == "G5")), "{rules:?}"); + let wb = s.doc().unwrap().wb.clone(); + let mut cache = crate::cf::CfCache::new(); + let mut styled = |c: &str| cache.look(&wb, 0, CellRef::parse(c).unwrap()).and_then(|l| l.style).is_some(); + assert_eq!([styled("D5"), styled("D6"), styled("E5"), styled("E6")], [false, true, false, true]); +} + #[test] fn samples_build() { for (name, _) in crate::sample::SAMPLES { diff --git a/crates/xlsx/src/sheet_write/rules.rs b/crates/xlsx/src/sheet_write/rules.rs index 584c28c01..00b0ca524 100644 --- a/crates/xlsx/src/sheet_write/rules.rs +++ b/crates/xlsx/src/sheet_write/rules.rs @@ -52,6 +52,8 @@ pub(super) fn x14_cfvo(v: &CfValueKind) -> String { format!("{}", esc(&val)) } +/// One `cfRule` element. The formulas generated for text, blank, error and date rules refer to the +/// top-left cell of all the rule's areas, the cell Excel reads relative references from. pub(super) fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String { let tl = RangeRef::top_left(&cf.ranges).map(|c| c.a1()).unwrap_or_else(|| "A1".into()); let stop = if cf.stop_if_true { " stopIfTrue=\"1\"" } else { "" }; From 179664610c628dbf8236dd9defe47aca33b0153c Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:23:48 +0100 Subject: [PATCH 3/4] Read relative list validation sources from the cell being checked A list source such as =$B1:$B2 is written for the rule's top-left cell. list_items evaluated it there for every cell, so the dropdown and the check showed and accepted the top-left cell's items everywhere. It now takes the cell and shifts the source to it, as Excel does. --- crates/engine/src/cmd/data/tests.rs | 19 +++++++++++++++++++ crates/engine/src/cmd/data/validation.rs | 12 +++++++----- crates/ui-egui/src/grid/editor.rs | 2 +- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/data/tests.rs b/crates/engine/src/cmd/data/tests.rs index 5b848e5c0..3bca70a87 100644 --- a/crates/engine/src/cmd/data/tests.rs +++ b/crates/engine/src/cmd/data/tests.rs @@ -480,6 +480,25 @@ fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() { assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules"); } +#[test] +fn a_relative_list_source_is_read_from_the_cell_being_checked() { + // As in Excel, `=$B1:$B2` on A3:A4,A1:A2 is written for A1 (the top-left cell of all the + // areas) and lists B4:B5 at A4. It used to list B1:B2 in every cell. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "B1:B5", "values": [["a"], ["b"], ["c"], ["d"], ["e"]]})).unwrap(); + s.execute("data.validation", json!({"range": "A3:A4,A1:A2", "type": "list", "formula1": "=$B1:$B2"})).unwrap(); + let wb = s.doc().unwrap().wb.clone(); + let dv = wb.active().unwrap().validations[0].clone(); + for (cell, items) in [("A1", ["a", "b"]), ("A2", ["b", "c"]), ("A3", ["c", "d"]), ("A4", ["d", "e"])] { + let at = CellRef::parse(cell).unwrap(); + assert_eq!(list_items(&wb, 0, at, &dv), items, "{cell}"); + for v in ["a", "b", "c", "d", "e"] { + assert_eq!(check_validation(&wb, 0, at, v).is_none(), items.contains(&v), "{cell} with {v}"); + } + } +} + #[test] fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() { // A typo used to give a rule that validates nothing (`type="none"`) or the wrong check, diff --git a/crates/engine/src/cmd/data/validation.rs b/crates/engine/src/cmd/data/validation.rs index 58c445225..71b3b2190 100644 --- a/crates/engine/src/cmd/data/validation.rs +++ b/crates/engine/src/cmd/data/validation.rs @@ -152,8 +152,10 @@ fn list_source(f: &str) -> String { format!("\"{}\"", t.replace('"', "\"\"")) } -/// List entries of a list validation (literal or from a range). -pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec { +/// List entries of a list validation (literal or from a range) for the cell `at`. A relative +/// source is written for the rule's top-left cell and read from `at`, as in Excel (`=$B1:$B3` on +/// A1:A9 lists B5:B7 at A5). +pub fn list_items(wb: &Workbook, sheet: usize, at: CellRef, dv: &Validation) -> Vec { let f = dv.f1.trim(); if is_literal_list(f) { // A quoted list is a string literal: `""` inside stands for one `"`. @@ -163,8 +165,8 @@ pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec { }; return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(); } - let at = RangeRef::top_left(&dv.ranges).unwrap_or_default(); - match gridcraft_calc::evaluate(wb, sheet, at, f) { + let first = RangeRef::top_left(&dv.ranges).unwrap_or(at); + match gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at)) { Value::Array(a) => a.data.iter().filter(|v| !v.is_empty()).map(Value::display).collect(), v if !v.is_empty() && !v.is_error() => vec![v.display()], _ => vec![], @@ -200,7 +202,7 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) - let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at)); let num = |f: &str| eval(f).to_number().ok(); let ok = match dv.kind { - ValidationKind::List => list_items(wb, sheet, dv).iter().any(|x| x.eq_ignore_ascii_case(input)), + ValidationKind::List => list_items(wb, sheet, at, dv).iter().any(|x| x.eq_ignore_ascii_case(input)), ValidationKind::Custom => eval(&dv.f1).to_bool().unwrap_or(false), ValidationKind::TextLength => { let n = input.chars().count() as f64; diff --git a/crates/ui-egui/src/grid/editor.rs b/crates/ui-egui/src/grid/editor.rs index 59f375e80..72b7cc7bb 100644 --- a/crates/ui-egui/src/grid/editor.rs +++ b/crates/ui-egui/src/grid/editor.rs @@ -125,7 +125,7 @@ pub(super) fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_ app.grid.list_picker = None; return; }; - let items = gridcraft_engine::cmd::data::list_items(&wb, si, &dv); + let items = gridcraft_engine::cmd::data::list_items(&wb, si, c, &dv); let r = geo.cell_rect(sh, c); // Fit the popup to its rows (up to LIST_PICKER_MAX_ROWS); without a minimum the scroll area collapses to its 64 px default. let height = list_picker_height(items.len(), LIST_PICKER_ROW_H, ui.spacing().item_spacing.y); From 342f4b401d0cb21bea2de64c089032a8922a4bc7 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:26:30 +0100 Subject: [PATCH 4/4] Check list validation against the list before the entry A source that takes in the cell being checked (=$A1:$A2 on A2:A3 is A2:A3 at A3) listed the value being typed, so any new value passed. The list is now read from the workbook as it was before the entry, as in Excel. --- crates/engine/src/cmd/data/tests.rs | 20 ++++++++++++++++++++ crates/engine/src/cmd/data/validation.rs | 5 ++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/data/tests.rs b/crates/engine/src/cmd/data/tests.rs index 3bca70a87..3c2782444 100644 --- a/crates/engine/src/cmd/data/tests.rs +++ b/crates/engine/src/cmd/data/tests.rs @@ -499,6 +499,26 @@ fn a_relative_list_source_is_read_from_the_cell_being_checked() { } } +#[test] +fn a_list_source_that_takes_in_the_cell_does_not_list_the_value_being_typed() { + // `=$A1:$A2` on A2:A3 is A1:A2 at A2 and A2:A3 at A3. The list is the one before the entry, as + // in Excel; a new value used to land in its own source and pass. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1:A2", "values": [["x"], ["y"]]})).unwrap(); + s.execute("data.validation", json!({"range": "A2:A3", "type": "list", "formula1": "=$A1:$A2"})).unwrap(); + let wb = &s.doc().unwrap().wb; + for (cell, accepted, refused) in [("A2", &["x", "y"][..], &["new"][..]), ("A3", &["y"], &["x", "new"])] { + let at = CellRef::parse(cell).unwrap(); + for v in accepted { + assert!(check_validation(wb, 0, at, v).is_none(), "{cell} refuses {v}"); + } + for v in refused { + assert!(check_validation(wb, 0, at, v).is_some(), "{cell} accepts {v}"); + } + } +} + #[test] fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() { // A typo used to give a rule that validates nothing (`type="none"`) or the wrong check, diff --git a/crates/engine/src/cmd/data/validation.rs b/crates/engine/src/cmd/data/validation.rs index 71b3b2190..2217a372a 100644 --- a/crates/engine/src/cmd/data/validation.rs +++ b/crates/engine/src/cmd/data/validation.rs @@ -187,6 +187,7 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) - // The formulas are written for the top-left cell of all the areas and see the value being entered, // as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5). let first = RangeRef::top_left(&dv.ranges).unwrap_or(at); + let original = wb; let entered; let wb = if sh.value(at) == v { wb @@ -202,7 +203,9 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) - let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at)); let num = |f: &str| eval(f).to_number().ok(); let ok = match dv.kind { - ValidationKind::List => list_items(wb, sheet, at, dv).iter().any(|x| x.eq_ignore_ascii_case(input)), + // The list is the one the dropdown showed before the entry, as in Excel: a source that takes + // in the cell itself (`=$A1:$A2` on A2:A3 is A2:A3 at A3) doesn't list the value being typed. + ValidationKind::List => list_items(original, sheet, at, dv).iter().any(|x| x.eq_ignore_ascii_case(input)), ValidationKind::Custom => eval(&dv.f1).to_bool().unwrap_or(false), ValidationKind::TextLength => { let n = input.chars().count() as f64;