diff --git a/apps/gridcraft-cli/src/check/calc.rs b/apps/gridcraft-cli/src/check/calc.rs index a3f33d296..8ce378071 100644 --- a/apps/gridcraft-cli/src/check/calc.rs +++ b/apps/gridcraft-cli/src/check/calc.rs @@ -31,20 +31,41 @@ fn is_volatile(e: &Expr, volatile_names: &HashSet) -> bool { } /// Upper-cased defined names whose formula is volatile, directly or through other names. +/// +/// Volatility flows backwards along name references: each name is parsed and walked once, and a +/// worklist carries volatility from a name to the names that use it. Rescanning every name until +/// nothing changes was quadratic: a file listing its names so that each pass marked only one more +/// did one full pass per name. pub(super) fn volatile_names(wb: &Workbook) -> HashSet { - let parsed: Vec<(String, Option)> = - wb.names.iter().map(|n| (n.name.to_ascii_uppercase(), gridcraft_engine::formula::parse(&n.formula).ok())).collect(); let mut set = HashSet::new(); - // Each round adds at least one name or stops, so `len + 1` rounds reach the fixed point. - for _ in 0..=parsed.len() { - let before = set.len(); - for (name, e) in &parsed { - if !set.contains(name) && e.as_ref().is_some_and(|e| is_volatile(e, &set)) { - set.insert(name.clone()); + // Upper-cased name -> the names whose formulas refer to it. + let mut users: HashMap> = HashMap::new(); + let mut queue = Vec::new(); + let none = HashSet::new(); + for n in &wb.names { + let name = n.name.to_ascii_uppercase(); + let Ok(e) = gridcraft_engine::formula::parse(&n.formula) else { continue }; + if is_volatile(&e, &none) { + if set.insert(name.clone()) { + queue.push(name.clone()); } + continue; } - if set.len() == before { - break; + let mut used = BTreeSet::new(); + e.walk(&mut |x| { + if let Expr::Name(r) = x { + used.insert(r.to_ascii_uppercase()); + } + }); + for r in used { + users.entry(r).or_default().push(name.clone()); + } + } + while let Some(v) = queue.pop() { + for user in users.remove(&v).unwrap_or_default() { + if set.insert(user.clone()) { + queue.push(user); + } } } set diff --git a/apps/gridcraft-cli/src/check/tests.rs b/apps/gridcraft-cli/src/check/tests.rs index 68c6f8fb5..57baa2198 100644 --- a/apps/gridcraft-cli/src/check/tests.rs +++ b/apps/gridcraft-cli/src/check/tests.rs @@ -178,6 +178,28 @@ fn volatile_names_propagate() { assert_eq!(check(&wb, &Options::default()).calc.volatile, 1); } +#[test] +fn volatile_names_propagate_through_a_long_chain_in_one_walk() { + // Chain0 uses Chain1, Chain1 uses Chain2, ... and only the last is volatile: listed in this order, a rescan + // until nothing changes marked one name per pass (quadratic). Every name in the chain is + // volatile; a name off the chain is not. + const LEN: usize = 5_000; + let mut wb = book(&[("A1", "=1", n(1.0))]); + let name = + |name: String, formula: String| gridcraft_engine::model::DefinedName { name, scope: None, formula, comment: String::new(), hidden: false }; + for i in 0..LEN { + let formula = if i + 1 == LEN { "NOW()".to_string() } else { format!("Chain{}+1", i + 1) }; + wb.names.push(name(format!("Chain{i}"), formula)); + } + wb.names.push(name("Plain".into(), "Chain0*0+Other".into())); + wb.names.push(name("Other".into(), "1".into())); + let set = volatile_names(&wb); + assert!((0..LEN).all(|i| set.contains(&format!("CHAIN{i}")))); + assert!(set.contains("PLAIN")); + assert!(!set.contains("OTHER")); + assert_eq!(set.len(), LEN + 1); +} + #[test] fn round_trip_of_a_simple_book_is_clean() { let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]); diff --git a/crates/chart/src/render/cartesian.rs b/crates/chart/src/render/cartesian.rs index ce0bbebb0..05105dbf4 100644 --- a/crates/chart/src/render/cartesian.rs +++ b/crates/chart/src/render/cartesian.rs @@ -1,6 +1,6 @@ use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range}; use super::legend::{cat_label, plot_kind}; -use super::lines::{decimate, draw_markers, draw_runs}; +use super::lines::{decimate_area, draw_markers, draw_runs}; use super::trend::{draw_trend_labels, draw_trendlines, max_backward, max_forward, max_forward_slots, supports_trendline}; use super::{ Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, Prim, Scale, SeriesData, TEXT, @@ -268,10 +268,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx base = vec![[b[0] - cw * 0.3, b[1]], [b[0] + cw * 0.3, b[1]]]; } } - let top = decimate(top, MAX_LINE_PTS * 2); - let base = decimate(base, MAX_LINE_PTS * 2); - let mut poly = top; - poly.extend(base.into_iter().rev()); + let poly = decimate_area(top, base, MAX_LINE_PTS * 2); if poly.len() >= 3 { ctx.out.push(Prim::Polygon { pts: poly, fill: s.color, stroke: None }); } diff --git a/crates/chart/src/render/lines.rs b/crates/chart/src/render/lines.rs index 23321903a..1a978ec69 100644 --- a/crates/chart/src/render/lines.rs +++ b/crates/chart/src/render/lines.rs @@ -54,6 +54,86 @@ pub(super) fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> { out } +/// Decimates the top and baseline of an area chart together at the same horizontal indices so +/// `area_mesh`'s paired vertical-strip topology (`pts[i]` ↔ `pts[2n - 1 - i]`) is preserved. +pub(super) fn decimate_area(top: Vec<[f32; 2]>, base: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> { + let n = top.len().min(base.len()); + if n == 0 { + return Vec::new(); + } + if n <= max { + let mut poly = top; + poly.truncate(n); + poly.extend(base.into_iter().take(n).rev()); + return poly; + } + let mut cols: Vec<[usize; 6]> = Vec::new(); + let mut keep = Vec::with_capacity(max * 2); + let mut i = 0; + while i < n { + let col = top[i][0].floor(); + let (start, mut t_mn, mut t_mx, mut b_mn, mut b_mx) = (i, i, i, i, i); + let mut k = i + 1; + while k < n && top[k][0].floor() == col { + if top[k][1] < top[t_mn][1] { + t_mn = k; + } + if top[k][1] > top[t_mx][1] { + t_mx = k; + } + if base[k][1] < base[b_mn][1] { + b_mn = k; + } + if base[k][1] > base[b_mx][1] { + b_mx = k; + } + k += 1; + } + let entry = [start, t_mn, t_mx, b_mn, b_mx, k - 1]; + cols.push(entry); + let mut idx = entry; + idx.sort_unstable(); + let mut last = usize::MAX; + for ix in idx { + if ix != last { + keep.push(ix); + last = ix; + } + } + i = k; + } + if keep.len() > max * 4 { + let max_groups = (max * 4 / 6).max(1); + let group_size = cols.len().div_ceil(max_groups); + keep.clear(); + for ch in cols.chunks(group_size) { + let start = ch[0][0]; + let end = ch.last().map_or(start, |c| c[5]); + let t_mn = ch.iter().map(|c| c[1]).min_by(|&a, &b| top[a][1].total_cmp(&top[b][1])).unwrap_or(start); + let t_mx = ch.iter().map(|c| c[2]).max_by(|&a, &b| top[a][1].total_cmp(&top[b][1])).unwrap_or(start); + let b_mn = ch.iter().map(|c| c[3]).min_by(|&a, &b| base[a][1].total_cmp(&base[b][1])).unwrap_or(start); + let b_mx = ch.iter().map(|c| c[4]).max_by(|&a, &b| base[a][1].total_cmp(&base[b][1])).unwrap_or(start); + let mut idx = [start, t_mn, t_mx, b_mn, b_mx, end]; + idx.sort_unstable(); + let mut last = usize::MAX; + for ix in idx { + if ix != last { + keep.push(ix); + last = ix; + } + } + } + } + let mut poly = Vec::with_capacity(keep.len() * 2); + for &ix in &keep { + poly.push(top[ix]); + } + for &ix in keep.iter().rev() { + poly.push(base[ix]); + } + poly +} + /// Draws a polyline split at gaps (`None`), decimated when long. With `smooth` (the plot box), /// each run is interpolated with a Catmull-Rom spline so the line curves through the data points. /// @@ -183,4 +263,57 @@ mod smooth_tests { // The first runs are still smoothed. assert!(matches!(ctx.out.first(), Some(Prim::Line { pts, .. }) if pts.len() > 3)); } + + #[test] + fn decimate_area_preserves_paired_vertical_strips() { + let top: Vec<[f32; 2]> = (0..1000).map(|i| [i as f32 * 0.25, ((i * 37) % 100) as f32]).collect(); + let base: Vec<[f32; 2]> = (0..1000).map(|i| [i as f32 * 0.25, 100.0]).collect(); + let poly = decimate_area(top, base, 64); + assert_eq!(poly.len() % 2, 0); + let half = poly.len() / 2; + for i in 0..half { + assert_eq!(poly[i][0], poly[poly.len() - 1 - i][0]); + assert_eq!(poly[poly.len() - 1 - i][1], 100.0); + } + } + + #[test] + fn decimate_area_preserves_extrema_across_3000_columns() { + // 3,000 occupied pixel columns with 6 points per column (18,000 points total), + // where each column has distinct top and baseline extrema so the initial per-column + // index list exceeds `MAX_LINE_PTS * 2 * 4` (6,400). + let max = MAX_LINE_PTS * 2; + let mut top = Vec::with_capacity(18_000); + let mut base = Vec::with_capacity(18_000); + for col in 0..3_000 { + let y_top = [50.0, 20.0, 80.0, 50.0, 50.0, 50.0]; + let y_base = [150.0, 150.0, 150.0, 120.0, 180.0, 150.0]; + for s in 0..6 { + let x = col as f32 + s as f32 * 0.1; + top.push([x, y_top[s]]); + base.push([x, y_base[s]]); + } + } + // Plant sharp spikes on both top and baseline in interior columns. + top[777 * 6 + 1][1] = 1.0; + top[1999 * 6 + 2][1] = 99.0; + base[1234 * 6 + 3][1] = 101.0; + base[2501 * 6 + 4][1] = 199.0; + + let poly = decimate_area(top, base, max); + assert_eq!(poly.len() % 2, 0); + let half = poly.len() / 2; + assert!(half <= max * 4, "retained {half} paired vertices, limit is {}", max * 4); + for i in 0..half { + assert_eq!(poly[i][0], poly[poly.len() - 1 - i][0]); + } + let top_kept = &poly[..half]; + let base_kept = &poly[half..]; + assert_eq!(top_kept.first().map(|p| p[0]), Some(0.0)); + assert_eq!(top_kept.last().map(|p| p[0]), Some(2999.5)); + assert!(top_kept.iter().any(|p| p[1] == 1.0), "top min spike preserved"); + assert!(top_kept.iter().any(|p| p[1] == 99.0), "top max spike preserved"); + assert!(base_kept.iter().any(|p| p[1] == 101.0), "base min spike preserved"); + assert!(base_kept.iter().any(|p| p[1] == 199.0), "base max spike preserved"); + } } 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.rs b/crates/engine/src/cmd.rs index 77fbb1d7a..1148a6b6e 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -459,8 +459,11 @@ pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000; pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet."; /// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or -/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or -/// deleting a sheet isn't an edit of its cells. +/// formula), or that unlocked it, whichever command made it. Sheets are matched by name, so +/// moving, renaming or deleting a sheet isn't an edit of its cells. A sort or duplicate removal +/// that moves an unlocked cell with equal content onto a locked one changes no content but would +/// leave the cell editable, so the effective lock (row and column formats included) is compared +/// as well. fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)]) -> Result<()> { for sh in &old.sheets { if !sh.is_protected() { @@ -471,7 +474,8 @@ fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String) continue; } for c in sh.cells.diff(&after.cells) { - if !same_content(sh.cell(c), after.cell(c)) + let unlocked_now = !new.styles.get(after.style_id(c)).protection.locked; + if (unlocked_now || !same_content(sh.cell(c), after.cell(c))) && old.styles.get(sh.style_id(c)).protection.locked && let Some(m) = locked_cell_error(sh, c, unlocked) { 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/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 69e04dae6..95a0c93f5 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -398,10 +398,10 @@ fn move_columns(s: &mut Session, p: &Json) -> Result { { return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into())); } - // The checks must authorize the sheet this command modifies (`target_sheet`), not whatever - // sheet is active: both carry the command's own `sheet` parameter on. - check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": p.get("sheet")}), Axis::Cols, before, count, true)?; - check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": p.get("sheet")}), Axis::Cols, from, count, false)?; + // The checks must authorize the sheet this command modifies, not whatever sheet is active: + // both get the index `target_sheet` resolved (from `sheet`, or a `Sheet!` range or cell). + check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": sheet}), Axis::Cols, before, count, true)?; + check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": sheet}), Axis::Cols, from, count, false)?; let moved_from = if before <= from { from + count } else { from }; if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 22e71330a..5f92718ba 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 { @@ -5225,6 +5297,42 @@ fn move_columns_checks_the_protection_of_the_named_sheet() { assert_eq!(v(&s, "A1"), Value::text("A")); } +#[test] +fn move_columns_checks_the_protection_of_a_sheet_named_by_its_range() { + // A `Sheet!` range or cell picks the sheet the move modifies; the protection checks must + // authorize that sheet too, not the active one. + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "P"})).unwrap(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4]]})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + for key in ["range", "cell"] { + let err = s.execute("sheet.moveColumns", json!({key: "P!A1", "cols": "B:C", "before": 4})).expect_err("moving columns on a protected sheet"); + assert!(err.to_string().contains("protected"), "{err}"); + } + let d = s.doc().unwrap(); + let sh = &d.wb.sheets[d.wb.sheet_index("P").unwrap()]; + let pv: Vec = (0..4).map(|c| sh.value(CellRef::new(0, c))).collect(); + assert_eq!(pv, vec![Value::number(1.0), Value::number(2.0), Value::number(3.0), Value::number(4.0)]); +} + +#[test] +fn sorting_cannot_unlock_a_locked_cell_by_moving_equal_content_onto_it() { + // An unlocked 2 sorted onto a locked 2 leaves the content as it was, but would make the cell + // editable; the sort is refused instead. + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[2], [2], [1]]})).unwrap(); + s.execute("home.lockCell", json!({"range": "A1", "on": false})).unwrap(); + s.execute("home.lockCell", json!({"range": "A3", "on": false})).unwrap(); + s.execute("review.protectSheet", json!({"sort": true})).unwrap(); + let before = s.doc().unwrap().wb.clone(); + let err = + s.execute("data.sort", json!({"range": "A1:A3", "header": false, "keys": [{"column": "A", "order": "asc"}]})).expect_err("sort unlocking A2"); + assert!(err.to_string().contains("protected sheet"), "{err}"); + assert_eq!(*s.doc().unwrap().wb, *before); + assert!(s.execute("cell.set", json!({"cell": "A2", "input": "9"})).is_err(), "A2 stays locked"); +} + #[test] fn a_script_cannot_switch_windows() { // Stored scripts are scoped to their workbook: a step that activates another window would 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/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs index c46447092..7dd55f706 100644 --- a/crates/ui-egui/src/ribbon/home.rs +++ b/crates/ui-egui/src/ribbon/home.rs @@ -622,10 +622,13 @@ fn size_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { let arrow_rect = Rect::from_min_max(egui::pos2(frame.right() - 16.0, frame.top() + 1.0), egui::pos2(frame.right() - 1.0, frame.bottom() - 1.0)); let mut field_ui = ui.new_child(egui::UiBuilder::new().max_rect(text_rect.shrink2(vec2(6.0, 0.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + // The text edit is `interact_size.y` tall and puts its text at the top of that unless told + // otherwise: center it, on the Font name's baseline (#3155). let out = egui::TextEdit::singleline(&mut text) .id(field) .font(theme::ui_font(12.5)) .frame(egui::Frame::NONE) + .align(egui::Align2::LEFT_CENTER) .desired_width(text_rect.width() - 12.0) .show(&mut field_ui); let resp = out.response; diff --git a/crates/ui-egui/tests/it/font_size_box.rs b/crates/ui-egui/tests/it/font_size_box.rs index b34b195ff..f159513c8 100644 --- a/crates/ui-egui/tests/it/font_size_box.rs +++ b/crates/ui-egui/tests/it/font_size_box.rs @@ -1,6 +1,7 @@ -//! Home ▸ Font Size is a box you type a size into, with the standard sizes behind its arrow (#932). +//! Home ▸ Font Size is a box you type a size into, with the standard sizes behind its arrow (#932), +//! and the size sits in the middle of that box (#3155). -use egui::{Event, Key, Modifiers, accesskit::Role}; +use egui::{Event, Key, Modifiers, Rect, Shape, accesskit::Role}; use egui_kittest::kittest::Queryable as _; use gridcraft_engine::Session; use gridcraft_engine::core::{CellRef, Value}; @@ -124,3 +125,31 @@ fn the_arrow_opens_the_plain_list_of_sizes() { h.run_steps(3); assert_eq!(size(&h, "A1"), 12.0, "Down from 11 then Enter picks 12"); } + +/// The on-screen box of the painted text `text` that overlaps `within`. The grid's row headers +/// paint "11" too, so the size has to be looked for inside its own box. +fn painted_text(h: &Harness, text: &str, within: Rect) -> Rect { + h.output() + .shapes + .iter() + .find_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == text => { + let rect = t.galley.rect.translate(t.pos.to_vec2()); + within.intersects(rect).then_some(rect) + } + _ => None, + }) + .unwrap_or_else(|| panic!("{text:?} is painted inside {within:?}")) +} + +#[test] +/// A text edit aligns its text to the top of its rect unless told otherwise, which left the size +/// 4 pt above the Font name's baseline. +fn the_size_is_vertically_centered_in_its_box() { + let h = harness(); + let field = h.get_by_role_and_label(Role::TextInput, "Font Size").rect(); + let value = painted_text(&h, "11", field); + let name = painted_text(&h, "Calibri", Rect::EVERYTHING); + assert!((value.center().y - field.center().y).abs() <= 1.0, "the size is centered in its box: {value:?} in {field:?}"); + assert!((value.center().y - name.center().y).abs() <= 1.0, "the size is on the Font name's baseline: {value:?} against {name:?}"); +} diff --git a/crates/ui-egui/tests/it/ribbon_layout.rs b/crates/ui-egui/tests/it/ribbon_layout.rs index 452f8ea33..fdb94b193 100644 --- a/crates/ui-egui/tests/it/ribbon_layout.rs +++ b/crates/ui-egui/tests/it/ribbon_layout.rs @@ -104,7 +104,10 @@ struct Clicks { #[test] /// The main button clicks once and the arrow twice separately; the tooltip keeps the platform shortcut, and the icon sits above the full-width label. fn big_split_button_separates_the_default_action_from_the_menu() { - let mut h = crate::common::builder().with_size(vec2(300.0, 200.0)).build_ui_state( + // The ribbon picks the shortcut by the OS egui reports, which kittest pins to Nix unless told: + // pin macOS, so the check is the same on every host (the other platforms' text is in + // `widgets::tests`). + let mut h = crate::common::builder().with_size(vec2(300.0, 200.0)).with_os(egui::os::OperatingSystem::Mac).build_ui_state( |ui, c: &mut Clicks| { let (main, arrow) = widgets::big_split_button(ui, Icon::Sum, "Auto\nSum", "AutoSum"); c.main += u32::from(main.clicked()); @@ -125,8 +128,7 @@ fn big_split_button_separates_the_default_action_from_the_menu() { h.ctx.all_styles_mut(|s| s.interaction.tooltip_delay = 0.0); h.get_by_label("Auto Sum").hover(); h.run_steps(6); - let shortcut = if cfg!(target_os = "macos") { "AutoSum (\u{2318}\u{21e7}T)" } else { "AutoSum (Alt+=)" }; - assert!(h.query_by_value(shortcut).is_some(), "tooltip with the shortcut"); + assert!(h.query_by_value("AutoSum (\u{2318}\u{21e7}T)").is_some(), "tooltip with the macOS shortcut"); // The icon part sits above the label part, across the full width. let main = h.get_by_label("Auto Sum").rect(); let arrow = h.get_by_label("Auto Sum options").rect(); 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}"); +}