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/tests.rs b/crates/engine/src/cmd/data/tests.rs index 5b848e5c0..3c2782444 100644 --- a/crates/engine/src/cmd/data/tests.rs +++ b/crates/engine/src/cmd/data/tests.rs @@ -480,6 +480,45 @@ 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_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 ce0d7e3b8..2217a372a 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()); } @@ -145,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 `"`. @@ -156,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 = dv.ranges.first().map(|r| r.start).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![], @@ -175,9 +184,10 @@ 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 original = wb; let entered; let wb = if sh.value(at) == v { wb @@ -193,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, 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; diff --git a/crates/engine/src/cmd/edit/paste_special.rs b/crates/engine/src/cmd/edit/paste_special.rs index 2477dc1a6..a19bceb67 100644 --- a/crates/engine/src/cmd/edit/paste_special.rs +++ b/crates/engine/src/cmd/edit/paste_special.rs @@ -284,7 +284,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/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index dbd4d9d78..b713061c2 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1733,7 +1733,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()); } @@ -1741,6 +1747,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 22e71330a..94a80f824 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2743,6 +2743,78 @@ 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 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/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); diff --git a/crates/xlsx/src/sheet_write/rules.rs b/crates/xlsx/src/sheet_write/rules.rs index 821ec4cef..00b0ca524 100644 --- a/crates/xlsx/src/sheet_write/rules.rs +++ b/crates/xlsx/src/sheet_write/rules.rs @@ -52,8 +52,10 @@ 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 = 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 74d450bb2..2bdba21b8 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}"); +}