From de254a0a6ecbb211c38041f72b9f5b331d589081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ken=20K=C3=B6lzer?= Date: Mon, 2 Nov 2026 14:41:27 +0100 Subject: [PATCH 01/80] Ribbon: Font Size value is centered in its box (fixes #3155) The size box draws its value with a frameless TextEdit. The theme floors a text edit at interact_size.y (22 pt), and egui's text edit style aligns its text to the top of that, which wins over the child layout's Align::Center. The digits sat 3.5 pt above the middle of the 24 pt box, 4 pt above the Font name's baseline. The text edit now aligns its text LEFT_CENTER. The new test checks that the value is centered in its box and on the Font name label's baseline. It fails without the change. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/ribbon/home.rs | 3 +++ crates/ui-egui/tests/it/font_size_box.rs | 33 ++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) 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:?}"); +} 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 02/80] 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 eea1fc276f8cb4afb4d2566e015219c7757c5d72 Mon Sep 17 00:00:00 2001 From: Neil Cawse Date: Mon, 2 Nov 2026 00:58:50 +0000 Subject: [PATCH 03/80] Charts: decimate area top and baseline at paired horizontal indices --- crates/chart/src/render/cartesian.rs | 7 +- crates/chart/src/render/lines.rs | 133 +++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 5 deletions(-) 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 ae0229cfb..770aabe02 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. pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option) { @@ -155,4 +235,57 @@ mod smooth_tests { assert!(out.len() > 5); assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1]))); } + + #[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"); + } } From e5f265bb74e349fb3cf44cba0bab004b0056f7e2 Mon Sep 17 00:00:00 2001 From: Aimar Mendez Date: Mon, 2 Nov 2026 22:19:21 +0000 Subject: [PATCH 04/80] Ribbon test: pin macOS for the AutoSum tooltip, so it passes on a Mac MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #2729 the ribbon picks a tooltip's shortcut by the OS egui reports (`ctx.os()`), which egui_kittest pins to Nix unless told. The test still chose the expected text with cfg!(target_os = "macos"), so on a Mac it looked for "AutoSum (⌘⇧T)" while the harness drew "AutoSum (Alt+=)", and `cargo xtask ci` failed there while the Linux gate stayed green. The harness now pins macOS and the test expects the ⌘⇧T tooltip on every host; widgets::tests already covers the Windows/Linux "Alt+=" text. Verified on macOS 27 (arm64): `cargo xtask ci` passes all 10 steps (with CRAFT_FONTS_DIR at the gate's craft-fonts commit); on main (7bb397a3) it fails at test with "tooltip with the shortcut". --- crates/ui-egui/tests/it/ribbon_layout.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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(); 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 05/80] 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 85e51e009c24fad47575f04d3c08f12342615d82 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 2 Nov 2026 19:25:34 -0500 Subject: [PATCH 06/80] Release 0.16.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 34 +++++++++++++++++----------------- Cargo.toml | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93356b73c..20dcf28ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1969,7 +1969,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.15.0" +version = "0.16.0" dependencies = [ "arboard", "chrono", @@ -1996,7 +1996,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -2008,7 +2008,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -2019,7 +2019,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -2031,7 +2031,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.15.0" +version = "0.16.0" dependencies = [ "chrono", "js-sys", @@ -2042,7 +2042,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -2065,7 +2065,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-core", "serde", @@ -2074,7 +2074,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-core", "regex", @@ -2082,7 +2082,7 @@ dependencies = [ [[package]] name = "gridcraft-math" -version = "0.15.0" +version = "0.16.0" dependencies = [ "ratex-font", "ratex-layout", @@ -2092,7 +2092,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-engine", "log", @@ -2101,7 +2101,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -2114,18 +2114,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.15.0" +version = "0.16.0" [[package]] name = "gridcraft-ui-egui" -version = "0.15.0" +version = "0.16.0" dependencies = [ "craft-ui", "craft-update", @@ -2150,7 +2150,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.15.0" +version = "0.16.0" dependencies = [ "eframe", "egui", @@ -2167,7 +2167,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.15.0" +version = "0.16.0" dependencies = [ "aes", "gridcraft-core", @@ -6618,7 +6618,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.15.0" +version = "0.16.0" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 8aa7ec8e7..594b29273 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.15.0" +version = "0.16.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" 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 07/80] 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 08/80] 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; From e8ccb5eb15726eec25ac2710dd32f98ec1df07a1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 23:33:31 -0500 Subject: [PATCH 09/80] check: find volatile defined names with a worklist instead of repeated passes (#3187) --- apps/gridcraft-cli/src/check/calc.rs | 41 ++++++++++++++++++++------- apps/gridcraft-cli/src/check/tests.rs | 22 ++++++++++++++ 2 files changed, 53 insertions(+), 10 deletions(-) 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"))]); From 178f3eb745a589c63b61ef97dd7b6b2d6fd48515 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 23:45:20 -0500 Subject: [PATCH 10/80] Protection: authorize the sheet a column move changes, and refuse edits that unlock locked cells (#3196) - sheet.moveColumns resolved its target sheet from `sheet`, or a `Sheet!` range or cell, but its two protection checks were passed only the `sheet` parameter, so a qualified range moved columns of a protected sheet while the checks authorized the active one. Both checks now get the resolved sheet index. - check_protection compared only cell content, so a sort or duplicate removal moving an unlocked cell with equal content onto a locked one left the content as it was but made the cell editable. The commit check now also refuses a change that leaves a previously locked cell unlocked (effective lock, row and column formats included). --- crates/engine/src/cmd.rs | 10 +++++++--- crates/engine/src/cmd/sheet.rs | 8 ++++---- crates/engine/src/tests.rs | 36 ++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 7 deletions(-) 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/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 94a80f824..5f92718ba 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5297,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 From 5361e2c08a4c31f52beaf7ad0920ff0adce08993 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 2 Nov 2026 23:43:02 -0600 Subject: [PATCH 11/80] #3136: ask before a drag and drop replaces data Dragging the selection's border onto cells that hold data replaced them without asking, so dragging column B onto column A silently destroyed A. Excel asks "There's already data here. Do you want to replace it?" first (its "Alert before overwriting cells" option, on by default). The drop now runs a new `range.move {range?, to, overwrite?}` command: a cut and paste that returns a confirm question when the destination holds data other than the moved cells; the grid shows it with OK and Cancel, and OK runs it again with `overwrite: true`. A refused move leaves the sheet and the clipboard as they were. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 11 +++++++++- crates/engine/src/cmd/edit/clipboard.rs | 29 ++++++++++++++++++++++++- crates/engine/src/cmd/edit/tests.rs | 28 ++++++++++++++++++++++++ crates/ui-egui/src/grid/interact.rs | 4 ++-- crates/ui-egui/tests/it/drag_cursor.rs | 27 +++++++++++++++++++++++ docs/ui-parity.md | 5 +++-- 6 files changed, 98 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2aa9d8fe3..2dfe2dbfb 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -23,7 +23,7 @@ use cell::{cell_set, range_fill, range_set_values}; pub(crate) use cell::{check_arg_counts, input_to_cell, is_formula_input, json_to_input, parse_typed_formula}; #[cfg(test)] pub(crate) use clipboard::parse_tsv; -use clipboard::{clear_clipboard, copy, cut, paste}; +use clipboard::{clear_clipboard, copy, cut, paste, range_move}; use fill::{auto_fill, fill_dir, fill_series, flash_fill}; #[cfg(test)] use find::replace_ci; @@ -81,6 +81,15 @@ pub fn specs() -> Vec { has_doc, paste ), + cmd!( + "range.move", + "Drag and Drop", + [], + None, + "{range?, to: \"C3\", overwrite?: bool} (drag the selection's border: a cut and paste that asks before replacing data unless overwrite)", + has_doc, + range_move + ), CommandSpec { shortcut_mac: Some("Ctrl+Cmd+V"), ..cmd!( diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index 3a1cd0739..d416d7b12 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -1,7 +1,8 @@ use super::cell::input_to_cell; use super::paste_special::paste_special; use super::{ - Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, cell_param_on, edit, json, ok, str_param, target_ranges, target_sheet, + Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, bad, bool_param, cell_param_on, edit, json, ok, str_param, target_ranges, + target_sheet, }; // ---------------------------------------------------------------- clipboard @@ -163,6 +164,32 @@ pub(super) fn paste(s: &mut Session, p: &Json) -> Result { paste_special(s, &json!({"what": "all", "at": p.get("at").cloned().unwrap_or(Json::Null)})) } +/// Drag and drop (dragging the selection's border): moves `range` so its top-left cell lands on +/// `to`, as Cut and Paste would. Like Excel, it asks before replacing cells that hold data (other +/// than the moved cells themselves); run it again with `overwrite: true` to replace them. A +/// refused or failed move leaves the clipboard as it was. +pub(super) fn range_move(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let to = cell_param_on(s, p, "to", sheet)?.ok_or_else(|| bad("range.move", "missing `to`"))?; + let kept = s.clipboard.take(); + let moved = cut(s, p).and_then(|_| { + let src = s.clipboard.as_ref().map(|c| c.range).unwrap_or_default(); + let dest = to.offset(i64::from(src.height()) - 1, i64::from(src.width()) - 1).map(|end| RangeRef::new(to, end)); + if bool_param(p, "overwrite") != Some(true) + && let Some(dest) = dest + && let Some(sh) = s.doc()?.wb.sheet(sheet) + && sh.cells.iter_range(dest).any(|(c, cell)| !src.contains(c) && cell.has_content()) + { + return Err(EngineError::Confirm("There's already data here. Do you want to replace it?".into())); + } + paste(s, &json!({"at": to.a1()})) + }); + if moved.is_err() { + s.clipboard = kept; + } + moved +} + fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result { let sheet = target_sheet(s, p)?; let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start); diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 3f3a2fb6d..232a9955a 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -353,6 +353,34 @@ fn cut_paste_that_overflows_the_grid_moves_nothing() { assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0)); } +#[test] +fn drag_and_drop_asks_before_replacing_data() { + // #3136: dragging column B's border onto column A replaced A without asking. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1:B3", "values": [["Apple", "Kiwi"], ["Pear", "Plum"], ["Fig", "Lime"]]})).unwrap(); + s.execute("edit.copy", json!({"range": "D9"})).unwrap(); + let text = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["input"].clone(); + let r = s.execute("range.move", json!({"range": "B:B", "to": "A1"})); + let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") }; + assert_eq!(q, "There's already data here. Do you want to replace it?"); + // Nothing moved, and the copy made before is still on the clipboard. + assert_eq!(text(&mut s, "A1"), json!("Apple")); + assert_eq!(text(&mut s, "B1"), json!("Kiwi")); + assert!(s.clipboard.as_ref().is_some_and(|c| !c.cut && c.range.a1() == "D9")); + // Confirmed, column B replaces column A. + s.execute("range.move", json!({"range": "B:B", "to": "A1", "overwrite": true})).unwrap(); + assert_eq!(text(&mut s, "A1"), json!("Kiwi")); + assert_eq!(text(&mut s, "A3"), json!("Lime")); + assert_eq!(text(&mut s, "B1"), json!("")); + // Onto empty cells, or overlapping only the moved cells themselves, nothing is asked. + s.execute("range.move", json!({"range": "A1:A3", "to": "A2"})).unwrap(); + assert_eq!(text(&mut s, "A4"), json!("Lime")); + s.execute("range.move", json!({"range": "A2:A4", "to": "C2"})).unwrap(); + assert_eq!(text(&mut s, "C2"), json!("Kiwi")); + assert!(s.execute("range.move", json!({"range": "C2"})).is_err(), "`to` is required"); +} + #[test] fn replace_case_insensitive() { assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear"); diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 5de25fd53..87f5d582b 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -719,9 +719,9 @@ fn handle_release(app: &mut SheetApp, input: &InteractionInput<'_>) { } Drag::Move { to: Some(to), .. } => { let src = sel.current(); + // Dropping on cells that hold data asks first, as in Excel. if to != src.start { - let _ = app.session.run("edit.cut", json!({"range": src.a1()})); - let _ = app.run("edit.paste", json!({"at": to.a1()})); + crate::macro_ui::call(app, "GridCraft", "range.move", json!({"range": src.a1(), "to": to.a1()})); } } Drag::MoveCols { source, before: Some(before) } => { diff --git a/crates/ui-egui/tests/it/drag_cursor.rs b/crates/ui-egui/tests/it/drag_cursor.rs index 1b2134564..667b552bb 100644 --- a/crates/ui-egui/tests/it/drag_cursor.rs +++ b/crates/ui-egui/tests/it/drag_cursor.rs @@ -172,6 +172,33 @@ fn moving_a_selection_keeps_move_and_still_moves_cells() { assert_eq!(sh.value(CellRef::parse("E5").unwrap()), Value::Number(2.0)); } +#[test] +fn moving_a_selection_onto_data_asks_before_replacing_it() { + // #3136: the drop replaced the cells there without asking. + let mut h = harness(); + h.state_mut().session.execute("selection.set", json!({"range": "A2:B2"})).unwrap(); + h.run_steps(2); + let r = selection_rect(&h); + press(&mut h, pos2(r.left() + 10.0, r.top() + 1.0)); + let target = cell(&h, "A1").center(); + move_with_cursor(&mut h, target, CursorIcon::Move); + button(&mut h, target, false); + h.run_steps(1); + let q = h.state().macros.question.clone().expect("a question before replacing A1:B1"); + assert_eq!(q.text, "There's already data here. Do you want to replace it?"); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::parse("A1").unwrap()), Value::text("A")); + assert_eq!(sh.value(CellRef::parse("A2").unwrap()), Value::Number(1.0)); + // OK replaces them. + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert!(h.state().macros.question.is_none()); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::parse("A1").unwrap()), Value::Number(1.0)); + assert_eq!(sh.value(CellRef::parse("B1").unwrap()), Value::Number(2.0)); + assert_eq!(sh.value(CellRef::parse("A2").unwrap()), Value::Empty); +} + #[test] fn resizing_headers_keeps_the_axis_cursor_when_leaving_the_header() { for cols in [true, false] { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..6a365749a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (dragging the selection's border onto data asks before replacing it; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -55,7 +55,7 @@ this logic, with one undo step. | Cursor while dragging cells, headers, freeze handles and objects | stable for the active drag (#936); Draw tools keep their crosshair; header arrows and a custom cell cursor remain unimplemented | | Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet | | Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series; month patterns use the workbook's 1900 or 1904 date system); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, and from a date Fill Days, Fill Weekdays, Fill Months and Fill Years, then Series…; a left-drag leaves an Auto Fill Options button with the same fill choices (gone once the workbook changes or the selection moves); Linear/Growth Trend and Flash Fill missing | -| Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | +| Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | border drag moves (M3) and asks "There's already data here. Do you want to replace it?" before replacing cells that hold data (`range.move`, #3136); Ctrl/Option to copy and Shift to insert are not wired to the border drag yet (reorder whole columns by their headers instead) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | | Point mode (click/drag/arrow references), reference colouring, F4 | done; Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. | | Number-format colors (`[Green]`, `[Color1]`–`[Color56]`) | Rendered in grid and PDF from shared RGB mappings (#1452) | @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Dragging the selection's border onto cells that hold data asks "There's already data here. Do you want to replace it?" (OK replaces, Cancel leaves the sheet and clipboard as they were) through the new `range.move` command (#3136); the row no longer claims Ctrl/Option copy and Shift insert for the border drag, which are not wired yet | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From 283789c1f4be5823edc681eb177183abf1ea4e1a Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 2 Nov 2026 23:54:23 -0600 Subject: [PATCH 12/80] #2339: write the XLDAPR extension URI in lower case next to rich values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workbook with an in-cell picture and a dynamic-array formula wrote the XLDAPR futureMetadata extension URI in upper case ({BDBB8CDC-…}), and Excel refuses such a workbook. The metadata part written for dynamic arrays alone, which Excel opens, spells the same URI in lower case; the rich-value writer now does too. Together with #1533 (content types and the XLRICHVALUE URI) this lets Excel open a workbook with an in-cell picture and a spilled formula. Co-Authored-By: Claude Opus 5.5 --- crates/xlsx/src/richdata.rs | 4 +++- crates/xlsx/src/tests/richdata_tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/xlsx/src/richdata.rs b/crates/xlsx/src/richdata.rs index 8a4f6ec0a..29c31e5dc 100644 --- a/crates/xlsx/src/richdata.rs +++ b/crates/xlsx/src/richdata.rs @@ -349,7 +349,9 @@ fn metadata(count: usize, dynamic: bool) -> String { } xml.push_str(""); if dynamic { - xml.push_str(""); + // Lower case, as the dynamic-array-only metadata in `write.rs` spells it: Excel refuses + // a workbook whose XLDAPR extension URI is in upper case (#2339). + xml.push_str(""); } let _ = write!(xml, ""); for i in 0..count { diff --git a/crates/xlsx/src/tests/richdata_tests.rs b/crates/xlsx/src/tests/richdata_tests.rs index c7bd488f7..b4beb92f1 100644 --- a/crates/xlsx/src/tests/richdata_tests.rs +++ b/crates/xlsx/src/tests/richdata_tests.rs @@ -151,6 +151,30 @@ fn reads_independent_indices_reordered_keys_and_both_relationship_layouts() { } } +#[test] +fn dynamic_array_metadata_is_spelled_the_same_with_and_without_pictures() { + // #2339: with an in-cell picture the XLDAPR extension URI was written in upper case, and + // Excel refused the workbook; the dynamic-array-only metadata, which Excel opens, is lower case. + let xldapr_uri = |pictures: bool| { + let mut wb = Workbook::new(); + let sheet = wb.sheet_mut(0).unwrap(); + if pictures { + sheet.set_picture(at("B2"), Arc::new(picture(png(), "image/png", "red"))); + } + let mut f = Cell::formula(Formula::new("SEQUENCE(3)")); + f.value = Value::Number(1.0); + sheet.cells.set(at("D1"), f); + sheet.spill_ranges.insert(at("D1"), RangeRef::parse("D1:D3").unwrap()); + let bytes = write_xlsx(&wb).unwrap(); + let mut package = Package::open(&bytes).unwrap(); + let metadata = package.read_xml("xl/metadata.xml").unwrap().unwrap(); + let future = metadata.kids("futureMetadata").find(|m| m.attr("name") == Some("XLDAPR")).unwrap(); + future.child("bk").unwrap().child("extLst").unwrap().child("ext").unwrap().attr("uri").unwrap().to_string() + }; + assert_eq!(xldapr_uri(false), "{bdbb8cdc-fa1e-496e-a857-3c3f30c029c3}"); + assert_eq!(xldapr_uri(true), xldapr_uri(false)); +} + #[test] fn pictures_roundtrip_across_sheets_with_and_without_dynamic_arrays() { for dynamic in [false, true] { From 7bea3d1d6a939432a9d684cd0baa60f30c6396b5 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:06:14 -0600 Subject: [PATCH 13/80] #1448: PivotTable ranks are dense; Count Numbers is captioned "Count of" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show Values As › Rank Largest to Smallest counted every larger sibling, so ties skipped ranks: 5, 4, 3, 3, 4 ranked 1, 2, 4, 4, 2 where Excel gives 1, 2, 3, 3, 2. It now counts the distinct larger values. A new Count Numbers value field was captioned "Count Numbers of Units"; Excel captions it "Count of Units", as GridCraft's XLSX reader already names a value field saved without a name. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/pivot/tests.rs | 16 ++++++++++++++++ crates/engine/src/pivot/compute.rs | 22 +++++++++++++--------- crates/engine/src/pivot/items.rs | 5 +++-- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/pivot/tests.rs b/crates/engine/src/cmd/pivot/tests.rs index 4e4f6870c..1733a2664 100644 --- a/crates/engine/src/cmd/pivot/tests.rs +++ b/crates/engine/src/cmd/pivot/tests.rs @@ -1013,3 +1013,19 @@ fn detail_cells_are_the_cells_show_details_takes() { let pt = &d.wb.active().unwrap().pivots[0]; assert!(crate::pivot::detail_cells(&d.wb, pt, d.wb.active_sheet()).unwrap_err().contains("out of date")); } + +#[test] +fn rank_ties_are_dense_and_count_numbers_is_captioned_count_of() { + // #1448 items 10 and 12: Excel ranks 5, 4, 3, 3, 4 as 1, 2, 3, 3, 2 (GridCraft gave 1, 2, 4, + // 4, 2), and captions a Count Numbers field "Count of Val". + let mut s = Session::new(); + s.new_workbook(); + let rows = json!([["Key", "Val"], ["A", 5], ["B", 4], ["C", 3], ["D", 3], ["E", 4]]); + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + s.execute("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$6", "destination": "Sheet1!D3", "rows": ["Key"], "values": ["Val"]})).unwrap(); + s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "rank"})).unwrap(); + let ranks: Vec = ["E4", "E5", "E6", "E7", "E8"].iter().map(|a| val(&s, a)).collect(); + assert_eq!(ranks, [1.0, 2.0, 3.0, 3.0, 2.0].map(Value::Number)); + s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "normal", "func": "countNumbers"})).unwrap(); + assert_eq!(text(&s, "E3"), "Count of Val"); +} diff --git a/crates/engine/src/pivot/compute.rs b/crates/engine/src/pivot/compute.rs index 9ebf44737..0ffa8cae5 100644 --- a/crates/engine/src/pivot/compute.rs +++ b/crates/engine/src/pivot/compute.rs @@ -355,15 +355,19 @@ fn ranked_value(raw: Value, lookup: &ValueLookup<'_>, context: &ShowContext<'_>) let Some((_, parent)) = context.row_path.split_last() else { return Value::Empty }; let Some(me) = raw.as_f64() else { return raw }; let siblings = context.siblings.get(parent).cloned().unwrap_or_default(); - let mut rank = 1u64; - for sibling in siblings { - let mut path = parent.to_vec(); - path.push(sibling); - if number(&lookup.get(&path, context.col_path, context.value_index)).is_some_and(|value| value > me) { - rank += 1; - } - } - Value::number(rank as f64) + // Excel's ranks are dense: ties share a rank and the next value takes the next one + // (5, 4, 3, 3, 4 rank 1, 2, 3, 3, 2), so count the distinct larger values. + let mut larger: Vec = siblings + .into_iter() + .filter_map(|sibling| { + let mut path = parent.to_vec(); + path.push(sibling); + number(&lookup.get(&path, context.col_path, context.value_index)).filter(|value| *value > me) + }) + .collect(); + larger.sort_by(f64::total_cmp); + larger.dedup(); + Value::number(larger.len() as f64 + 1.0) } fn show_value(definition: &PivotValue, raw: Value, lookup: &ValueLookup<'_>, context: &ShowContext<'_>) -> Value { diff --git a/crates/engine/src/pivot/items.rs b/crates/engine/src/pivot/items.rs index 1a39c59d9..79b74da1a 100644 --- a/crates/engine/src/pivot/items.rs +++ b/crates/engine/src/pivot/items.rs @@ -197,7 +197,8 @@ impl Acc { } } -/// Excel's caption verb for a function ("Sum of Sales"). +/// Excel's caption verb for a function ("Sum of Sales"). Count Numbers is captioned "Count of +/// Sales", as Excel names it and as the XLSX reader names a value field saved without a name. pub fn func_caption(f: PivotFunc) -> &'static str { match f { PivotFunc::Sum => "Sum", @@ -206,7 +207,7 @@ pub fn func_caption(f: PivotFunc) -> &'static str { PivotFunc::Max => "Max", PivotFunc::Min => "Min", PivotFunc::Product => "Product", - PivotFunc::CountNumbers => "Count Numbers", + PivotFunc::CountNumbers => "Count", PivotFunc::StdDev => "StdDev", PivotFunc::StdDevP => "StdDevp", PivotFunc::Var => "Var", From 1fd10711a5215034e1be3737c45a157d9116d635 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:10:34 -0600 Subject: [PATCH 14/80] #1456: GROUPBY total depth 2 or more adds subtotals GROUPBY's total_depth only ever added one Total line. In Excel a depth of N adds a subtotal line for each group of the first N - 1 key columns (after the group, or before it when the depth is negative) and labels the last line "Grand Total"; a depth greater than the number of key columns, or above 1 with field_relationship 1 (a flat table), is #VALUE!. The subtotals aggregate the group's source rows, so AVERAGE and COUNT subtotals are right too. Co-Authored-By: Claude Opus 5.5 --- crates/functions/src/dynamic.rs | 122 +++++++++++++++++++++++++++++++- docs/function-parity.md | 3 +- 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/crates/functions/src/dynamic.rs b/crates/functions/src/dynamic.rs index ee3e10a3e..7115e44c3 100644 --- a/crates/functions/src/dynamic.rs +++ b/crates/functions/src/dynamic.rs @@ -760,6 +760,13 @@ fn groupby(args: &[Arg], c: &mut dyn Ctx) -> R { let total = opt_count(args, 4)?.unwrap_or(1); let sort = opt_count(args, 5)?.unwrap_or(1); let filter = opt_arr(args, 6)?; + let relationship = opt_count(args, 7)?.unwrap_or(0); + // A total depth of N adds subtotals for the first N - 1 key columns, so it can't exceed the + // key columns, and a flat table (field_relationship 1) has no levels to subtotal. + let depth = total.unsigned_abs() as usize; + if depth > rf.cols.max(1) || (depth > 1 && relationship == 1) { + return Err(CellError::Value); + } let p = prepare(&[&rf], &vals, fh, filter.as_ref())?; let keys: Vec> = p.rows.iter().map(|&r| key_row(&rf, r)).collect(); let mut groups: HashMap> = HashMap::new(); @@ -791,13 +798,18 @@ fn groupby(args: &[Arg], c: &mut dyn Ctx) -> R { lines .sort_by(|a, b| cmp_keys(&[a.get(ci).cloned().unwrap_or(Value::Empty)], &[b.get(ci).cloned().unwrap_or(Value::Empty)], &[sort.signum()])); } + if depth > 1 { + let mut rows_of = |keys: &[Value]| groups.get(&row_key(keys)).cloned().unwrap_or_default(); + lines = with_subtotals(lines, 0, depth, rf.cols, total < 0, &mut rows_of, &mut agg_row); + } let mut out = Vec::new(); if p.show_headers { let mut h = p.field_headers.clone(); h.extend(p.value_headers.iter().cloned()); out.push(h); } - let mut total_line = vec![Value::from("Total")]; + // With subtotals Excel calls the last line "Grand Total". + let mut total_line = vec![Value::from(if depth > 1 { "Grand Total" } else { "Total" })]; total_line.resize(rf.cols, Value::Empty); total_line.extend(agg_row(&p.rows)); if total < 0 { @@ -812,6 +824,51 @@ fn groupby(args: &[Arg], c: &mut dyn Ctx) -> R { from_rows(out, &Value::Empty) } +/// GROUPBY's subtotals: `lines` (`key_cols` keys, then values) with the lines of each group of the +/// key at `level` kept together in the order the groups first appear, and for every key level above +/// `depth` a line after the group (before it when `before`): the group's keys, blanks for the deeper +/// keys, and the values aggregated over the group's source rows (`rows_of` maps a line's keys to them). +fn with_subtotals( + lines: Vec>, + level: usize, + depth: usize, + key_cols: usize, + before: bool, + rows_of: &mut dyn FnMut(&[Value]) -> Vec, + agg: &mut dyn FnMut(&[usize]) -> Vec, +) -> Vec> { + if level + 1 >= depth { + return lines; + } + let mut order: Vec = Vec::new(); + let mut members: HashMap>> = HashMap::new(); + for line in lines { + let key = row_key(line.get(level..=level).unwrap_or_default()); + let group = members.entry(key.clone()).or_default(); + if group.is_empty() { + order.push(key); + } + group.push(line); + } + let mut out = Vec::new(); + for key in order { + let group = members.remove(&key).unwrap_or_default(); + let mut subtotal: Vec = group.first().map(|line| line.iter().take(level + 1).cloned().collect()).unwrap_or_default(); + subtotal.resize(key_cols, Value::Empty); + let rows: Vec = group.iter().flat_map(|line| rows_of(line.get(..key_cols).unwrap_or_default())).collect(); + subtotal.extend(agg(&rows)); + let inner = with_subtotals(group, level + 1, depth, key_cols, before, rows_of, agg); + if before { + out.push(subtotal); + out.extend(inner); + } else { + out.extend(inner); + out.push(subtotal); + } + } + out +} + /// Height and width of a PIVOTBY grid: `header_rows` (one per column field, plus the value-name row) and /// `body_rows` (row keys plus the total row), each `lead` row-field cells wide plus `value_cols` cells per column key. fn pivot_dims(header_rows: usize, body_rows: usize, col_keys: usize, value_cols: usize, lead: usize) -> (usize, usize) { @@ -1265,6 +1322,69 @@ mod tests { is_err(ev("GROUPBY", vec![rf(region), av(col(&[1.0])), t("SUM")]), CellError::Value); } + #[test] + fn groupby_total_depth_adds_subtotals() { + // #1456 item 1, values as Excel 365 shows them. + let keys2 = || av(arr(vec![vec![tv("a"), tv("x")], vec![tv("a"), tv("y")], vec![tv("b"), tv("x")]])); + let vals3 = || av(col(&[1.0, 2.0, 3.0])); + let e = Value::Empty; + let r = ev("GROUPBY", vec![keys2(), vals3(), t("SUM"), empty(), n(2.0)]); + assert_eq!( + rows_of(&r), + vec![ + vec![tv("a"), tv("x"), nv(1.0)], + vec![tv("a"), tv("y"), nv(2.0)], + vec![tv("a"), e.clone(), nv(3.0)], + vec![tv("b"), tv("x"), nv(3.0)], + vec![tv("b"), e.clone(), nv(3.0)], + vec![tv("Grand Total"), e.clone(), nv(6.0)], + ] + ); + let r = ev("GROUPBY", vec![keys2(), vals3(), t("SUM"), empty(), n(-2.0)]); + assert_eq!( + rows_of(&r), + vec![ + vec![tv("Grand Total"), e.clone(), nv(6.0)], + vec![tv("a"), e.clone(), nv(3.0)], + vec![tv("a"), tv("x"), nv(1.0)], + vec![tv("a"), tv("y"), nv(2.0)], + vec![tv("b"), e.clone(), nv(3.0)], + vec![tv("b"), tv("x"), nv(3.0)], + ] + ); + let keys3 = || { + av(arr(vec![ + vec![tv("a"), tv("x"), tv("p")], + vec![tv("a"), tv("x"), tv("q")], + vec![tv("a"), tv("y"), tv("p")], + vec![tv("b"), tv("x"), tv("p")], + ])) + }; + let vals4 = || av(col(&[1.0, 2.0, 3.0, 4.0])); + let r = ev("GROUPBY", vec![keys3(), vals4(), t("SUM"), empty(), n(3.0)]); + let line = |k: [&str; 3], v: f64| k.iter().map(|s| if s.is_empty() { Value::Empty } else { tv(s) }).chain([nv(v)]).collect::>(); + assert_eq!( + rows_of(&r), + vec![ + line(["a", "x", "p"], 1.0), + line(["a", "x", "q"], 2.0), + line(["a", "x", ""], 3.0), + line(["a", "y", "p"], 3.0), + line(["a", "y", ""], 3.0), + line(["a", "", ""], 6.0), + line(["b", "x", "p"], 4.0), + line(["b", "x", ""], 4.0), + line(["b", "", ""], 4.0), + line(["Grand Total", "", ""], 10.0), + ] + ); + // Depth 1 keeps "Total"; a depth past the key columns, or subtotals of a flat table, are errors. + let r = ev("GROUPBY", vec![keys2(), vals3(), t("SUM"), empty(), n(1.0)]); + assert_eq!(rows_of(&r).last().unwrap(), &vec![tv("Total"), e, nv(6.0)]); + is_err(ev("GROUPBY", vec![av(arr(vec![vec![tv("a")], vec![tv("b")]])), av(col(&[1.0, 2.0])), t("SUM"), empty(), n(2.0)]), CellError::Value); + is_err(ev("GROUPBY", vec![keys2(), vals3(), t("SUM"), empty(), n(2.0), empty(), empty(), n(1.0)]), CellError::Value); + } + #[test] fn pivotby() { let (region, item, amt) = sales(); diff --git a/docs/function-parity.md b/docs/function-parity.md index 7e730d78c..97bd5401a 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-11-01 · **Change:** minor (Python cells are edited in the formula bar; earlier: the Python errors `#PYTHON!`, `#BUSY!`, `#TIMEOUT!` and `#BLOCKED!`, and a Python in cells row; earlier: YEARFRAC basis 0 February-end rule, MOD quotient limit, PERCENTRANK significance; earlier: edge cases matched to Excel: base conversions, BITLSHIFT/BITRSHIFT, UNICHAR noncharacters, UPPER/LOWER/PROPER casing, FIXED/DOLLAR output, F/T/GAMMA/WEIBULL/BETA distributions, BINOM trial cap, HARMEAN; earlier: ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-11-03 · **Change:** minor (GROUPBY total depth 2 or more adds subtotals; earlier: Python cells are edited in the formula bar; earlier: the Python errors `#PYTHON!`, `#BUSY!`, `#TIMEOUT!` and `#BLOCKED!`, and a Python in cells row; earlier: YEARFRAC basis 0 February-end rule, MOD quotient limit, PERCENTRANK significance; earlier: edge cases matched to Excel: base conversions, BITLSHIFT/BITRSHIFT, UNICHAR noncharacters, UPPER/LOWER/PROPER casing, FIXED/DOLLAR output, F/T/GAMMA/WEIBULL/BETA distributions, BINOM trial cap, HARMEAN; earlier: ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -134,6 +134,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | GROUPBY: a total depth of 2 or more adds a subtotal line for each group of the leading key columns (after the group, or before it for a negative depth) and labels the last line Grand Total; a depth past the key columns, or above 1 with `field_relationship` 1, is `#VALUE!`, as in Excel (#1456 item 1) | | 2026-11-01 | minor | Python in cells row: Python mode in the formula bar | | 2026-11-01 | minor | Python in cells (stored, not run): `#PYTHON!`, `#BUSY!`, `#TIMEOUT!` and `#BLOCKED!` done, with their `ERROR.TYPE` numbers; `PY` still counted as missing | | 2026-10-31 | minor | BIN2*, HEX2* and OCT2* reject surrounding spaces and tabs in source strings with `#NUM!`, matching Excel; plain unpadded values remain valid | From 6874e0b6892cd622a2a3c525198291435b958b4f Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:13:27 -0600 Subject: [PATCH 15/80] #3136: range.move pastes on the range's sheet and asks before replacing spilled values Addresses CodeRabbit review on #3216: - "Pass the resolved sheet to the paste step": a sheet-qualified move (`Two!B1:B2` with Sheet1 active) was checked on Two but pasted on the active sheet, replacing its data unasked. range.move now calls paste_special with the range's sheet. - "Check spilled values in the drop destination": cells showing a dynamic array's spilled values hold no cell of their own, so the drop replaced them without the prompt. They now count as data, as in Excel (ISBLANK of a spill cell is FALSE, COUNTA counts it). Tests: drag_and_drop_on_another_sheet_stays_on_that_sheet, drag_and_drop_asks_before_replacing_spilled_values. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit/clipboard.rs | 10 ++++--- crates/engine/src/cmd/edit/tests.rs | 36 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index d416d7b12..78057691f 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -165,8 +165,8 @@ pub(super) fn paste(s: &mut Session, p: &Json) -> Result { } /// Drag and drop (dragging the selection's border): moves `range` so its top-left cell lands on -/// `to`, as Cut and Paste would. Like Excel, it asks before replacing cells that hold data (other -/// than the moved cells themselves); run it again with `overwrite: true` to replace them. A +/// `to`, as Cut and Paste would. Like Excel, it asks before replacing cells that hold data or show a +/// spilled value (other than the moved cells themselves); run it again with `overwrite: true` to replace them. A /// refused or failed move leaves the clipboard as it was. pub(super) fn range_move(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; @@ -178,11 +178,13 @@ pub(super) fn range_move(s: &mut Session, p: &Json) -> Result { if bool_param(p, "overwrite") != Some(true) && let Some(dest) = dest && let Some(sh) = s.doc()?.wb.sheet(sheet) - && sh.cells.iter_range(dest).any(|(c, cell)| !src.contains(c) && cell.has_content()) + && (sh.cells.iter_range(dest).any(|(c, cell)| !src.contains(c) && cell.has_content()) + || sh.spill.range(dest.start..=dest.end).any(|(c, v)| dest.contains(*c) && !src.contains(*c) && *v != Value::Empty)) { return Err(EngineError::Confirm("There's already data here. Do you want to replace it?".into())); } - paste(s, &json!({"at": to.a1()})) + // On the range's sheet, which `paste` would leave to the active one. + paste_special(s, &json!({"what": "all", "at": to.a1(), "sheet": sheet})) }); if moved.is_err() { s.clipboard = kept; diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 232a9955a..e5b223a91 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -381,6 +381,42 @@ fn drag_and_drop_asks_before_replacing_data() { assert!(s.execute("range.move", json!({"range": "C2"})).is_err(), "`to` is required"); } +#[test] +fn drag_and_drop_asks_before_replacing_spilled_values() { + // Cells showing a dynamic array's spilled values hold no cell of their own, but they are data. + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap(); + s.execute("range.setValues", json!({"range": "C2:C3", "values": [["Kiwi"], ["Plum"]]})).unwrap(); + let r = s.execute("range.move", json!({"range": "C2:C3", "to": "A2"})); + let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") }; + assert_eq!(q, "There's already data here. Do you want to replace it?"); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::new(2, 2)), Value::text("Plum")); + s.execute("range.move", json!({"range": "C2:C3", "to": "A2", "overwrite": true})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::new(1, 0)), Value::text("Kiwi")); +} + +#[test] +fn drag_and_drop_on_another_sheet_stays_on_that_sheet() { + // The move was checked on the range's sheet but pasted on the active one, replacing its data + // without asking. + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.insertSheet", json!({"name": "Two"})).unwrap(); + s.execute("range.setValues", json!({"range": "B1:B2", "values": [["Kiwi"], ["Plum"]]})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "Keep"})).unwrap(); + s.execute("range.move", json!({"range": "Two!B1:B2", "to": "A1"})).unwrap(); + let wb = &s.doc().unwrap().wb; + let two = wb.sheet_index("Two").unwrap(); + assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(0, 0)), Value::text("Keep")); + assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(0, 0)), Value::text("Kiwi")); + assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(1, 0)), Value::text("Plum")); + assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(0, 1)), Value::Empty); +} + #[test] fn replace_case_insensitive() { assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear"); From 044599c6efa8d5cf3e093f48f9c3a025b7af13ce Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:27:25 -0600 Subject: [PATCH 16/80] #1448: a legacy "Count Numbers of" caption follows a new function Addresses CodeRabbit review on #3219, "Recognize the legacy Count Numbers caption when the function changes": workbooks saved before Count Numbers was captioned "Count of" carry "Count Numbers of Val" (optionally with a number suffix). pivot.valueSettings now treats that as an automatic caption of a Count Numbers field, so changing the function renames it; a custom name stays. Test: legacy_count_numbers_caption_follows_the_function. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/pivot/fields.rs | 10 +++++++--- crates/engine/src/cmd/pivot/tests.rs | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/pivot/fields.rs b/crates/engine/src/cmd/pivot/fields.rs index 48c25657c..55036f541 100644 --- a/crates/engine/src/cmd/pivot/fields.rs +++ b/crates/engine/src/cmd/pivot/fields.rs @@ -507,9 +507,13 @@ pub(super) fn value_settings(s: &mut Session, p: &Json) -> Result { let mut v = cur.clone(); if let Some(f) = func { v.func = f; - // An automatic caption follows the function. - let auto = auto_name(cur.func, &cur.source_col); - if cur.name.eq_ignore_ascii_case(&auto) || cur.name.strip_prefix(auto.as_str()).is_some_and(|r| r.chars().all(|c| c.is_ascii_digit())) { + // An automatic caption follows the function. Count Numbers was captioned "Count + // Numbers of …" before it took Excel's "Count of …", and saved workbooks still carry it. + let is_auto = |base: &str| { + cur.name.eq_ignore_ascii_case(base) || cur.name.strip_prefix(base).is_some_and(|r| r.chars().all(|c| c.is_ascii_digit())) + }; + let legacy = cur.func == PivotFunc::CountNumbers && is_auto(&format!("Count Numbers of {}", cur.source_col)); + if is_auto(&auto_name(cur.func, &cur.source_col)) || legacy { v.name = unique_value_name(pt, &auto_name(f, &cur.source_col), Some(i)); } } diff --git a/crates/engine/src/cmd/pivot/tests.rs b/crates/engine/src/cmd/pivot/tests.rs index 1733a2664..308d30f4a 100644 --- a/crates/engine/src/cmd/pivot/tests.rs +++ b/crates/engine/src/cmd/pivot/tests.rs @@ -1029,3 +1029,20 @@ fn rank_ties_are_dense_and_count_numbers_is_captioned_count_of() { s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "normal", "func": "countNumbers"})).unwrap(); assert_eq!(text(&s, "E3"), "Count of Val"); } + +#[test] +fn legacy_count_numbers_caption_follows_the_function() { + // Workbooks saved before Count Numbers was captioned "Count of" carry "Count Numbers of Val"; + // that is still an automatic caption, so it follows a new function. A custom name stays. + let mut s = Session::new(); + s.new_workbook(); + let rows = json!([["Key", "Val"], ["A", 5], ["B", 4]]); + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + s.execute("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$3", "destination": "Sheet1!D3", "rows": ["Key"], "values": ["Val"]})).unwrap(); + for (legacy, after) in [("Count Numbers of Val", "Sum of Val"), ("Count Numbers of Val2", "Sum of Val"), ("Numbers counted", "Numbers counted")] { + s.execute("pivot.valueSettings", json!({"index": 0, "func": "countNumbers", "name": legacy})).unwrap(); + assert_eq!(text(&s, "E3"), legacy); + s.execute("pivot.valueSettings", json!({"index": 0, "func": "sum"})).unwrap(); + assert_eq!(text(&s, "E3"), after, "{legacy}"); + } +} From 44460f23cf18ba007da621652f2dbf0b42b98e98 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:27:49 -0600 Subject: [PATCH 17/80] #3166: open a web page's table as a sheet, also when it is named .xls Web apps' "Export to Excel" often writes an HTML table and names it .xls. GridCraft read such a file as CSV text, so it opened as lines of markup in column A. Excel opens the table (after warning that the format and the extension don't match). Opening a file whose bytes are a web page with a table now reads the first table as a sheet, through the clipboard HTML table reader: the cells go through the CSV reader, so values read as they do in a CSV (and stay text with csvAsText); cells are bold and centred; the sheet takes the page's or the file's name. A page that isn't named .htm/.html opens with a warning saying what it is. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/clip_html.rs | 16 ++++--- crates/engine/src/io.rs | 78 ++++++++++++++++++++++++++++++++++ crates/engine/src/io/tests.rs | 32 ++++++++++++++ docs/file-format-parity.md | 5 ++- 4 files changed, 122 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/clip_html.rs b/crates/engine/src/clip_html.rs index e1d217c9c..fa9822390 100644 --- a/crates/engine/src/clip_html.rs +++ b/crates/engine/src/clip_html.rs @@ -17,11 +17,13 @@ const MAX_ROW_SPAN: usize = gridcraft_core::MAX_ROWS as usize; /// table is ignored: the HTML only refines cells of the tab-separated text, which stays the source. const MAX_HTML_CELLS: usize = 1_000_000; -/// One table cell: its visible text and the number the source recorded for it, if any. +/// One table cell: its visible text, the number the source recorded for it, if any, and whether +/// it is a header cell (``). #[derive(Clone, Debug, Default, PartialEq)] pub(crate) struct HtmlCell { pub text: String, pub num: Option, + pub header: bool, } /// The cells of the first table in `html`, row by row, read no further than `max_rows` rows of @@ -88,7 +90,7 @@ pub(crate) fn table_cells(html: &str, max_rows: usize, max_cols: usize) -> Vec().ok()).filter(|n| n.is_finite()); let colspan = span(attrs, "colspan", MAX_SPAN); let rowspan = span(attrs, "rowspan", MAX_ROW_SPAN); - cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num }, colspan, rowspan }); + cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num, header: name == "th" }, colspan, rowspan }); } ("td" | "th", true) | ("tr", true) => grid.finish(&mut cell), ("br", _) => { @@ -376,18 +378,18 @@ mod tests { 2.50 & upx"#; let rows = table_cells(excel, 2, 3); assert_eq!(rows.len(), 2); - assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }); - assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None }); + assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0), ..HtmlCell::default() }); + assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None, ..HtmlCell::default() }); assert_eq!(rows[1].len(), 3, "colspan keeps the next cell in its column"); assert_eq!(rows[1][0].text, "2.50 & up"); assert_eq!(rows[1][2].text, "x"); let calc = r#"
Rp10,000.00
"#; - assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]); + assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0), ..HtmlCell::default() }]]); let quoted_gt = r#"
1500
"#; - assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]); + assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0), ..HtmlCell::default() }]]); } #[test] @@ -434,7 +436,7 @@ mod tests { assert!(cells <= MAX_HTML_CELLS, "{cells} cells"); assert!(rows.iter().all(|r| r.len() <= MAX_SPAN)); assert_eq!(rows[0].len(), MAX_SPAN, "the first span fills the sheet's width"); - assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0) }); + assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0), ..HtmlCell::default() }); // Bounded by the pasted text's shape, the same markup yields just that shape. let rows = table_cells(&html, 3, 2); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index ce1fb40b0..9e3c7f7ae 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -291,6 +291,9 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, })?; return Ok((wb, report.warnings)); } + if let Some(read) = read_html_table(name, kind, bytes, csv_as_text) { + return read; + } match kind { Some(FileKind::Json) => { let mut wb: Workbook = serde_json::from_slice(bytes).map_err(|e| EngineError::Other(format!("not a GridCraft JSON workbook: {e}")))?; @@ -346,9 +349,84 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String { ) } +/// A web page that holds a table, whatever its extension: a web app's "Export to Excel" is often +/// HTML saved as `.xls` (#3166). Excel opens its table as a sheet (after warning that the format +/// and extension don't match): the cells read like typed input (or as text with `csv_as_text`), +/// header cells (``) are bold and centred, and the sheet takes the name the page gives it +/// (``) or the file's. `None` when the bytes aren't such a page. +fn read_html_table(name: &str, kind: Option, bytes: &[u8], csv_as_text: bool) -> Option)>> { + let text = String::from_utf8_lossy(bytes); + let head: String = text.trim_start_matches('\u{feff}').trim_start().chars().take(16).collect::().to_ascii_lowercase(); + if ![" = row + .iter() + .map(|cell| { + let field = match cell.num { + Some(n) => gridcraft_core::number_to_text(n), + None => cell.text.split_whitespace().collect::>().join(" "), + }; + if field.contains(['\t', '\n', '\r', '"']) { format!("\"{}\"", field.replace('"', "\"\"")) } else { field } + }) + .collect(); + tsv.push_str(&fields.join("\t")); + tsv.push('\n'); + } + let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() }; + let read = gridcraft_xlsx::read_csv_with_report(tsv.as_bytes(), &opts).map_err(|e| EngineError::Other(e.to_string())); + Some(read.map(|(mut wb, report)| { + wb.csv_delimiter = None; + let header_style = wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| { + st.font.bold = true; + st.align.h = gridcraft_model::HAlign::Center; + }); + if let Some(sh) = wb.sheet_mut(0) { + for (r, row) in rows.iter().enumerate() { + for (c, cell) in row.iter().enumerate() { + if cell.header && !cell.text.trim().is_empty() { + sh.set_style(CellRef::new(r as u32, c as u32), header_style); + } + } + } + } + let page_name = find_between(&text, "", "").map(|n| crate::clip_html::normalized(n.trim())); + match page_name.filter(|n| !n.is_empty()) { + Some(sheet) => set_first_sheet_name(&mut wb, &sheet), + None => rename_first_sheet(&mut wb, name), + } + let mut warnings = report.warnings; + if !matches!(kind, Some(FileKind::Html)) { + warnings.insert(0, format!("'{name}' is a web page (HTML), not a workbook in the format its name says: its table was opened as a sheet. Save as .xlsx to keep your changes.")); + } + (wb, warnings) + })) +} + +/// The text between the first `open` and the `close` after it, matched case-insensitively. +fn find_between<'a>(text: &'a str, open: &str, close: &str) -> Option<&'a str> { + let lower = text.to_ascii_lowercase(); + let start = lower.find(&open.to_ascii_lowercase())?.checked_add(open.len())?; + let len = lower.get(start..)?.find(&close.to_ascii_lowercase())?; + text.get(start..start.checked_add(len)?) +} + /// A CSV opens as a sheet named after the file (like Excel). fn rename_first_sheet(wb: &mut Workbook, path: &str) { let stem = std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Sheet1"); + set_first_sheet_name(wb, stem); +} + +/// Names the first sheet `name`, without the characters a sheet name can't hold and cut to 31 +/// characters; an empty or reserved result keeps the default name. +fn set_first_sheet_name(wb: &mut Workbook, stem: &str) { let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect(); let clean = clean.trim_matches('\'').to_string(); // "History" is reserved (see `Workbook::check_sheet_name`): keep the default name instead. diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index 9051c5c09..34b1a3c0a 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -604,3 +604,35 @@ fn opening_google_sheets_export_unwraps_only_formulas_gridcraft_calculates() { assert_eq!(text("A2").as_deref(), Some("IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")")); assert_eq!(opened.sheets[0].cells.get(CellRef::parse("A2").unwrap()).map(|c| c.value.clone()), Some(Value::text("salut"))); } + +#[test] +fn a_web_page_saved_as_xls_opens_its_table() { + // #3166: a web app's "Export to Excel" is an HTML table saved as .xls; it opened as lines of + // markup. Excel opens the table: values read as typed, bold and centred, names + // the sheet. + let page = "\u{feff}\ + \ + \ + \ +
ItemQtyCode
Kiwi &\n Lime12007
Plum4,50042
"; + let (wb, warnings) = open_bytes("orders.xls", page.as_bytes()).unwrap(); + assert!(warnings.first().is_some_and(|w| w.contains("is a web page (HTML)")), "{warnings:?}"); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.name, "Orders"); + let v = |a: &str| sh.value(CellRef::parse(a).unwrap()); + assert_eq!(v("A1"), gridcraft_core::Value::text("Item")); + assert_eq!(v("A2"), gridcraft_core::Value::text("Kiwi & Lime")); + assert_eq!(v("B2"), gridcraft_core::Value::number(12.0)); + assert_eq!(v("C2"), gridcraft_core::Value::number(7.0), "read like typed input, as Excel does"); + assert_eq!(v("B3"), gridcraft_core::Value::number(4.5), "the number the page recorded"); + let style = |a: &str| wb.styles.get(sh.style_id(CellRef::parse(a).unwrap())).clone(); + assert!(style("A1").font.bold && style("A1").align.h == gridcraft_model::HAlign::Center); + assert!(!style("A2").font.bold); + // Opened as text, codes keep their zeros; a page named .html gets no warning. + let (wb, warnings) = open_bytes_with("orders.html", page.as_bytes(), None, true).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("C2").unwrap()), gridcraft_core::Value::text("007")); + // Plain text that merely starts with a tag-like word is still read as CSV. + let (wb, _) = open_bytes("notes.csv", b",1\nx,2\n").unwrap(); + assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::number(2.0)); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c4bc72bae..8e4a8fed7 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-03 · **Change:** minor (a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -107,7 +107,7 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8; per-column day/month date-order detection for `/` and `-` dates) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | | `.prn` formatted text (space-delimited) | R/W | no | no | | | `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy | -| `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes (one sheet as a table; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported. A sheet with pictures or charts is written with the sheet's own column widths and row heights, a picture as a `data:` URI `` and a chart as inline SVG, each placed at its anchor; shapes, text boxes and comments are not exported | +| `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes: opening reads the page's first table as a sheet (header cells bold and centred, values read as typed, the sheet named by ``), also when the page is saved as `.xls`/`.xlsx` as web apps' "Export to Excel" does, with a warning that the format and name don't match (#3166); saving writes one sheet as a table; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported. A sheet with pictures or charts is written with the sheet's own column widths and row heights, a picture as a `data:` URI `` and a chart as inline SVG, each placed at its anchor; shapes, text boxes and comments are not exported | | `.mht` / `.mhtml` single-file web page | R/W (Windows) | no | no | | | `.json` | via Power Query | GridCraft's own workbook JSON (debug) and JSON import | GridCraft JSON | Not Power Query's JSON shaping; notes and hyperlinks break export (#126) | | `.xml` with XML maps | Developer ▸ Import/Export | no | no | | @@ -155,6 +155,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Opening a web page (HTML) reads its first table as a sheet, whatever the file is called: a web app's "Export to Excel" saved as `.xls` no longer opens as lines of markup. Values read as in a CSV (or as text with `csvAsText`), `` cells are bold and centred, `` names the sheet, and a page not named `.htm`/`.html` opens with a warning (#3166) | | 2026-11-02 | minor | Python cells: `.xlsx` reads and writes `_xlfn._xlws.PY(i,0 or 1)` with the code in the Python part (and reads the string-literal form) with cached values and spills, other formats adopt PY formulas, GridCraft JSON keeps the whole cell, and opening limits code size, result size and spill ranges | | 2026-11-02 | trivial | An equation without Office Math takes its stored source only while its text, paragraphs included, is what the writer wrote for it; a paragraph split that changes no character reads back as a text box with the split text | | 2026-11-02 | trivial | Each equation box carries the marker of its own body in its description, the Office Math box and the plain-text fallback alike, and the reader takes the stored source only while the body it reads matches the marker: the text of an equation without Office Math that another application edited reads back as a text box with the edited text instead of the stale source | From ba1c62469da1f52c3ef8041f26a128c588ffb697 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:31:59 -0600 Subject: [PATCH 18/80] #3136: a move on another sheet keeps the active sheet's selection Addresses CodeRabbit review on #3216, "Keep the selection on the sheet it describes": moving Two!B1:B2 with Sheet1 active selected A1:A2 on Sheet1, where nothing moved. The selection belongs to the active sheet, so range.move now keeps it when the moved cells are on another sheet; a move on the active sheet still selects the moved cells. Test: drag_and_drop_on_another_sheet_stays_on_that_sheet (now also checks the selection). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit/clipboard.rs | 10 ++++++++-- crates/engine/src/cmd/edit/tests.rs | 4 ++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index 78057691f..bbef17163 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -183,8 +183,14 @@ pub(super) fn range_move(s: &mut Session, p: &Json) -> Result { { return Err(EngineError::Confirm("There's already data here. Do you want to replace it?".into())); } - // On the range's sheet, which `paste` would leave to the active one. - paste_special(s, &json!({"what": "all", "at": to.a1(), "sheet": sheet})) + // On the range's sheet, which `paste` would leave to the active one. The selection + // belongs to the active sheet: it follows the moved cells only when they are on it. + let selection = s.doc()?.selection.clone(); + let pasted = paste_special(s, &json!({"what": "all", "at": to.a1(), "sheet": sheet}))?; + if sheet != s.doc()?.wb.active_sheet() { + s.doc_mut()?.selection = selection; + } + Ok(pasted) }); if moved.is_err() { s.clipboard = kept; diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index e5b223a91..d24559d20 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -408,7 +408,11 @@ fn drag_and_drop_on_another_sheet_stays_on_that_sheet() { s.execute("range.setValues", json!({"range": "B1:B2", "values": [["Kiwi"], ["Plum"]]})).unwrap(); s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); s.execute("cell.set", json!({"cell": "A1", "input": "Keep"})).unwrap(); + s.execute("selection.set", json!({"range": "C5"})).unwrap(); s.execute("range.move", json!({"range": "Two!B1:B2", "to": "A1"})).unwrap(); + // Sheet1 stays active with its own selection; the moved cells are on Two. + assert_eq!(s.doc().unwrap().wb.active_sheet(), 0); + assert_eq!(s.doc().unwrap().selection.a1(), "C5"); let wb = &s.doc().unwrap().wb; let two = wb.sheet_index("Two").unwrap(); assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(0, 0)), Value::text("Keep")); From ef8ac5e6db1d157f5539d628ac5243157a9745ea Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:36:42 -0600 Subject: [PATCH 19/80] #1456: GROUPBY refuses oversized subtotals before building them, without recursion Addresses CodeRabbit review on #3221, "Check the output size before with_subtotals builds subtotal lines": the size limit was checked only after every subtotal line existed. A row with 4000 key columns at total depth 4000 nested with_subtotals 4000 levels deep and overflowed the stack (the process aborted) before the late check could refuse it. with_subtotals now works without recursion: each line gets an id for its key prefix at every level, a stable sort by those ids keeps each group together in first-appearance order, and one walk adds the subtotals. The output size (with the header and total lines when present) is checked while the groups are counted, before any subtotal line is built. Test: groupby_subtotals_past_the_size_limit_are_refused_before_they_are_built. Co-Authored-By: Claude Opus 5.5 --- crates/functions/src/dynamic.rs | 106 +++++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 30 deletions(-) diff --git a/crates/functions/src/dynamic.rs b/crates/functions/src/dynamic.rs index 7115e44c3..816af8ea2 100644 --- a/crates/functions/src/dynamic.rs +++ b/crates/functions/src/dynamic.rs @@ -800,7 +800,11 @@ fn groupby(args: &[Arg], c: &mut dyn Ctx) -> R { } if depth > 1 { let mut rows_of = |keys: &[Value]| groups.get(&row_key(keys)).cloned().unwrap_or_default(); - lines = with_subtotals(lines, 0, depth, rf.cols, total < 0, &mut rows_of, &mut agg_row); + // The header and total lines count too, so the whole result is checked before it is built. + let around = usize::from(p.show_headers) + usize::from(total != 0); + let width = rf.cols.saturating_add(vals.cols); + let fits = |n: usize| check_output_size(n.saturating_add(around), width); + lines = with_subtotals(lines, depth, rf.cols, total < 0, &fits, &mut rows_of, &mut agg_row)?; } let mut out = Vec::new(); if p.show_headers { @@ -824,49 +828,82 @@ fn groupby(args: &[Arg], c: &mut dyn Ctx) -> R { from_rows(out, &Value::Empty) } -/// GROUPBY's subtotals: `lines` (`key_cols` keys, then values) with the lines of each group of the -/// key at `level` kept together in the order the groups first appear, and for every key level above +/// GROUPBY's subtotals: `lines` (`key_cols` keys, then values) with the lines of each group of a +/// key level kept together in the order the groups first appear, and for every key level above /// `depth` a line after the group (before it when `before`): the group's keys, blanks for the deeper /// keys, and the values aggregated over the group's source rows (`rows_of` maps a line's keys to them). +/// +/// No recursion, so a deep total depth can't exhaust the stack: each line gets the id of its key +/// prefix at every level (ids count up as prefixes first appear), a stable sort by those ids brings +/// each group together, and one walk adds a subtotal where a group starts or ends. `fits` gets the +/// line count as the groups are found, so an oversized result is refused before it is built. fn with_subtotals( - lines: Vec>, - level: usize, + mut lines: Vec>, depth: usize, key_cols: usize, before: bool, + fits: &dyn Fn(usize) -> R<()>, rows_of: &mut dyn FnMut(&[Value]) -> Vec, agg: &mut dyn FnMut(&[usize]) -> Vec, -) -> Vec> { - if level + 1 >= depth { - return lines; +) -> R>> { + let levels = depth.saturating_sub(1); + if levels == 0 { + return Ok(lines); } - let mut order: Vec = Vec::new(); - let mut members: HashMap>> = HashMap::new(); - for line in lines { - let key = row_key(line.get(level..=level).unwrap_or_default()); - let group = members.entry(key.clone()).or_default(); - if group.is_empty() { - order.push(key); + let mut ids: HashMap<(usize, String), usize> = HashMap::new(); + let mut paths: Vec> = Vec::with_capacity(lines.len()); + for line in &lines { + let mut parent = usize::MAX; + let mut path = Vec::with_capacity(levels); + for level in 0..levels { + let next = ids.len(); + parent = *ids.entry((parent, row_key(line.get(level..=level).unwrap_or_default()))).or_insert(next); + path.push(parent); } - group.push(line); + fits(lines.len().saturating_add(ids.len()))?; + paths.push(path); } - let mut out = Vec::new(); - for key in order { - let group = members.remove(&key).unwrap_or_default(); - let mut subtotal: Vec = group.first().map(|line| line.iter().take(level + 1).cloned().collect()).unwrap_or_default(); - subtotal.resize(key_cols, Value::Empty); - let rows: Vec = group.iter().flat_map(|line| rows_of(line.get(..key_cols).unwrap_or_default())).collect(); - subtotal.extend(agg(&rows)); - let inner = with_subtotals(group, level + 1, depth, key_cols, before, rows_of, agg); + let mut group_rows: Vec> = vec![Vec::new(); ids.len()]; + for (line, path) in lines.iter().zip(&paths) { + let rows = rows_of(line.get(..key_cols).unwrap_or_default()); + for &id in path { + if let Some(g) = group_rows.get_mut(id) { + g.extend_from_slice(&rows); + } + } + } + let mut subtotal = |keys: &[Value], level: usize, id: usize| -> Vec { + let mut line: Vec = keys.iter().take(level + 1).cloned().collect(); + line.resize(key_cols, Value::Empty); + line.extend(agg(group_rows.get(id).map(Vec::as_slice).unwrap_or_default())); + line + }; + let mut order: Vec = (0..lines.len()).collect(); + order.sort_by(|&a, &b| paths.get(a).cmp(&paths.get(b))); + let mut out = Vec::with_capacity(lines.len().saturating_add(ids.len())); + let mut prev: Option<(Vec, &[usize])> = None; + for &i in &order { + let (Some(line), Some(path)) = (lines.get_mut(i).map(std::mem::take), paths.get(i)) else { continue }; + // The first level at which this line leaves the previous line's groups. + let split = prev.as_ref().map_or(0, |(_, p)| p.iter().zip(path).take_while(|(a, b)| a == b).count()); if before { - out.push(subtotal); - out.extend(inner); - } else { - out.extend(inner); - out.push(subtotal); + for (level, &id) in path.iter().enumerate().skip(split) { + out.push(subtotal(&line, level, id)); + } + } else if let Some((keys, p)) = &prev { + for (level, &id) in p.iter().enumerate().skip(split).rev() { + out.push(subtotal(keys, level, id)); + } + } + prev = Some((line.get(..key_cols).unwrap_or_default().to_vec(), path)); + out.push(line); + } + if !before && let Some((keys, p)) = &prev { + for (level, &id) in p.iter().enumerate().rev() { + out.push(subtotal(keys, level, id)); } } - out + Ok(out) } /// Height and width of a PIVOTBY grid: `header_rows` (one per column field, plus the value-name row) and @@ -1322,6 +1359,15 @@ mod tests { is_err(ev("GROUPBY", vec![rf(region), av(col(&[1.0])), t("SUM")]), CellError::Value); } + #[test] + fn groupby_subtotals_past_the_size_limit_are_refused_before_they_are_built() { + // One row with 4000 key columns at depth 4000 asks for 3999 subtotal lines, each 4001 cells + // wide: 16 million cells, over the limit. It is #NUM! before any subtotal line exists (it + // used to build them all, nesting 4000 levels deep, and refuse them afterwards). + let keys = av(arr(vec![(0..4000).map(|i| nv(f64::from(i))).collect()])); + is_err(ev("GROUPBY", vec![keys, av(col(&[1.0])), t("SUM"), empty(), n(4000.0)]), CellError::Num); + } + #[test] fn groupby_total_depth_adds_subtotals() { // #1456 item 1, values as Excel 365 shows them. From d5c892f23874f20b105f16a9cee8b1c23eb5d75d Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:37:00 -0600 Subject: [PATCH 20/80] #2630: mark the checkbox mapping bag with its extRef A workbook with checkboxes saved by GridCraft made Excel repair it ("Repaired records: Format from /xl/styles.xml (Styles)") and drop every checkbox. The feature property bag part GridCraft writes for checkboxes left out extRef="XFComplementsMapperExtRef" on the XFComplements bag, the bag the cell formats' xfComplement indexes into. With it, Excel opens the file without repairing and shows the checkboxes. Co-Authored-By: Claude Opus 5.5 --- crates/xlsx/src/feature_bags.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/xlsx/src/feature_bags.rs b/crates/xlsx/src/feature_bags.rs index 71a6514a5..db2e73e91 100644 --- a/crates/xlsx/src/feature_bags.rs +++ b/crates/xlsx/src/feature_bags.rs @@ -25,10 +25,13 @@ pub const XF_EXT_URI: &str = "{C7286773-470A-42A8-94C5-96B5CB345126}"; /// The bags for one checkbox cell control, referenced as `xfComplement i="0"`: Checkbox (0), /// XFControls → 0 (1), XFComplement → 1 (2), XFComplements mapping index 0 → 2 (3). Every -/// reference points to an earlier bag, as [MS-XLSX] 2.6.230 requires. +/// reference points to an earlier bag, as [MS-XLSX] 2.6.230 requires. The XFComplements bag is +/// the one `xfComplement` indexes into, and Excel finds it by its `extRef`: without +/// `extRef="XFComplementsMapperExtRef"` it repairs the workbook ("Format from /xl/styles.xml") +/// and drops every checkbox (#2630). pub fn checkbox_part() -> String { format!( - "\n012" + "\n012" ) } @@ -73,6 +76,9 @@ mod tests { fn written_chain_resolves_and_hostile_chains_do_not() { let root = crate::xml::parse(checkbox_part().as_bytes()).unwrap_or_default(); assert_eq!(checkbox_complements(&root), vec![true]); + // #2630: Excel repairs the file unless the mapping bag carries this extRef. + let mapper = root.kids("bag").find(|b| b.attr("type") == Some("XFComplements")); + assert_eq!(mapper.and_then(|b| b.attr("extRef")), Some("XFComplementsMapperExtRef")); // Out-of-range, wrong-type and self references resolve to false without panicking. let bad = format!( "99114294967295x" From 8b2550c9cf8fac8c9759f48fa8eafbb376a4d79d Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:44:15 -0600 Subject: [PATCH 21/80] #1442: Ctrl+. moves to the next corner of the selection Excel's Next Corner of Selection (Ctrl+., Control+. on a Mac) moves the active cell clockwise to the next corner of the selected area (top-left, top-right, bottom-right, bottom-left) and keeps the area selected; the new active cell is also the anchor, so Shift+arrows then move the opposite corner. GridCraft had no such command. New command selection.nextCorner (Selection::next_corner) bound to Ctrl+. on every platform. A one-row or one-column area goes end to end; a single cell stays where it is. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 11 +++++++- crates/engine/src/cmd/edit/selection.rs | 6 +++++ crates/engine/src/cmd/edit/tests.rs | 26 +++++++++++++++++++ crates/engine/src/selection.rs | 17 ++++++++++++ crates/ui-egui/src/keymap.rs | 2 ++ crates/ui-egui/tests/it/keyboard_shortcuts.rs | 5 ++++ docs/ui-parity.md | 4 ++- 7 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2aa9d8fe3..cf75a429d 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -36,7 +36,7 @@ use go_to::go_to_special; use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts}; use paste_special::paste_special; pub(crate) use paste_special::{rewrite_all_formulas, rewrite_cell_links, rewrite_python_refs}; -use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set}; +use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_next_corner, selection_set}; pub fn specs() -> Vec { vec![ @@ -62,6 +62,15 @@ pub fn specs() -> Vec { cmd!(noundo "selection.set", "Select", [], None, "{range: \"A1:B2,D4\" | cell, active?: \"A1\", sheet?}", has_doc, selection_set), cmd!(noundo "selection.move", "Move Selection", [], None, "{dr, dc, extend?: bool, jump?: bool (Ctrl+arrow), page?: bool}", has_doc, selection_move), cmd!(noundo "selection.next", "Next Cell in Selection", [], None, "{forward?: true, byRow?: false} (Enter/Tab inside a selection)", has_doc, selection_next), + cmd!( + noundo "selection.nextCorner", + "Next Corner of Selection", + [], + Some("Ctrl+."), + "{} (the active cell moves clockwise to the next corner of the selected area, which stays selected)", + has_doc, + selection_next_corner + ), cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} (current region first, then the whole sheet)", has_doc, select_all), cmd!(noundo "selection.currentRegion", "Select Current Region", [], Some("Ctrl+Shift+8"), "{}", has_doc, select_region), cmd!(noundo "selection.row", "Select Entire Row", [], Some("Shift+Space"), "{}", has_doc, select_row), diff --git a/crates/engine/src/cmd/edit/selection.rs b/crates/engine/src/cmd/edit/selection.rs index ede66a34d..03e178156 100644 --- a/crates/engine/src/cmd/edit/selection.rs +++ b/crates/engine/src/cmd/edit/selection.rs @@ -137,6 +137,12 @@ pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result { Ok(json!({"active": d.selection.active.a1()})) } +pub(super) fn selection_next_corner(s: &mut Session, _: &Json) -> Result { + let d = s.doc_mut()?; + d.selection.next_corner(); + Ok(json!({"active": d.selection.active.a1(), "selection": d.selection.a1()})) +} + pub(super) fn select_all(s: &mut Session, _: &Json) -> Result { let d = s.doc_mut()?; let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 3f3a2fb6d..ead610e50 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -371,3 +371,29 @@ fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() { let sh = s.doc().unwrap().wb.active().unwrap(); assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text)); } + +#[test] +fn next_corner_of_selection_goes_clockwise_and_anchors_there() { + // #1442, as Excel does it: B2 → D2 → D4 → B4 → B2, the area stays selected, and Shift+arrows + // then move the corner opposite the new active cell. + let mut s = Session::new(); + s.new_workbook(); + s.execute("selection.set", json!({"range": "B2:D4", "active": "B2"})).unwrap(); + let mut seen = Vec::new(); + for _ in 0..4 { + let r = s.execute("selection.nextCorner", json!({})).unwrap(); + assert_eq!(r["selection"], "B2:D4"); + seen.push(r["active"].as_str().unwrap().to_string()); + } + assert_eq!(seen, ["D2", "D4", "B4", "B2"]); + s.execute("selection.nextCorner", json!({})).unwrap(); + s.execute("selection.nextCorner", json!({})).unwrap(); + s.execute("selection.move", json!({"dr": 0, "dc": 1, "extend": true})).unwrap(); + assert_eq!(s.doc().unwrap().selection.current().a1(), "C2:D4"); + // One row goes end to end; a single cell stays. + s.execute("selection.set", json!({"range": "B2:D2", "active": "B2"})).unwrap(); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "D2"); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B2"); + s.execute("selection.set", json!({"range": "C5"})).unwrap(); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C5"); +} diff --git a/crates/engine/src/selection.rs b/crates/engine/src/selection.rs index 0e00e4869..a69870adb 100644 --- a/crates/engine/src/selection.rs +++ b/crates/engine/src/selection.rs @@ -128,6 +128,23 @@ impl Selection { } } +impl Selection { + /// Ctrl+. (Next Corner of Selection): the active cell moves clockwise to the next corner of + /// the current area (top-left, top-right, bottom-right, bottom-left) and becomes the anchor, + /// so Shift+arrows then move the opposite corner. The area stays selected. From a cell that + /// isn't a corner the next corner is the top-left one; a single cell stays where it is. + pub fn next_corner(&mut self) { + let r = self.current(); + let corners = [r.start, CellRef::new(r.start.row, r.end.col), r.end, CellRef::new(r.end.row, r.start.col)]; + let next = match corners.iter().position(|c| *c == self.active) { + Some(i) => (1..=4).filter_map(|k| corners.get((i + k) % 4)).copied().find(|c| *c != self.active).unwrap_or(self.active), + None => r.start, + }; + self.active = next; + self.anchor = next; + } +} + /// Ctrl+arrow: jump to the edge of the current data region (Excel's End mode). pub fn jump(sheet: &Sheet, from: CellRef, dr: i64, dc: i64) -> CellRef { if dr == 0 && dc == 0 { diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 5217724c0..5acce9b4d 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -115,6 +115,8 @@ fn lookup_number_formats(ctx: LookupContext) -> Option { Num7 if ctrl && s => borders("outside"), Minus if ctrl && s => borders("none"), Num8 if ctrl && s => run("selection.currentRegion"), + // Ctrl+. (Control on a Mac too): Next Corner of Selection. + Period if ctrl && !s => run("selection.nextCorner"), Semicolon if ctrl && s => Shortcut::Time, Quote if ctrl && s => Shortcut::ValueAbove, Quote if ctrl => Shortcut::FormulaAbove, diff --git a/crates/ui-egui/tests/it/keyboard_shortcuts.rs b/crates/ui-egui/tests/it/keyboard_shortcuts.rs index 8d3de6fcb..f59504dee 100644 --- a/crates/ui-egui/tests/it/keyboard_shortcuts.rs +++ b/crates/ui-egui/tests/it/keyboard_shortcuts.rs @@ -100,6 +100,11 @@ fn windows_and_mac_variants() { assert_eq!(lookup(Key::ArrowLeft, Some(Key::Num4), alt_shift, false), run("data.ungroup")); assert_eq!(lookup(Key::PageDown, Some(Key::Num3), Modifiers::ALT, false), Some(Shortcut::ScreenRight)); assert_eq!(lookup(Key::PageDown, Some(Key::Num3), CTRL_SHIFT, false), None); + // Ctrl+. is Next Corner of Selection, with Control on a Mac too (#1442); Cmd+Shift+. stays + // Increase Font Size. + assert_eq!(lookup(Key::Period, Some(Key::Period), cmd, false), run("selection.nextCorner")); + assert_eq!(lookup(Key::Period, Some(Key::Period), ctrl, true), run("selection.nextCorner")); + assert_eq!(lookup(Key::Period, Some(Key::Period), Modifiers { shift: true, ..mac_cmd }, true), run("home.increaseFontSize")); // Plain keys stay with the grid (typing, navigation). assert_eq!(lookup(Key::A, Some(Key::A), Modifiers::NONE, false), None); assert_eq!(lookup(Key::F2, Some(Key::F2), Modifiers::NONE, false), None); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..96fa3864d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Ctrl+. Next Corner of Selection; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -125,6 +125,7 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | — | Cmd+Shift+H, Cmd+Shift+R, Cmd+Shift+K / J, Control+L, Control+U | replace, sort, group / ungroup, Define Name, edit cell | **added** | | Alt+Shift+Right / Left | Option+Right / Left is next/previous sheet | group / ungroup | **added** | | Ctrl+Shift+* (Ctrl+Shift+8), Ctrl+Shift+Space | Cmd+Shift+Space | current region, select all | **added** | +| Ctrl+. | Control+. | Next Corner of Selection: the active cell goes clockwise to the next corner and anchors there (`selection.nextCorner`, #1442) | **added** | | Ctrl+Shift+Home / End, Shift+Home | same | extend to A1 / last used cell / row start | **added** | | Ctrl+P | Cmd+P | print | **added** | | Ctrl+Shift+> / < | Cmd+Shift+> / < | font size | **added** | @@ -237,6 +238,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Ctrl+. (Control+. on a Mac): Next Corner of Selection, `selection.nextCorner`: the active cell moves clockwise to the next corner of the selected area, which stays selected, and becomes the anchor, as in Excel (#1442) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From 49f2ef515bf433c0bfb2a1dcef804f6711ef3164 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:52:58 -0600 Subject: [PATCH 22/80] #1609: write page margins as their decimals The page margins are kept as f32 and were widened to f64 when written, so a 0.3-inch margin was saved as left="0.30000001192092896" (and 1.1 as 1.100000023841858). Each margin now goes through its shortest decimal form first, so the file says 0.3, as Excel writes it. Co-Authored-By: Claude Opus 5.5 --- crates/xlsx/src/sheet_write.rs | 7 ++++++- crates/xlsx/src/tests/page_order.rs | 12 ++++++++++++ docs/file-format-parity.md | 3 ++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index a32e6bee6..ed3cb89ea 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -370,7 +370,12 @@ fn write_sheet_printing<'a>(s: &mut String, sheet: &'a Sheet, rels: &mut Rels, o } // Unmodelled content kept from the source file (printer settings, controls, OLE objects…). let kept = crate::passthrough::SheetKept::write(sheet, rels, out); - let m = |i: usize| num(p.margins.get(i).copied().unwrap_or(0.75) as f64); + // Margins are kept as f32: widen each through its shortest decimal, so 0.3 is written as + // `0.3`, as Excel writes it, and not as `0.30000001192092896` (#1609). + let m = |i: usize| { + let inches = p.margins.get(i).copied().unwrap_or(0.75); + num(inches.to_string().parse::().unwrap_or(f64::from(inches))) + }; let _ = write!(s, "", m(0), m(1), m(2), m(3), m(4), m(5)); s.push_str(""#; + let (wb, _) = read_xlsx(&minimal(body, &[], "", "")).unwrap(); + let xml = sheet_xml(&write_xlsx(&wb).unwrap()); + assert!(xml.contains(r#""#), "{xml}"); + // The default sheet writes Excel's default margins exactly. + let xml = sheet_xml(&write_xlsx(&Workbook::new()).unwrap()); + assert!(xml.contains(r#""#), "{xml}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c4bc72bae..45587104c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-03 · **Change:** minor (XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -155,6 +155,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | XLSX `pageMargins` are written as the decimals they were read or set as (`0.3`, as Excel writes them), not as the f64 widening of the stored f32 (`0.30000001192092896`) (#1609 item 7) | | 2026-11-02 | minor | Python cells: `.xlsx` reads and writes `_xlfn._xlws.PY(i,0 or 1)` with the code in the Python part (and reads the string-literal form) with cached values and spills, other formats adopt PY formulas, GridCraft JSON keeps the whole cell, and opening limits code size, result size and spill ranges | | 2026-11-02 | trivial | An equation without Office Math takes its stored source only while its text, paragraphs included, is what the writer wrote for it; a paragraph split that changes no character reads back as a text box with the split text | | 2026-11-02 | trivial | Each equation box carries the marker of its own body in its description, the Office Math box and the plain-text fallback alike, and the reader takes the stored source only while the body it reads matches the marker: the text of an equation without Office Math that another application edited reads back as a text box with the edited text instead of the stale source | From 2fe3bfc0779d5441de35fc4accebd5f1b61473c1 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:59:09 -0600 Subject: [PATCH 23/80] #1786: the live pen stroke is drawn as the ink it commits While dragging with a pen, the stroke under the pointer was always painted blue at 2 pt, whatever pen or highlighter was chosen, so the preview and the committed ink disagreed (a highlighter previewed as a thin blue line). The preview now uses the colour and width draw.stroke stores (pen blue and 2 pt by default, 0.5-40 pt, whole points) and the translucency Ink shapes are painted with above 8 pt. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid/interact.rs | 36 +++++++++++++++++++++++++---- docs/ui-parity.md | 3 ++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 5de25fd53..a94f08e6d 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -225,10 +225,10 @@ fn handle_draw_tool(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool { )) }) .collect(); - input - .ui - .painter() - .add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9)))); + // The line under the pointer is the ink the stroke will commit (#1786). + let theme = app.session.active().map(|d| d.wb.theme.clone()).unwrap_or_default(); + let (color, width) = pen_ink(&app.session.draw_color, app.session.draw_width, &theme); + input.ui.painter().add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(width * geo.z, color))); } if resp.drag_stopped() && app.grid.ink.len() >= 2 { let pts: Vec<[f32; 2]> = std::mem::take(&mut app.grid.ink); @@ -249,6 +249,17 @@ fn handle_draw_tool(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool { false } +/// The colour and width (points) of the ink a pen stroke commits, as `draw.stroke` stores it (the +/// pen's colour, pen blue by default; its width, 2 pt by default, kept to 0.5–40 pt and stored +/// whole) and as Ink shapes paint it (a highlighter, over 8 pt, is translucent). +pub(crate) fn pen_ink(color: &str, width: f32, theme: &gridcraft_engine::model::Theme) -> (Color32, f32) { + let pen_blue = gridcraft_engine::model::Color::rgb(0x1F, 0x5F, 0xC9); + let line = gridcraft_engine::model::Color::from_hex(color).unwrap_or(pen_blue); + let width = if width <= 0.0 { 2.0 } else { width }.clamp(0.5, 40.0).round().max(1.0); + let color = line.resolve(theme).map(crate::theme::color32).unwrap_or(Color32::from_rgb(0x1F, 0x5F, 0xC9)); + (if width > 8.0 { color.gamma_multiply(0.45) } else { color }, width) +} + fn handle_header_autofit(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool { let (geo, sh, sel, pos) = (input.geo, input.sheet, &input.selection, input.position); let (in_col_header, in_row_header) = (input.in_column_header, input.in_row_header); @@ -1044,3 +1055,20 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte ed.point_anchor = Some((sheet, anchor)); app.grid.ensure_visible = true; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_pen_preview_is_the_ink_the_stroke_commits() { + // #1786: the preview was always blue at 2 pt, whatever the pen. + let theme = gridcraft_engine::model::Theme::default(); + assert_eq!(pen_ink("#C00000", 3.0, &theme), (Color32::from_rgb(0xC0, 0, 0), 3.0)); + assert_eq!(pen_ink("", 0.0, &theme), (Color32::from_rgb(0x1F, 0x5F, 0xC9), 2.0), "no pen chosen yet: pen blue, 2 pt"); + assert_eq!(pen_ink("not a colour", 0.2, &theme), (Color32::from_rgb(0x1F, 0x5F, 0xC9), 1.0)); + // A highlighter is wide and translucent, as the committed ink is painted. + let (c, w) = pen_ink("#FFFF00", 12.0, &theme); + assert_eq!((c, w), (Color32::from_rgb(0xFF, 0xFF, 0).gamma_multiply(0.45), 12.0)); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..5b7b4fb66 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (the Draw tab's live stroke preview uses the pen's colour and thickness; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Draw tab: the stroke drawn under the pointer while dragging a pen is the ink that will be committed, in the pen's colour and thickness (translucent for a highlighter), instead of always blue at 2 pt (#1786) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From fe965e758150adadfbc27cd154bc360675715c13 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:02:40 -0600 Subject: [PATCH 24/80] #2225: copy quotes only cells with a tab or line break Copy wrapped any cell that held a double quote in quotes (and doubled its quotes), so copying `say "hi"` into another app gave `"say ""hi"""`. Excel quotes only text with a tab or line break; a cell with just quotes goes on the clipboard as it is. Pasting text read a field that starts with a quote as quoted even when its closing quote doesn't end the field, so `"hi" there` (which Excel copies as it is) pasted as `hi there`. A field is now quoted only when its closing quote is followed by a tab, a line end or the end of the text; otherwise it is the text as written, as Excel pastes it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit/clipboard.rs | 10 ++++++++++ crates/engine/src/cmd/edit/tests.rs | 19 +++++++++++++++++++ crates/engine/src/display.rs | 4 +++- docs/ui-parity.md | 3 ++- 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index 3a1cd0739..1490b082c 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -228,14 +228,23 @@ pub(crate) fn parse_tsv(text: &str) -> Vec> { let mut chars = text.chars().peekable(); let mut quoted = false; let mut at_start = true; + // A quoted field's text as it stands in the input, for a field that turns out not to be one. + let mut raw = String::new(); while let Some(c) = chars.next() { if quoted { + raw.push(c); if c == '"' { if chars.peek() == Some(&'"') { field.push('"'); + raw.push('"'); chars.next(); } else { quoted = false; + // A field is quoted only when its closing quote ends it, as Excel reads it: + // `"hi" there` is that text, quotes included. + if !matches!(chars.peek(), None | Some('\t' | '\n' | '\r')) { + field = std::mem::take(&mut raw); + } } } else { field.push(c); @@ -246,6 +255,7 @@ pub(crate) fn parse_tsv(text: &str) -> Vec> { '"' if at_start => { quoted = true; at_start = false; + raw = String::from('"'); } '\t' => { row.push(std::mem::take(&mut field)); diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 3f3a2fb6d..5034646cd 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -307,6 +307,9 @@ fn tsv() { assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]); assert_eq!(parse_tsv("\"x\ty\"\tz"), vec![vec!["x\ty", "z"]]); assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]); + // #2225: a quote that doesn't close the field leaves it as written, as Excel pastes it. + assert_eq!(parse_tsv("\"hi\" there\tx\n"), vec![vec!["\"hi\" there", "x"]]); + assert_eq!(parse_tsv("say \"hi\"\n"), vec![vec!["say \"hi\""]]); } #[test] @@ -371,3 +374,19 @@ fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() { let sh = s.doc().unwrap().wb.active().unwrap(); assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text)); } + +#[test] +fn copy_quotes_only_cells_with_tabs_or_line_breaks() { + // #2225: a cell holding a quote was wrapped in quotes on copy; Excel quotes only text with a + // tab or line break, and pastes the result back as the same cells. + let mut s = Session::new(); + s.new_workbook(); + for (cell, input) in [("A1", "say \"hi\""), ("A2", "plain"), ("A3", "\"hi\" there"), ("A4", "x \"q\"\ny"), ("A5", "tab\tinside")] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + let text = s.execute("edit.copy", json!({"range": "A1:A5"})).unwrap()["text"].as_str().unwrap().to_string(); + assert_eq!(text, "say \"hi\"\nplain\n\"hi\" there\n\"x \"\"q\"\"\ny\"\n\"tab\tinside\"\n"); + let rows = parse_tsv(&text); + let cells: Vec<&str> = rows.iter().map(|r| r[0].as_str()).collect(); + assert_eq!(cells, ["say \"hi\"", "plain", "\"hi\" there", "x \"q\"\ny", "tab\tinside"]); +} diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index 2189d6c64..06a0fa587 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -156,7 +156,9 @@ pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String { } first = false; let t = cell_text(wb, sh, CellRef::new(row, col)); - if t.contains(['\t', '\n', '"']) { + // Like Excel, only text with a tab or line break is quoted (its quotes doubled); + // `say "hi"` is copied as it is (#2225). + if t.contains(['\t', '\n', '\r']) { out.push('"'); out.push_str(&t.replace('"', "\"\"")); out.push('"'); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..bf36ff04d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (copy quotes only cells with a tab or line break, as Excel does; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Copy puts a cell that holds a quote on the clipboard as it is (`say "hi"`), quoting only text with a tab or line break, as Excel does; pasting text reads a field as quoted only when its closing quote ends it, so `"hi" there` pastes with its quotes (#2225) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From c121fb590152c562546dc2d248109b7e06195e0e Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:07:29 -0600 Subject: [PATCH 25/80] #2809: keep a workbook's R1C1 reference style through open and save A workbook saved by Excel in R1C1 reference style says so in . GridCraft didn't read the attribute, so opening and saving such a file switched it back to A1 style for the next person who opened it in Excel. The calculation settings now keep it (CalcSettings.r1c1, serde default false) and the writer puts it back; A1 stays the default and isn't written. GridCraft itself still shows and takes A1 references; the R1C1 display and entry are the rest of the issue. Co-Authored-By: Claude Opus 5.5 --- crates/model/src/workbook.rs | 5 +++++ crates/xlsx/src/read.rs | 1 + crates/xlsx/src/tests/roundtrip.rs | 20 +++++++++++++++++++- crates/xlsx/src/write.rs | 3 +++ docs/file-format-parity.md | 3 ++- 5 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 2d16b46b5..1c5b007f6 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -95,6 +95,10 @@ pub struct CalcSettings { pub max_change: f64, /// Use precision as displayed. pub precision_as_displayed: bool, + /// R1C1 reference style (``): kept with the file and written back; + /// GridCraft itself shows and takes A1 references. + #[serde(default)] + pub r1c1: bool, } impl Default for CalcSettings { @@ -105,6 +109,7 @@ impl Default for CalcSettings { max_iterations: Self::DEFAULT_ITERATIONS, max_change: Self::DEFAULT_CHANGE, precision_as_displayed: false, + r1c1: false, } } } diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 3cc2a6655..ead44c0e7 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -221,6 +221,7 @@ fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) { wb.calc.set_max_change(d); } wb.calc.precision_as_displayed = !c.flag("fullPrecision", true); + wb.calc.r1c1 = c.attr("refMode") == Some("R1C1"); } if let Some(p) = xml.child("workbookProtection") { wb.protected_structure = p.flag("lockStructure", false); diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 2bdba21b8..ade0835c2 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -20,7 +20,7 @@ fn rr(a: &str) -> RangeRef { fn sample() -> Workbook { let mut wb = Workbook::new(); wb.date_system = DateSystem::D1904; - wb.calc = CalcSettings { mode: CalcMode::Manual, iterative: true, max_iterations: 42, max_change: 0.5, precision_as_displayed: true }; + wb.calc = CalcSettings { mode: CalcMode::Manual, iterative: true, max_iterations: 42, max_change: 0.5, precision_as_displayed: true, r1c1: true }; wb.protected_structure = true; wb.structure_password = Some(gridcraft_model::PasswordHash::new_sha512("s3cret", b"0123456789abcdef")); wb.props = DocProps { @@ -1696,3 +1696,21 @@ fn generated_rule_formulas_use_the_top_left_cell_of_every_area() { z.by_name("xl/worksheets/sheet1.xml").unwrap().read_to_string(&mut xml).unwrap(); assert!(xml.contains("NOT(ISERROR(SEARCH(\"x\",C2)))"), "{xml}"); } + +#[test] +fn r1c1_reference_style_is_kept() { + // #2809: `` was dropped on open, so a workbook saved by Excel in R1C1 + // style came back in A1 style. + let bytes = super::minimal("", &[], "", r#""#); + let (wb, _) = read_xlsx(&bytes).unwrap(); + assert!(wb.calc.r1c1); + let saved = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let book = pkg.read_xml("xl/workbook.xml").unwrap().unwrap(); + assert_eq!(book.child("calcPr").and_then(|c| c.attr("refMode")), Some("R1C1")); + // A1 is the default and isn't written. + let saved = write_xlsx(&Workbook::new()).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let book = pkg.read_xml("xl/workbook.xml").unwrap().unwrap(); + assert_eq!(book.child("calcPr").and_then(|c| c.attr("refMode")), None); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 2c1e8f60f..a96e7a350 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -326,6 +326,9 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) if wb.calc.precision_as_displayed { w.push_str(" fullPrecision=\"0\""); } + if wb.calc.r1c1 { + w.push_str(" refMode=\"R1C1\""); + } if out.needs_calc { w.push_str(" fullCalcOnLoad=\"1\""); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c4bc72bae..f07bb1fcf 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-03 · **Change:** minor (a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -155,6 +155,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | XLSX `` (a workbook saved in R1C1 reference style) is read into the calculation settings and written back; GridCraft itself still shows A1 references (#2809, file part) | | 2026-11-02 | minor | Python cells: `.xlsx` reads and writes `_xlfn._xlws.PY(i,0 or 1)` with the code in the Python part (and reads the string-literal form) with cached values and spills, other formats adopt PY formulas, GridCraft JSON keeps the whole cell, and opening limits code size, result size and spill ranges | | 2026-11-02 | trivial | An equation without Office Math takes its stored source only while its text, paragraphs included, is what the writer wrote for it; a paragraph split that changes no character reads back as a text box with the split text | | 2026-11-02 | trivial | Each equation box carries the marker of its own body in its description, the Office Math box and the plain-text fallback alike, and the reader takes the stored source only while the body it reads matches the marker: the text of an equation without Office Math that another application edited reads back as a text box with the edited text instead of the stale source | From 5acb4af7892c14d2fe8da57dae91f634b281cabf Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:08:01 -0600 Subject: [PATCH 26/80] #1442: Ctrl+. skips corners the sheet can't select and stops once on a merged corner Addresses CodeRabbit review on #3226: - "Check the next corner against protected-sheet selection rules": on a sheet protected without Select Locked Cells, Ctrl+. could make a locked corner active. It now goes on to the next corner it may select. - "Keep the active cell at a merged cell's anchor": a corner inside a merged cell made that cell active instead of the merge's top-left cell. The corner now lands on the top-left cell, and corners in the same merged cell are one stop. Selection::next_corner_by takes where each corner leads (or None to skip it); next_corner keeps its plain behaviour. Tests: next_corner_skips_corners_the_sheet_protection_does_not_let_you_select, next_corner_stops_once_on_a_merged_corner_at_its_top_left_cell. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit/selection.rs | 8 +++++++- crates/engine/src/cmd/edit/tests.rs | 27 +++++++++++++++++++++++++ crates/engine/src/selection.rs | 19 ++++++++++++----- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/edit/selection.rs b/crates/engine/src/cmd/edit/selection.rs index 03e178156..27db1c53a 100644 --- a/crates/engine/src/cmd/edit/selection.rs +++ b/crates/engine/src/cmd/edit/selection.rs @@ -139,7 +139,13 @@ pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result { pub(super) fn selection_next_corner(s: &mut Session, _: &Json) -> Result { let d = s.doc_mut()?; - d.selection.next_corner(); + let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; + // A corner in a merged cell lands on its top-left cell; one the sheet's protection doesn't + // let you select is skipped. + let stop = |c: CellRef| Some(sheet.merge_at(c).map_or(c, |m| m.start)).filter(|c| cell_selectable(&d.wb, sheet, *c)); + let mut next = d.selection.clone(); + next.next_corner_by(stop); + d.selection = next; Ok(json!({"active": d.selection.active.a1(), "selection": d.selection.a1()})) } diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index ead610e50..8e8c52b36 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -397,3 +397,30 @@ fn next_corner_of_selection_goes_clockwise_and_anchors_there() { s.execute("selection.set", json!({"range": "C5"})).unwrap(); assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C5"); } + +#[test] +fn next_corner_skips_corners_the_sheet_protection_does_not_let_you_select() { + // With locked cells unselectable, Ctrl+. goes on to the next corner it may select. + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.lockCell", json!({"range": "B2:C3", "on": false})).unwrap(); + s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap(); + s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap(); + let r = s.execute("selection.nextCorner", json!({})).unwrap(); + assert_eq!((r["active"].as_str(), r["selection"].as_str()), (Some("B3"), Some("B2:D3")), "D2 and D3 are locked"); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B2"); +} + +#[test] +fn next_corner_stops_once_on_a_merged_corner_at_its_top_left_cell() { + // A corner inside a merged cell is that merged cell, whose active cell is its top-left one. + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.mergeCells", json!({"range": "C2:D2"})).unwrap(); + s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap(); + let seen: Vec = (0..4).map(|_| s.execute("selection.nextCorner", json!({})).unwrap()["active"].as_str().unwrap().to_string()).collect(); + assert_eq!(seen, ["C2", "D3", "B3", "B2"]); + // A selection that is one merged cell has no other corner. + s.execute("selection.set", json!({"range": "C2:D2", "active": "C2"})).unwrap(); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C2"); +} diff --git a/crates/engine/src/selection.rs b/crates/engine/src/selection.rs index a69870adb..3f3feee9d 100644 --- a/crates/engine/src/selection.rs +++ b/crates/engine/src/selection.rs @@ -134,14 +134,23 @@ impl Selection { /// so Shift+arrows then move the opposite corner. The area stays selected. From a cell that /// isn't a corner the next corner is the top-left one; a single cell stays where it is. pub fn next_corner(&mut self) { + self.next_corner_by(Some); + } + + /// [`Selection::next_corner`] where `stop` says where each corner takes the active cell: the + /// top-left cell of a merged cell the corner is in, or `None` for a corner that can't be + /// selected (it is skipped). Corners that end up on the active cell are one stop with it. + pub fn next_corner_by(&mut self, stop: impl Fn(CellRef) -> Option) { let r = self.current(); let corners = [r.start, CellRef::new(r.start.row, r.end.col), r.end, CellRef::new(r.end.row, r.start.col)]; - let next = match corners.iter().position(|c| *c == self.active) { - Some(i) => (1..=4).filter_map(|k| corners.get((i + k) % 4)).copied().find(|c| *c != self.active).unwrap_or(self.active), - None => r.start, + let next = match corners.iter().rposition(|c| stop(*c) == Some(self.active)) { + Some(i) => (1..=4).filter_map(|k| corners.get((i + k) % 4)).filter_map(|c| stop(*c)).find(|c| *c != self.active), + None => corners.iter().find_map(|c| stop(*c)), }; - self.active = next; - self.anchor = next; + if let Some(next) = next { + self.active = next; + self.anchor = next; + } } } From e23994b7cb990416caee69b871dd140c1fba63d7 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:17:35 -0600 Subject: [PATCH 27/80] #3166: a web page's table keeps its characters, its shown text and its header formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit review on #3223: - "Decode HTML before parsing its cells": a page in Windows-1252 read as lossy UTF-8, so "Café" lost its é. The page is now decoded as a CSV is (BOM, UTF-8, else Windows-1252/1256); decode_text is exported for it. - "Keep visible cell text when csv_as_text is enabled": opened as text, a cell with x:num took the recorded number ("4,50" became "4.5"). The recorded number is used only when values are read. - "Preserve the imported format when styling a header": a date in a lost its date format when made bold and centred. Header styling now builds on the style the CSV reader gave the cell. Test: a_web_page_table_keeps_its_characters_text_and_header_formats. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/io.rs | 36 ++++++++++++++++++++++------------- crates/engine/src/io/tests.rs | 20 +++++++++++++++++++ crates/xlsx/src/lib.rs | 2 +- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 9e3c7f7ae..f62a55e40 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -355,7 +355,8 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String { /// header cells (``) are bold and centred, and the sheet takes the name the page gives it /// (``) or the file's. `None` when the bytes aren't such a page. fn read_html_table(name: &str, kind: Option, bytes: &[u8], csv_as_text: bool) -> Option)>> { - let text = String::from_utf8_lossy(bytes); + // Decoded as a CSV is (BOM, UTF-8, else Windows-1252/1256), so a legacy page keeps its letters. + let text = gridcraft_xlsx::decode_text(bytes); let head: String = text.trim_start_matches('\u{feff}').trim_start().chars().take(16).collect::().to_ascii_lowercase(); if ![", bytes: &[u8], csv_as_text let fields: Vec = row .iter() .map(|cell| { - let field = match cell.num { + // The number the page recorded (`x:num`) only when values are read; as text, the + // cell keeps the text it shows. + let field = match cell.num.filter(|_| !csv_as_text) { Some(n) => gridcraft_core::number_to_text(n), None => cell.text.split_whitespace().collect::>().join(" "), }; @@ -384,17 +387,24 @@ fn read_html_table(name: &str, kind: Option, bytes: &[u8], csv_as_text let read = gridcraft_xlsx::read_csv_with_report(tsv.as_bytes(), &opts).map_err(|e| EngineError::Other(e.to_string())); Some(read.map(|(mut wb, report)| { wb.csv_delimiter = None; - let header_style = wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| { - st.font.bold = true; - st.align.h = gridcraft_model::HAlign::Center; - }); - if let Some(sh) = wb.sheet_mut(0) { - for (r, row) in rows.iter().enumerate() { - for (c, cell) in row.iter().enumerate() { - if cell.header && !cell.text.trim().is_empty() { - sh.set_style(CellRef::new(r as u32, c as u32), header_style); - } - } + // Header cells are bold and centred on top of the style the CSV reader gave them, so a + // date in a header keeps its date format. + let headers: Vec = rows + .iter() + .enumerate() + .flat_map(|(r, row)| row.iter().enumerate().filter(|(_, cell)| cell.header && !cell.text.trim().is_empty()).map(move |(c, _)| CellRef::new(r as u32, c as u32))) + .collect(); + let mut bold: std::collections::HashMap = std::collections::HashMap::new(); + for at in headers { + let Some(base) = wb.sheet(0).map(|sh| sh.style_id(at)) else { continue }; + let id = *bold.entry(base).or_insert_with(|| { + wb.styles.derive(base, |st| { + st.font.bold = true; + st.align.h = gridcraft_model::HAlign::Center; + }) + }); + if let Some(sh) = wb.sheet_mut(0) { + sh.set_style(at, id); } } let page_name = find_between(&text, "", "").map(|n| crate::clip_html::normalized(n.trim())); diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index 34b1a3c0a..fab60261f 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -636,3 +636,23 @@ fn a_web_page_saved_as_xls_opens_its_table() { let (wb, _) = open_bytes("notes.csv", b",1\nx,2\n").unwrap(); assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::number(2.0)); } + +#[test] +fn a_web_page_table_keeps_its_characters_text_and_header_formats() { + // A page saved in Windows-1252 keeps its accented letters (it read as replacement characters). + let mut page = b"
Caf".to_vec(); + page.push(0xE9); + page.extend_from_slice(b"2024-01-15
Plum4,50
"); + let (wb, _) = open_bytes("menu.xls", &page).unwrap(); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::text("Café")); + // A date in a header cell stays a date, bold and centred (its format was replaced). + let b1 = wb.styles.get(sh.style_id(CellRef::parse("B1").unwrap())); + assert!(matches!(sh.value(CellRef::parse("B1").unwrap()), gridcraft_core::Value::Number(_))); + assert!(b1.font.bold && b1.align.h == gridcraft_model::HAlign::Center); + assert_ne!(b1.num_fmt.as_str(), "General", "the date keeps its date format"); + assert_eq!(sh.value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::number(4.5)); + // Opened as text, a cell keeps the text it shows, not the number the page recorded for it. + let (wb, _) = open_bytes_with("menu.xls", &page, None, true).unwrap(); + assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::text("4,50")); +} diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 5b94531c4..a6ede6af5 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -47,7 +47,7 @@ use gridcraft_core::DateSystem; use gridcraft_model::Workbook; pub use crypto::{DecryptError, check_encryption, decrypt}; -pub use csv::{MAX_CSV_BYTES, read_csv, read_csv_with_report, write_csv, write_csv_limited}; +pub use csv::{MAX_CSV_BYTES, decode_text, read_csv, read_csv_with_report, write_csv, write_csv_limited}; pub use ods::read_ods; pub use package::MAX_PART; pub use passthrough::rebase_links; From 914a76a9d84b3cca02d247662c9091624ce3ebe1 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:21:40 -0600 Subject: [PATCH 28/80] #1442: Ctrl+. skips hidden corners and stays inside the selected area Addresses CodeRabbit review on #3226, "Skip hidden corner destinations" and "Keep merged destinations inside the selected area": with column D hidden, Ctrl+. on B2:D3 made D2 active; with B1:C1 merged and column C selected, it made B1 active, outside the selection. A corner in a hidden row or column, or one whose merged cell starts outside the selected area, is now passed over like a locked corner on a protected sheet. Test: next_corner_skips_hidden_corners_and_stays_inside_the_selected_area. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit/selection.rs | 11 ++++++++--- crates/engine/src/cmd/edit/tests.rs | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/edit/selection.rs b/crates/engine/src/cmd/edit/selection.rs index 27db1c53a..8115f6390 100644 --- a/crates/engine/src/cmd/edit/selection.rs +++ b/crates/engine/src/cmd/edit/selection.rs @@ -140,9 +140,14 @@ pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result { pub(super) fn selection_next_corner(s: &mut Session, _: &Json) -> Result { let d = s.doc_mut()?; let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; - // A corner in a merged cell lands on its top-left cell; one the sheet's protection doesn't - // let you select is skipped. - let stop = |c: CellRef| Some(sheet.merge_at(c).map_or(c, |m| m.start)).filter(|c| cell_selectable(&d.wb, sheet, *c)); + // A corner in a merged cell lands on its top-left cell. One in a hidden row or column, one + // the sheet's protection doesn't let you select, and a merged cell that starts outside the + // selected area are skipped. + let area = d.selection.current(); + let stop = |c: CellRef| { + Some(sheet.merge_at(c).map_or(c, |m| m.start)) + .filter(|c| area.contains(*c) && !sheet.is_row_hidden(c.row) && !sheet.is_col_hidden(c.col) && cell_selectable(&d.wb, sheet, *c)) + }; let mut next = d.selection.clone(); next.next_corner_by(stop); d.selection = next; diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 8e8c52b36..c9051252d 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -424,3 +424,22 @@ fn next_corner_stops_once_on_a_merged_corner_at_its_top_left_cell() { s.execute("selection.set", json!({"range": "C2:D2", "active": "C2"})).unwrap(); assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C2"); } + +#[test] +fn next_corner_skips_hidden_corners_and_stays_inside_the_selected_area() { + // A corner in a hidden column is passed over, as keyboard moves pass over hidden cells. + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.hideColumns", json!({"cols": "D:D"})).unwrap(); + s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap(); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B3"); + // Column C selected with B1:C1 merged: its top corner's merged cell starts in column B, + // outside the selection, so the active cell doesn't go there. + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.mergeCells", json!({"range": "B1:C1"})).unwrap(); + s.execute("selection.set", json!({"cell": "C5"})).unwrap(); + s.execute("selection.column", json!({})).unwrap(); + let r = s.execute("selection.nextCorner", json!({})).unwrap(); + assert_eq!((r["active"].as_str(), r["selection"].as_str()), (Some("C1048576"), Some("C:C"))); +} From 17f733a81e7b4fd85dc0f99c7d492cc1919d21a1 Mon Sep 17 00:00:00 2001 From: adifiedio Date: Tue, 3 Nov 2026 12:55:11 +0530 Subject: [PATCH 29/80] Frozen panes: frozen columns keep their text, gridlines and borders while scrolling (#2544) The grid took a column's right edge to be where the next column starts, `x(col + 1)`. At the freeze line the next column is in the scrolled pane and moves with the scroll, so everything measured that way inside the frozen columns shrank as the sheet scrolled right: - text overflowing from a frozen cell was clipped at the scrolled column's edge, until only a character or two showed; - the frozen columns' gridlines stopped short, with a stray vertical line drawn inside them; - a merged range's borders under the last frozen column ended early; - the freeze handle measured the last frozen column wrongly when dropped while scrolled. Geo gains `x_end`/`y_end` (a column's or row's own far edge) and `same_col_pane`, and every such measurement uses them. Text overflows only into empty cells of its own pane. In Page Layout view the gridlines now stop at each page's last column instead of running into the margin. Fixes #2544 --- crates/ui-egui/src/grid/cell_paint.rs | 4 +- crates/ui-egui/src/grid/geo.rs | 18 +++-- crates/ui-egui/src/grid/interact.rs | 4 +- crates/ui-egui/src/grid/quadrant.rs | 31 ++++----- crates/ui-egui/tests/it/borders.rs | 37 +++++++++++ crates/ui-egui/tests/it/freeze_handles.rs | 16 +++++ .../ui-egui/tests/it/text_overflow_scroll.rs | 66 +++++++++++++++++++ docs/ui-parity.md | 3 +- 8 files changed, 153 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/grid/cell_paint.rs b/crates/ui-egui/src/grid/cell_paint.rs index 9c6a0b10b..187790e6c 100644 --- a/crates/ui-egui/src/grid/cell_paint.rs +++ b/crates/ui-egui/src/grid/cell_paint.rs @@ -194,7 +194,7 @@ pub(super) fn merged_edges(wb: &Workbook, si: usize, sh: &Sheet, geo: &Geo, area if sh.col_width(col) == 0.0 { continue; } - let (x0, x1) = (geo.x(sh, col), geo.x(sh, col.saturating_add(1))); + let (x0, x1) = (geo.x(sh, col), geo.x_end(sh, col)); let top = look(wb, si, sh, CellRef::new(m.start.row, col), cf).0.style.borders.top; let bottom = look(wb, si, sh, CellRef::new(m.end.row, col), cf).0.style.borders.bottom; out.push((top, [pos2(x0, rect.top()), pos2(x1, rect.top())])); @@ -205,7 +205,7 @@ pub(super) fn merged_edges(wb: &Workbook, si: usize, sh: &Sheet, geo: &Geo, area if sh.row_height(row) == 0.0 { continue; } - let (y0, y1) = (geo.y(sh, row), geo.y(sh, row.saturating_add(1))); + let (y0, y1) = (geo.y(sh, row), geo.y_end(sh, row)); let left = look(wb, si, sh, CellRef::new(row, m.start.col), cf).0.style.borders.left; let right = look(wb, si, sh, CellRef::new(row, m.end.col), cf).0.style.borders.right; out.push((left, [pos2(rect.left(), y0), pos2(rect.left(), y1)])); diff --git a/crates/ui-egui/src/grid/geo.rs b/crates/ui-egui/src/grid/geo.rs index 33b1b5461..a1005b32f 100644 --- a/crates/ui-egui/src/grid/geo.rs +++ b/crates/ui-egui/src/grid/geo.rs @@ -158,11 +158,21 @@ impl Geo { self.cells.top() + self.frozen_h + (self.ri.start(row) as f32 - self.ri.start(self.fr) as f32 - self.scroll.y) * self.z } } + /// Screen x of `col`'s right edge. Not `x(col + 1)`: across the freeze line (and between + /// pages) the next column is laid out elsewhere (#2544). + pub fn x_end(&self, sh: &Sheet, col: u32) -> f32 { + self.x(sh, col) + sh.col_width(col) * self.z + } + /// Screen y of `row`'s bottom edge (see `x_end`). + pub fn y_end(&self, sh: &Sheet, row: u32) -> f32 { + self.y(sh, row) + sh.row_height(row) * self.z + } + /// Columns `a` and `b` are on the same side of the freeze line, so they scroll together. + pub fn same_col_pane(&self, a: u32, b: u32) -> bool { + (a < self.fc) == (b < self.fc) + } pub fn cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect { - Rect::from_min_max( - pos2(self.x(sh, c.col), self.y(sh, c.row)), - pos2(self.x(sh, c.col) + sh.col_width(c.col) * self.z, self.y(sh, c.row) + sh.row_height(c.row) * self.z), - ) + Rect::from_min_max(pos2(self.x(sh, c.col), self.y(sh, c.row)), pos2(self.x_end(sh, c.col), self.y_end(sh, c.row))) } pub fn range_rect(&self, sh: &Sheet, r: RangeRef) -> Rect { let a = self.cell_rect(sh, r.start); diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 5de25fd53..fc60e0626 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -891,7 +891,7 @@ fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 { return 0; } let c = geo.col_at(sh, p.x); - let (l, r) = (geo.x(sh, c), geo.x(sh, c.saturating_add(1).min(MAX_COLS - 1))); + let (l, r) = (geo.x(sh, c), geo.x_end(sh, c)); let b = if p.x - l > r - p.x { c.saturating_add(1) } else { c }; let room = geo.ci.at((((geo.cells.width() - 40.0) / geo.z).max(0.0)) as f64); b.min(room).min(MAX_COLS - 1) @@ -900,7 +900,7 @@ fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 { return 0; } let rr = geo.row_at(sh, p.y); - let (t, b2) = (geo.y(sh, rr), geo.y(sh, rr.saturating_add(1).min(MAX_ROWS - 1))); + let (t, b2) = (geo.y(sh, rr), geo.y_end(sh, rr)); let b = if p.y - t > b2 - p.y { rr.saturating_add(1) } else { rr }; let room = geo.ri.at((((geo.cells.height() - 40.0) / geo.z).max(0.0)) as f64); b.min(room).min(MAX_ROWS - 1) diff --git a/crates/ui-egui/src/grid/quadrant.rs b/crates/ui-egui/src/grid/quadrant.rs index 554bdcfdd..9c5712da7 100644 --- a/crates/ui-egui/src/grid/quadrant.rs +++ b/crates/ui-egui/src/grid/quadrant.rs @@ -142,21 +142,17 @@ fn paint_gridlines(q: &QuadrantPaint<'_>) { if sh.show_gridlines { let stroke = Stroke::new(1.0, t.gridline); let top = geo.y(sh, r0); - let bottom = geo.y(sh, r1 + 1); + let bottom = geo.y_end(sh, r1); let left = geo.x(sh, c0); - let right = geo.x(sh, c1 + 1); - for c in c0..=c1 + 1 { - if c <= c1 && sh.col_width(c) == 0.0 { - continue; - } - let x = geo.x(sh, c).round() - 0.5; + let right = geo.x_end(sh, c1); + let xs = (c0..=c1).filter(|&c| sh.col_width(c) > 0.0).map(|c| geo.x(sh, c)).chain([right]); + for x in xs { + let x = x.round() - 0.5; p.line_segment([pos2(x, top), pos2(x, bottom)], stroke); } - for r in r0..=r1 + 1 { - if r <= r1 && sh.row_height(r) == 0.0 { - continue; - } - let y = geo.y(sh, r).round() - 0.5; + let ys = (r0..=r1).filter(|&r| sh.row_height(r) > 0.0).map(|r| geo.y(sh, r)).chain([bottom]); + for y in ys { + let y = y.round() - 0.5; p.line_segment([pos2(left, y), pos2(right, y)], stroke); } } @@ -224,7 +220,6 @@ fn outside_text_cells(q: &mut QuadrantPaint<'_>, merges: &[RangeRef]) -> Vec<(Ce // Text cells just outside the quadrant whose text may overflow into it: the nearest non-empty // cell left of `c0` and right of `c1` in each row (within the same frozen / scrolled pane). let mut outside: Vec<(CellRef, Rect, Look, Option)> = Vec::new(); - let same_pane = |col: u32| (col < geo.fc) == (c0 < geo.fc); for r in r0..=r1 { if sh.row_height(r) == 0.0 { continue; @@ -232,7 +227,7 @@ fn outside_text_cells(q: &mut QuadrantPaint<'_>, merges: &[RangeRef]) -> Vec<(Ce let left = (c0.saturating_sub(20)..c0).rev().map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); let right = (c1.saturating_add(1)..=c1.saturating_add(20).min(MAX_COLS - 1)).map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); for n in [left, right].into_iter().flatten() { - if same_pane(n.col) && sh.col_width(n.col) > 0.0 && in_merge(n).is_none() { + if geo.same_col_pane(n.col, c0) && sh.col_width(n.col) > 0.0 && in_merge(n).is_none() { let (lk, cfl) = look(wb, si, sh, n, cf); outside.push((n, geo.cell_rect(sh, n), lk, cfl)); } @@ -333,7 +328,9 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option, painted: &(CellRef, Rect, Look, Option c.col { - right = geo.x(sh, across_end + 1); + right = geo.x_end(sh, across_end); } if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) { let mut cc = c.col + 1; while right - rect.left() < w + 2.0 * pad && cc < MAX_COLS && empty_ok(CellRef::new(c.row, cc)) && cc <= c1 + 20 { - right = geo.x(sh, cc + 1); + right = geo.x_end(sh, cc); cc += 1; } } diff --git a/crates/ui-egui/tests/it/borders.rs b/crates/ui-egui/tests/it/borders.rs index a9f203012..c0213ed92 100644 --- a/crates/ui-egui/tests/it/borders.rs +++ b/crates/ui-egui/tests/it/borders.rs @@ -160,3 +160,40 @@ fn merged_range_draws_the_outline_from_its_edge_cells() { assert_eq!(ys.iter().filter(|y| (**y - max_y).abs() < 0.5).count(), 4, "bottom edge"); assert!(max_x - min_x > 100.0 && max_y - min_y > 40.0, "the outline spans the whole merged area"); } + +/// #2544: a merged range in frozen columns keeps its whole outline while the others scroll: the +/// bottom edge under its last column ends at that column's own right edge, the freeze line. +#[test] +fn frozen_merged_range_keeps_its_outline_when_scrolled() { + let mut h = harness(); + h.state_mut().run("home.borders", json!({"range": "A2:B3", "preset": "thickOutside", "color": "#C81E1E"})).unwrap(); + h.state_mut().run("home.mergeCells", json!({"range": "A2:B3"})).unwrap(); + h.state_mut().run("view.freezePanes", json!({"cell": "C1"})).unwrap(); + h.state_mut().run("selection.set", json!({"range": "H10"})).unwrap(); + h.state_mut().view_mut().unwrap().scroll.x = 300.0; + h.run_steps(6); + let freeze_x = { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = gridcraft_ui_egui::grid::Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cells.left() + geo.frozen_w + }; + let red = Color32::from_rgb(0xC8, 0x1E, 0x1E); + let right_ends: Vec = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::LineSegment { points, stroke } if stroke.color == red && (points[0].y - points[1].y).abs() < 0.5 => { + Some(points[0].x.max(points[1].x)) + } + _ => None, + }) + .collect(); + assert_eq!(right_ends.len(), 4, "top and bottom edges, one segment per column: {right_ends:?}"); + assert_eq!( + right_ends.iter().filter(|x| (**x - freeze_x).abs() < 1.0).count(), + 2, + "B's segments end at the freeze line: {right_ends:?} vs {freeze_x}" + ); +} diff --git a/crates/ui-egui/tests/it/freeze_handles.rs b/crates/ui-egui/tests/it/freeze_handles.rs index 7bcc81d1a..e3ccf8c65 100644 --- a/crates/ui-egui/tests/it/freeze_handles.rs +++ b/crates/ui-egui/tests/it/freeze_handles.rs @@ -66,3 +66,19 @@ fn dragging_the_handles_freezes_moves_and_unfreezes() { drag(&mut h, rows.center(), pos2(rows.center().x, g.cells.top() - 5.0)); assert_eq!(freeze(&h), None); } + +/// The last frozen column ends at the freeze line however far the other columns scroll, while +/// the next column's left edge moves with the scroll (#2544). +#[test] +fn last_frozen_column_ends_at_the_freeze_line_when_scrolled() { + let mut h = harness(); + h.state_mut().run("view.freezePanes", serde_json::json!({"cell": "C3"})).unwrap(); + h.state_mut().view_mut().unwrap().scroll = egui::vec2(300.0, 200.0); + h.run_steps(2); + let g = geo(&h); + let sh = h.state().session.active().unwrap().wb.active().unwrap().clone(); + let split = g.cells.min + egui::vec2(g.frozen_w, g.frozen_h); + assert!((g.x_end(&sh, 1) - split.x).abs() < 0.01 && (g.y_end(&sh, 1) - split.y).abs() < 0.01); + assert!(g.x(&sh, 2) < split.x && g.y(&sh, 2) < split.y, "C and row 3 are scrolled under the frozen panes"); + assert!(g.same_col_pane(0, 1) && g.same_col_pane(2, 9) && !g.same_col_pane(1, 2)); +} diff --git a/crates/ui-egui/tests/it/text_overflow_scroll.rs b/crates/ui-egui/tests/it/text_overflow_scroll.rs index 8756666db..076478e52 100644 --- a/crates/ui-egui/tests/it/text_overflow_scroll.rs +++ b/crates/ui-egui/tests/it/text_overflow_scroll.rs @@ -390,3 +390,69 @@ fn wrapped_text_clipping_survives_partial_vertical_scroll() { clip={clip:?}, expected={expected_bottom:.2}" ); } + +/// Issue #2544: text in a frozen column keeps its whole cell while the other columns scroll. +#[test] +fn frozen_column_text_keeps_its_cell_when_scrolled() { + let mut h = harness(|s| { + s.execute("cell.set", json!({"cell": "A1", "input": LEFT})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "v"})).unwrap(); + s.execute("view.freezeFirstColumn", json!({})).unwrap(); + }); + for col in [1, 2, 3, 8] { + scroll_to_col(&mut h, col); + let a1 = cell_rect(&h, "A1"); + let clips = text_clips(&h, LEFT); + assert_eq!(clips.len(), 1, "A1 is drawn once: {clips:?}"); + assert!( + (clips[0].left() - a1.left()).abs() <= 0.5 && (clips[0].right() - a1.right()).abs() <= 0.5, + "A1's text spans A1 at column {col}: {clips:?} vs {a1:?}" + ); + } +} + +/// Issue #2544: with two frozen columns, A1's text keeps overflowing across the whole of empty B1. +#[test] +fn frozen_text_overflows_to_the_freeze_line_when_scrolled() { + let mut h = harness(|s| { + s.execute("cell.set", json!({"cell": "A1", "input": LEFT})).unwrap(); + s.execute("view.freezePanes", json!({"cell": "C1"})).unwrap(); + }); + for col in [2, 3, 4, 10] { + scroll_to_col(&mut h, col); + let (a1, b1) = (cell_rect(&h, "A1"), cell_rect(&h, "B1")); + let clips = text_clips(&h, LEFT); + assert_eq!(clips.len(), 1, "A1 is drawn once: {clips:?}"); + assert!( + (clips[0].left() - a1.left()).abs() <= 0.5 && (clips[0].right() - b1.right()).abs() <= 0.5, + "A1's text spans A1:B1 at column {col}: {clips:?} vs {a1:?} {b1:?}" + ); + } +} + +/// Issue #2544: the frozen columns' gridlines reach the freeze line, and none is drawn inside +/// them at a scrolled column's edge. +#[test] +fn frozen_column_gridlines_stay_put_when_scrolled() { + let mut h = harness(|s| { + s.execute("view.freezePanes", json!({"cell": "C1"})).unwrap(); + }); + scroll_to_col(&mut h, 6); + let (a5, b5) = (cell_rect(&h, "A5"), cell_rect(&h, "B5")); + let frozen = |s: &egui::epaint::ClippedShape| s.clip_rect.right() <= b5.right() + 0.5 && s.clip_rect.left() <= a5.left(); + let segments: Vec<[egui::Pos2; 2]> = h + .output() + .shapes + .iter() + .filter(|s| frozen(s)) + .filter_map(|s| match &s.shape { + Shape::LineSegment { points, .. } => Some(*points), + _ => None, + }) + .collect(); + let row5 = segments.iter().find(|p| (p[0].y - (a5.top() - 0.5).round()).abs() <= 1.0 && p[0].y == p[1].y); + assert!(row5.is_some_and(|p| p[1].x >= b5.right() - 1.0), "row 5's gridline reaches the freeze line: {row5:?} vs {b5:?}"); + let verticals: Vec = segments.iter().filter(|p| p[0].x == p[1].x && p[0].y < b5.bottom()).map(|p| p[0].x).collect(); + let expected = [a5.left(), a5.right(), b5.right()].map(|x| x.round() - 0.5); + assert!(verticals.iter().all(|x| expected.iter().any(|e| (x - e).abs() <= 0.5)), "only A and B's edges: {verticals:?} vs {expected:?}"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..213ce49a3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (frozen columns keep their text, gridlines and merged borders whole while the sheet scrolls (#2544); earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Frozen panes: text in frozen columns overflows only into its own pane and keeps its whole width while the sheet scrolls; frozen gridlines and merged-cell borders reach the freeze line, and the freeze handle measures the last frozen column itself (#2544). Page Layout view gridlines stop at each page's last column | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From f0557ec4437398b55d684fbd9228fca1f66736bc Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:25:29 -0600 Subject: [PATCH 30/80] #3089: keep "remove personal information on save" and honour it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Excel's per-workbook privacy setting (Trust Center › Privacy Options › Remove personal information from file properties on save, set by the Document Inspector) is saved as , and while it is on Excel leaves the author and company empty on save. GridCraft dropped the attribute, so a cleaned workbook came back with the setting off, and it wrote the author and company back. Workbook.remove_personal_info now keeps the setting (read and written as filterPrivacy), and while it is on the writer leaves dc:creator and Company out. Other properties are written as before. Note authors and lastModifiedBy are the rest of the issue (the latter overlaps #881). Co-Authored-By: Claude Opus 5.5 --- crates/model/src/workbook.rs | 6 ++++++ crates/xlsx/src/read.rs | 1 + crates/xlsx/src/tests/roundtrip.rs | 27 +++++++++++++++++++++++++++ crates/xlsx/src/write.rs | 12 +++++++++--- docs/file-format-parity.md | 3 ++- 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 2d16b46b5..dbd011427 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -200,6 +200,11 @@ pub struct Workbook { pub calc: CalcSettings, #[serde(default, skip_serializing_if = "is_all")] pub show_objects: ShowObjects, + /// Trust Center › Privacy Options › "Remove personal information from file properties on save" + /// (``): kept with the file, and while it is on the author and + /// company are left out of what GridCraft saves, as Excel leaves them empty. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub remove_personal_info: bool, /// Read and set with [`Workbook::active_sheet`] and [`Workbook::set_active_sheet`]. pub active_sheet: ActiveSheet, /// The workbook window's display settings (``), kept across an open + save. @@ -338,6 +343,7 @@ impl Workbook { date_system: DateSystem::D1900, calc: CalcSettings::default(), show_objects: ShowObjects::All, + remove_personal_info: false, active_sheet: ActiveSheet::default(), view: WorkbookView::default(), props: DocProps::default(), diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 3cc2a6655..6c43feb31 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -200,6 +200,7 @@ fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) { if p.flag("date1904", false) { cx.date_system = DateSystem::D1904; } + wb.remove_personal_info = p.flag("filterPrivacy", false); wb.show_objects = match p.attr("showObjects") { Some("none") => ShowObjects::None, Some("placeholders") => ShowObjects::Placeholders, diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 2bdba21b8..72ce434be 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -1696,3 +1696,30 @@ fn generated_rule_formulas_use_the_top_left_cell_of_every_area() { z.by_name("xl/worksheets/sheet1.xml").unwrap().read_to_string(&mut xml).unwrap(); assert!(xml.contains("NOT(ISERROR(SEARCH(\"x\",C2)))"), "{xml}"); } + +#[test] +fn remove_personal_information_is_kept_and_honoured() { + // #3089: `` was dropped, so a workbook cleaned of personal + // information came back with it switched off, and GridCraft wrote the author and company back. + let bytes = super::minimal("", &[], "", r#""#); + let (mut wb, _) = read_xlsx(&bytes).unwrap(); + assert!(wb.remove_personal_info); + wb.props.author = "Jane Example".into(); + wb.props.company = "Example Co".into(); + wb.props.title = "Budget".into(); + let saved = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let book = pkg.read_xml("xl/workbook.xml").unwrap().unwrap(); + assert_eq!(book.child("workbookPr").and_then(|p| p.attr("filterPrivacy")), Some("1")); + let core = String::from_utf8(pkg.read("docProps/core.xml").unwrap().unwrap()).unwrap(); + let app = String::from_utf8(pkg.read("docProps/app.xml").unwrap().unwrap()).unwrap(); + assert!(!core.contains("Jane Example") && !app.contains("Example Co"), "{core}\n{app}"); + assert!(core.contains("Budget"), "other properties stay: {core}"); + // Off (the default), nothing changes. + wb.remove_personal_info = false; + let saved = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let book = pkg.read_xml("xl/workbook.xml").unwrap().unwrap(); + assert_eq!(book.child("workbookPr").and_then(|p| p.attr("filterPrivacy")), None); + assert!(String::from_utf8(pkg.read("docProps/core.xml").unwrap().unwrap()).unwrap().contains("Jane Example")); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 2c1e8f60f..c53776cb6 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -293,6 +293,9 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) if wb.date_system == gridcraft_core::DateSystem::D1904 { w.push_str(" date1904=\"1\""); } + if wb.remove_personal_info { + w.push_str(" filterPrivacy=\"1\""); + } w.push_str("/>"); if wb.protected_structure { let pw = wb.structure_password.as_ref().map(|h| crate::sheet_write::password_attrs(h, "workbook")).unwrap_or_default(); @@ -399,13 +402,16 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) // Document properties. let p = &wb.props; + // "Remove personal information on save": the author and company are not saved (#3089). + let private = |v: &String| if wb.remove_personal_info { String::new() } else { v.clone() }; + let (author, company) = (private(&p.author), private(&p.company)); let mut core = format!( "{XML_DECL}" ); for (tag, v) in [ ("dc:title", &p.title), ("dc:subject", &p.subject), - ("dc:creator", &p.author), + ("dc:creator", &author), ("cp:keywords", &p.keywords), ("dc:description", &p.description), ] { @@ -417,8 +423,8 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) out.part("docProps/core.xml", Some("application/vnd.openxmlformats-package.core-properties+xml"), core.into_bytes()); let mut app = String::from(XML_DECL); app.push_str("GridCraft"); - if !p.company.is_empty() { - let _ = write!(app, "{}", esc(&p.company)); + if !company.is_empty() { + let _ = write!(app, "{}", esc(&company)); } app.push_str(""); out.part("docProps/app.xml", Some("application/vnd.openxmlformats-officedocument.extended-properties+xml"), app.into_bytes()); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c4bc72bae..51c0ad309 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-03 · **Change:** minor (`workbookPr filterPrivacy` kept, and honoured on save; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -155,6 +155,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | XLSX `` (Remove personal information from file properties on save) is read and written back, and while it is on GridCraft saves no author (`dc:creator`) and no company, as Excel leaves them empty (#3089, file part) | | 2026-11-02 | minor | Python cells: `.xlsx` reads and writes `_xlfn._xlws.PY(i,0 or 1)` with the code in the Python part (and reads the string-literal form) with cached values and spills, other formats adopt PY formulas, GridCraft JSON keeps the whole cell, and opening limits code size, result size and spill ranges | | 2026-11-02 | trivial | An equation without Office Math takes its stored source only while its text, paragraphs included, is what the writer wrote for it; a paragraph split that changes no character reads back as a text box with the split text | | 2026-11-02 | trivial | Each equation box carries the marker of its own body in its description, the Office Math box and the plain-text fallback alike, and the reader takes the stored source only while the body it reads matches the marker: the text of an equation without Office Math that another application edited reads back as a text box with the edited text instead of the stale source | From 48c52e14af39c93b40f552e2ae41d0e25ef6e9f8 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:36:40 -0600 Subject: [PATCH 31/80] #3166: a web page opens after a leading comment and keeps its non-breaking spaces Addresses CodeRabbit review on #3223: - "Skip leading HTML comments before the prefix check": a page starting with an exporter's was read as CSV text. Complete leading comments are now skipped before looking for the page's start. - "Preserve nonbreaking spaces in HTML text imports": split_whitespace turned New  York into "New York". The decoded cell text is used as is (ordinary whitespace is already collapsed), so the non-breaking spaces stay, as Excel keeps them. Test: a_web_page_table_opens_after_a_leading_comment_and_keeps_nonbreaking_spaces. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/io.rs | 13 +++++++++++-- crates/engine/src/io/tests.rs | 12 ++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index f62a55e40..913fcdd5a 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -357,7 +357,15 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String { fn read_html_table(name: &str, kind: Option, bytes: &[u8], csv_as_text: bool) -> Option)>> { // Decoded as a CSV is (BOM, UTF-8, else Windows-1252/1256), so a legacy page keeps its letters. let text = gridcraft_xlsx::decode_text(bytes); - let head: String = text.trim_start_matches('\u{feff}').trim_start().chars().take(16).collect::().to_ascii_lowercase(); + // Comments an exporter writes before the page (``) don't make it something else. + let mut rest = text.trim_start_matches('\u{feff}').trim_start(); + while let Some(after) = rest.strip_prefix("").and_then(|end| after.get(end + 3..)) { + Some(more) => rest = more.trim_start(), + None => break, + } + } + let head: String = rest.chars().take(16).collect::().to_ascii_lowercase(); if ![", bytes: &[u8], csv_as_text // cell keeps the text it shows. let field = match cell.num.filter(|_| !csv_as_text) { Some(n) => gridcraft_core::number_to_text(n), - None => cell.text.split_whitespace().collect::>().join(" "), + // Whitespace is already collapsed; non-breaking spaces stay, as in Excel. + None => cell.text.clone(), }; if field.contains(['\t', '\n', '\r', '"']) { format!("\"{}\"", field.replace('"', "\"\"")) } else { field } }) diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index fab60261f..ad3fae99c 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -656,3 +656,15 @@ fn a_web_page_table_keeps_its_characters_text_and_header_formats() { let (wb, _) = open_bytes_with("menu.xls", &page, None, true).unwrap(); assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::text("4,50")); } + +#[test] +fn a_web_page_table_opens_after_a_leading_comment_and_keeps_nonbreaking_spaces() { + // An exporter's comment before still makes a web page; ` ` stays a non-breaking + // space, as Excel keeps it, while ordinary whitespace collapses. + let page = "\n
New  Yorka \n b
"; + let (wb, warnings) = open_bytes("cities.xls", page.as_bytes()).unwrap(); + assert!(warnings.first().is_some_and(|w| w.contains("is a web page (HTML)")), "{warnings:?}"); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::text("New\u{a0}\u{a0}York")); + assert_eq!(sh.value(CellRef::parse("B1").unwrap()), gridcraft_core::Value::text("a b")); +} From 846cc09d67d9fb3c4dd2cbdf3653d3b111d59da2 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:34:55 -0600 Subject: [PATCH 32/80] #3101: show or hide the sheet tabs and scroll bars of a workbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Excel can hide the sheet tabs, the horizontal scroll bar and the vertical scroll bar of a workbook window (File › Options › Advanced › Display options for this workbook), saved with the workbook. GridCraft already read and wrote these settings (#3057) but always showed all three, and had no command or option for them. New commands view.showSheetTabs, view.showHorizontalScrollBar and view.showVerticalScrollBar {on?} (toggling without `on`, undoable, saved with the workbook), and a "Display options for this workbook" section in File › Options. The tab bar follows them: without the tabs the scroll bar takes the bar, without the scroll bar the tabs do, and with neither the bar isn't shown; without the vertical scroll bar the grid takes its width. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/view.rs | 36 +++ crates/engine/src/tests.rs | 19 ++ crates/ui-egui/src/backstage/options.rs | 18 +- crates/ui-egui/src/grid/show.rs | 12 +- crates/ui-egui/src/tabs.rs | 325 +++++++++++++----------- crates/ui-egui/tests/it/tab_split.rs | 46 ++++ docs/ui-parity.md | 3 +- 7 files changed, 303 insertions(+), 156 deletions(-) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index c508890c3..3631d0e56 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -40,6 +40,27 @@ pub fn specs() -> Vec { cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")), cmd!("view.showOutlineSymbols", "Show Outline Symbols", [], Some("Ctrl+8"), "{on?}", has_doc, |s, p| sheet_flag(s, p, "outlineSymbols")), cmd!("view.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects), + cmd!( + "view.showSheetTabs", + "Show Sheet Tabs", + [], + None, + "{on?} (File › Options › Advanced, this workbook; toggles without `on`)", + has_doc, + |s, p| { workbook_view_flag(s, p, "sheetTabs") } + ), + cmd!( + "view.showHorizontalScrollBar", + "Show Horizontal Scroll Bar", + [], + None, + "{on?} (this workbook; toggles without `on`)", + has_doc, + |s, p| { workbook_view_flag(s, p, "horizontal") } + ), + cmd!("view.showVerticalScrollBar", "Show Vertical Scroll Bar", [], None, "{on?} (this workbook; toggles without `on`)", has_doc, |s, p| { + workbook_view_flag(s, p, "vertical") + }), cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")), cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom), cmd!("view.zoom100", "100%", ["View", "Zoom"], None, "{}", has_doc, |s, _| zoom(s, &json!({"percent": 100}))), @@ -189,6 +210,21 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result { }) } +/// File › Options › Advanced › Display options for this workbook: the sheet tabs and the two +/// scroll bars, saved with the workbook (``). +fn workbook_view_flag(s: &mut Session, p: &Json, which: &str) -> Result { + edit(s, |cx| { + let view = &mut cx.wb.view; + let slot = match which { + "sheetTabs" => &mut view.show_sheet_tabs, + "horizontal" => &mut view.show_horizontal_scroll, + _ => &mut view.show_vertical_scroll, + }; + *slot = bool_param(p, "on").unwrap_or(!*slot); + Ok(json!({"on": *slot})) + }) +} + /// Ctrl+6: hides every object, or shows them again. Placeholders (from a file) count as shown. fn show_objects(s: &mut Session, p: &Json) -> Result { use gridcraft_model::ShowObjects; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5f92718ba..4d0cabd17 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5457,3 +5457,22 @@ fn a_view_kept_through_redo_doesnt_make_the_saved_workbook_dirty() { assert_eq!(d.wb.active().unwrap().view, gridcraft_model::SheetViewMode::PageBreakPreview); assert!(!d.is_dirty(), "back at the saved edits, the view alone isn't a change"); } + +#[test] +fn workbook_display_options_are_commands() { + // #3101: Show sheet tabs / horizontal / vertical scroll bar, toggling without `on`, saved with + // the workbook and undoable. + let mut s = Session::new(); + s.new_workbook(); + let view = |s: &Session| s.doc().unwrap().wb.view; + assert_eq!(s.execute("view.showSheetTabs", json!({})).unwrap()["on"], json!(false)); + assert_eq!(s.execute("view.showHorizontalScrollBar", json!({"on": false})).unwrap()["on"], json!(false)); + assert_eq!(s.execute("view.showVerticalScrollBar", json!({"on": false})).unwrap()["on"], json!(false)); + let v = view(&s); + assert!(!v.show_sheet_tabs && !v.show_horizontal_scroll && !v.show_vertical_scroll); + let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&s.doc().unwrap().wb).unwrap()).unwrap(); + assert_eq!(back.view, v, "saved with the workbook"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(view(&s).show_vertical_scroll); + assert_eq!(s.execute("view.showSheetTabs", json!({})).unwrap()["on"], json!(true), "toggles back"); +} diff --git a/crates/ui-egui/src/backstage/options.rs b/crates/ui-egui/src/backstage/options.rs index 6b9f70eb7..afe976733 100644 --- a/crates/ui-egui/src/backstage/options.rs +++ b/crates/ui-egui/src/backstage/options.rs @@ -2,7 +2,8 @@ use super::{Key, Language, Modifiers, SheetApp, Ui, dim, i18n, json, section, ve /// Excel's File › Options page: General (display theme, interface language and Show formula bar), /// Editing (AutoComplete) and Save (the autosave checkbox toggling `app.ui.autosave`), plus Formulas -/// (calculation mode and iterative calculation) when a document is active. Theme, language, formula +/// (calculation mode and iterative calculation) and the display options for this workbook (sheet +/// tabs and scroll bars) when a document is active. Theme, language, formula /// bar, AutoComplete and calculation changes run their commands through `run_or_alert`; calculation /// settings are saved with the workbook. pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) { @@ -26,7 +27,20 @@ pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) { if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() { app.toggle_autosave(); } - let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return }; + let Some((calc, view)) = app.session.active().map(|d| (d.wb.calc.clone(), d.wb.view)) else { return }; + ui.add_space(18.0); + // Excel's Advanced › Display options for this workbook: saved with the workbook (#3101). + section(ui, lang, "Display options for this workbook"); + for (on, label, id) in [ + (view.show_horizontal_scroll, "Show horizontal scroll bar", "view.showHorizontalScrollBar"), + (view.show_vertical_scroll, "Show vertical scroll bar", "view.showVerticalScrollBar"), + (view.show_sheet_tabs, "Show sheet tabs", "view.showSheetTabs"), + ] { + let mut on = on; + if ui.checkbox(&mut on, lang.tr(label)).changed() { + app.run_or_alert(id, json!({"on": on})); + } + } ui.add_space(18.0); section(ui, lang, "Formulas"); ui.label(lang.tr("Workbook calculation")); diff --git a/crates/ui-egui/src/grid/show.rs b/crates/ui-egui/src/grid/show.rs index f1416c2ec..287bd0bcd 100644 --- a/crates/ui-egui/src/grid/show.rs +++ b/crates/ui-egui/src/grid/show.rs @@ -20,7 +20,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let available = ui.available_rect_before_wrap(); // A right-to-left sheet has its vertical scrollbar on the left. let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); - let bar = 14.0_f32.min(available.width()); + let bar = vertical_bar_width(app, available); let rect = if rtl { Rect::from_min_max(pos2(available.left() + bar, available.top()), available.max) } else { @@ -213,6 +213,12 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { update_clipboard_repaint(app, ui, now); } +/// The vertical scroll bar's width: none when the workbook hides it (File › Options › +/// Display options for this workbook), and the grid takes its room. +fn vertical_bar_width(app: &SheetApp, available: Rect) -> f32 { + if app.session.active().is_none_or(|d| d.wb.view.show_vertical_scroll) { 14.0_f32.min(available.width()) } else { 0.0 } +} + fn vertical_scroll_track(available: Rect, grid: Rect, rtl: bool) -> (f32, f32) { if rtl { (available.left(), grid.left()) } else { (grid.right(), available.right()) } } @@ -420,6 +426,10 @@ fn paint_panes(input: PanesInput<'_>, cf: &mut CfCache) -> Vec { /// The worksheet is painted virtually, so it needs its own scrollbar rather than a /// ScrollArea containing all million rows. Keep the scroll extent stable during a drag. fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, track_x: (f32, f32), limit: f32, t: &Tokens) { + // A workbook that hides its vertical scroll bar leaves it no width. + if track_x.1 <= track_x.0 { + return; + } let track = Rect::from_min_max(pos2(track_x.0, geo.cells.top() + geo.frozen_h), pos2(track_x.1, geo.cells.bottom())); if track.height() <= 0.0 || track.width() <= 0.0 { return; diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 6702f7cb8..6147b2e14 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -100,29 +100,37 @@ fn tab_split_handle(app: &mut SheetApp, ui: &mut Ui, split: Rect, bar: (f32, f32 /// Draws the worksheet tab strip: navigation, scroll/reveal, rename/drag/move with optional copy, context menu, new-sheet button, split handle and grid scrollbar. pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + // File › Options › Advanced › Display options for this workbook (#3101): with neither the + // sheet tabs nor the horizontal scroll bar, the bar isn't there at all. + let view = app.session.active().map(|d| d.wb.view).unwrap_or_default(); + if !view.show_sheet_tabs && !view.show_horizontal_scroll { + return; + } egui::Panel::bottom("sheet_tabs").exact_size(32.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { let rect = ui.max_rect(); ui.painter().line_segment([rect.left_top(), rect.right_top()], Stroke::new(1.0, t.ribbon_border)); ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.add_space(8.0); - if crate::widgets::icon_button(ui, Icon::Left, t.text_dim, app.ui.language.tr("Previous sheet"), vec2(24.0, 24.0)).clicked() { - app.run_or_alert("sheet.previous", json!({})); + if view.show_sheet_tabs { + if crate::widgets::icon_button(ui, Icon::Left, t.text_dim, app.ui.language.tr("Previous sheet"), vec2(24.0, 24.0)).clicked() { + app.run_or_alert("sheet.previous", json!({})); + } + if crate::widgets::icon_button(ui, Icon::Right, t.text_dim, app.ui.language.tr("Next sheet"), vec2(24.0, 24.0)).clicked() { + app.run_or_alert("sheet.next", json!({})); + } + let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, app.ui.language.tr("All sheets"), vec2(24.0, 24.0)); + all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, app.ui.language.tr("All sheets"))); + let items = app.session.active().map(|d| navigator_items(&d.wb.sheets)).unwrap_or_default(); + let active = app.session.active().map(|d| d.wb.active_sheet().to_string()).unwrap_or_default(); + let rows: Vec<(&str, String)> = items.iter().map(|(i, name)| (i.as_str(), name.clone())).collect(); + if let crate::ribbon::ComboResult::Picked(i) = crate::ribbon::editable_combo(ui, &all, "sheet_navigator", &active, &rows, false) + && let Ok(i) = i.parse::() + { + activate_sheet(app, i); + } + ui.add_space(8.0); } - if crate::widgets::icon_button(ui, Icon::Right, t.text_dim, app.ui.language.tr("Next sheet"), vec2(24.0, 24.0)).clicked() { - app.run_or_alert("sheet.next", json!({})); - } - let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, app.ui.language.tr("All sheets"), vec2(24.0, 24.0)); - all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, app.ui.language.tr("All sheets"))); - let items = app.session.active().map(|d| navigator_items(&d.wb.sheets)).unwrap_or_default(); - let active = app.session.active().map(|d| d.wb.active_sheet().to_string()).unwrap_or_default(); - let rows: Vec<(&str, String)> = items.iter().map(|(i, name)| (i.as_str(), name.clone())).collect(); - if let crate::ribbon::ComboResult::Picked(i) = crate::ribbon::editable_combo(ui, &all, "sheet_navigator", &active, &rows, false) - && let Ok(i) = i.parse::() - { - activate_sheet(app, i); - } - ui.add_space(8.0); let Some(d) = app.session.active() else { return }; let (bar_left, bar_width) = (ui.cursor().left(), ui.available_width()); let uid = d.uid; @@ -143,6 +151,8 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { // After the handle at the scrollbar's left edge is dragged, the tabs and New sheet // take the share it was dragged to. let tabs_width = match share { + // Without the scroll bar the tabs have the whole bar. + _ if !view.show_horizontal_scroll => (bar_width - NEW_SHEET_WIDTH).max(0.0), Some(share) => (bar_width * share - NEW_SHEET_WIDTH).max(0.0), None => (bar_width * 0.6).max(100.0), }; @@ -155,154 +165,159 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { data.insert_temp(reveal_id, reveal_key); changed }); - ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { - // Translate vertical wheel movement into horizontal - // worksheet-tab scrolling. - let wheel_scroll = if ui.rect_contains_pointer(ui.max_rect()) { - ui.input(|i| { - let d = i.smooth_scroll_delta; - if d.x.abs() > d.y.abs() { d.x } else { d.y } - }) - } else { - 0.0 - }; + if view.show_sheet_tabs { + ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + // Translate vertical wheel movement into horizontal + // worksheet-tab scrolling. + let wheel_scroll = if ui.rect_contains_pointer(ui.max_rect()) { + ui.input(|i| { + let d = i.smooth_scroll_delta; + if d.x.abs() > d.y.abs() { d.x } else { d.y } + }) + } else { + 0.0 + }; - egui::ScrollArea::horizontal() - .id_salt("worksheet_tabs_horizontal") - .auto_shrink([true, false]) - .min_scrolled_width(0.0) - .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) - .show(ui, |ui| { - if wheel_scroll.abs() > 0.0 { - ui.scroll_with_delta(vec2(wheel_scroll, 0.0)); - } - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - for (i, name, color, active) in sheets { - let renaming = app.grid.renaming_tab == Some(i); - let font = if active { theme::ui_bold(13.0) } else { theme::ui_font(13.0) }; - let tw = ui.painter().layout_no_wrap(name.clone(), font.clone(), t.text).size().x; - let w = (tw + 32.0).max(72.0); - let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag()); - if active && reveal_active { - resp.scroll_to_me(None); - } - let r = r.shrink2(vec2(0.0, 1.0)); - if active { - ui.painter().rect_filled(Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), 4.0, t.tab_active); - ui.painter().rect_stroke( - Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), - 4.0, - Stroke::new(1.0, t.ribbon_border), - StrokeKind::Inside, - ); - } else if resp.hovered() { - ui.painter().rect_filled(r, 4.0, t.hover); - } - if let Some(c) = color { - let band = Rect::from_min_size(pos2(r.left() + 6.0, r.bottom() - 4.0), vec2(r.width() - 12.0, 3.0)); - ui.painter().rect_filled(band, 1.0, theme::color32(c)); - } else if active { - let band = Rect::from_center_size(pos2(r.center().x, r.bottom() - 3.0), vec2(tw.min(48.0), 2.5)); - ui.painter().rect_filled(band, 1.0, t.accent); - } - if renaming { - let mut text = app.grid.rename_text.clone(); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(6.0, 5.0)))); - let ed = child - .add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0)) - .accessible_name(app.ui.language.tr("Sheet Name")); - app.grid.rename_text = text.clone(); - if rename_needs_focus(ed.has_focus(), ed.lost_focus()) { - ed.request_focus(); + egui::ScrollArea::horizontal() + .id_salt("worksheet_tabs_horizontal") + .auto_shrink([true, false]) + .min_scrolled_width(0.0) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + if wheel_scroll.abs() > 0.0 { + ui.scroll_with_delta(vec2(wheel_scroll, 0.0)); + } + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + for (i, name, color, active) in sheets { + let renaming = app.grid.renaming_tab == Some(i); + let font = if active { theme::ui_bold(13.0) } else { theme::ui_font(13.0) }; + let tw = ui.painter().layout_no_wrap(name.clone(), font.clone(), t.text).size().x; + let w = (tw + 32.0).max(72.0); + let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag()); + if active && reveal_active { + resp.scroll_to_me(None); } - if ed.lost_focus() { - if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && text != name { - app.run_or_alert("sheet.rename", json!({"sheet": i, "name": text})); + let r = r.shrink2(vec2(0.0, 1.0)); + if active { + ui.painter().rect_filled(Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), 4.0, t.tab_active); + ui.painter().rect_stroke( + Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), + 4.0, + Stroke::new(1.0, t.ribbon_border), + StrokeKind::Inside, + ); + } else if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover); + } + if let Some(c) = color { + let band = Rect::from_min_size(pos2(r.left() + 6.0, r.bottom() - 4.0), vec2(r.width() - 12.0, 3.0)); + ui.painter().rect_filled(band, 1.0, theme::color32(c)); + } else if active { + let band = Rect::from_center_size(pos2(r.center().x, r.bottom() - 3.0), vec2(tw.min(48.0), 2.5)); + ui.painter().rect_filled(band, 1.0, t.accent); + } + if renaming { + let mut text = app.grid.rename_text.clone(); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(6.0, 5.0)))); + let ed = child + .add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0)) + .accessible_name(app.ui.language.tr("Sheet Name")); + app.grid.rename_text = text.clone(); + if rename_needs_focus(ed.has_focus(), ed.lost_focus()) { + ed.request_focus(); } - app.grid.renaming_tab = None; + if ed.lost_focus() { + if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && text != name { + app.run_or_alert("sheet.rename", json!({"sheet": i, "name": text})); + } + app.grid.renaming_tab = None; + } + } else { + ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text }); } - } else { - ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text }); - } - // A right-click activates the tab too, as in Excel, so the menu's sheet commands (Protect - // Sheet, Select All Sheets) act on the sheet that was clicked rather than the one that was active. - if resp.clicked() || (resp.secondary_clicked() && !active) { - activate_sheet(app, i); - } - if resp.double_clicked() { - app.grid.renaming_tab = Some(i); - app.grid.rename_text = name.clone(); - } - // Drag to reorder; with the copy modifier held, drop a copy instead. - let copy = drop_copies_sheet(ui.input(|inp| inp.modifiers), ui.ctx().os() == egui::os::OperatingSystem::Mac); - if resp.dragged() && copy { - ui.ctx().set_cursor_icon(egui::CursorIcon::Copy); - } - if resp.drag_stopped() - && let Some(p) = ui.input(|inp| inp.pointer.interact_pos()) - { - let dx = p.x - r.center().x; - let steps = (dx / w).round() as i64; - if let Some(params) = tab_drop(i, steps, copy) { - app.run_or_alert("sheet.move", params); + // A right-click activates the tab too, as in Excel, so the menu's sheet commands (Protect + // Sheet, Select All Sheets) act on the sheet that was clicked rather than the one that was active. + if resp.clicked() || (resp.secondary_clicked() && !active) { + activate_sheet(app, i); } - } - resp.context_menu(|ui| { - ui.set_min_width(180.0); - if ui.button(app.ui.language.tr("Insert Sheet")).clicked() { - app.run_or_alert("home.insertSheet", json!({"before": i})); - } - if ui.button(app.ui.language.tr("Delete")).clicked() { - app.run_or_alert("home.deleteSheet", json!({"sheet": i})); - } - if ui.button(app.ui.language.tr("Rename")).clicked() { + if resp.double_clicked() { app.grid.renaming_tab = Some(i); app.grid.rename_text = name.clone(); } - if ui.button(app.ui.language.tr("Move or Copy…")).clicked() { - app.open_dialog("moveSheet", json!({"sheet": i})); + // Drag to reorder; with the copy modifier held, drop a copy instead. + let copy = drop_copies_sheet(ui.input(|inp| inp.modifiers), ui.ctx().os() == egui::os::OperatingSystem::Mac); + if resp.dragged() && copy { + ui.ctx().set_cursor_icon(egui::CursorIcon::Copy); } - if ui.button(app.ui.language.tr("Duplicate")).clicked() { - app.run_or_alert("sheet.move", json!({"sheet": i, "to": i + 1, "copy": true})); + if resp.drag_stopped() + && let Some(p) = ui.input(|inp| inp.pointer.interact_pos()) + { + let dx = p.x - r.center().x; + let steps = (dx / w).round() as i64; + if let Some(params) = tab_drop(i, steps, copy) { + app.run_or_alert("sheet.move", params); + } } - ui.menu_button(app.ui.language.tr("Tab Color"), |ui| { - let colors = app.session.active().map(|d| d.wb.theme.colors).unwrap_or_default(); - if let Some(c) = crate::widgets::color_palette_fixed(ui, &colors, app.ui.language.tr("No Color")) { - app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c})); + resp.context_menu(|ui| { + ui.set_min_width(180.0); + if ui.button(app.ui.language.tr("Insert Sheet")).clicked() { + app.run_or_alert("home.insertSheet", json!({"before": i})); + } + if ui.button(app.ui.language.tr("Delete")).clicked() { + app.run_or_alert("home.deleteSheet", json!({"sheet": i})); + } + if ui.button(app.ui.language.tr("Rename")).clicked() { + app.grid.renaming_tab = Some(i); + app.grid.rename_text = name.clone(); + } + if ui.button(app.ui.language.tr("Move or Copy…")).clicked() { + app.open_dialog("moveSheet", json!({"sheet": i})); + } + if ui.button(app.ui.language.tr("Duplicate")).clicked() { + app.run_or_alert("sheet.move", json!({"sheet": i, "to": i + 1, "copy": true})); + } + ui.menu_button(app.ui.language.tr("Tab Color"), |ui| { + let colors = app.session.active().map(|d| d.wb.theme.colors).unwrap_or_default(); + if let Some(c) = crate::widgets::color_palette_fixed(ui, &colors, app.ui.language.tr("No Color")) { + app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c})); + ui.close(); + } + }); + ui.separator(); + if ui.button(app.ui.language.tr("Hide")).clicked() { + app.run_or_alert("sheet.hide", json!({"sheet": i})); + } + if ui.button(app.ui.language.tr("Unhide…")).clicked() { + app.run_or_alert("sheet.unhide", json!({})); + } + ui.separator(); + let protected = app.active_sheet_protected(); + if ui + .button(if protected { + app.ui.language.tr("Unprotect Sheet…") + } else { + app.ui.language.tr("Protect Sheet…") + }) + .clicked() + { + app.open_dialog("protectSheet", json!({})); + } + if ui.button(app.ui.language.tr("Select All Sheets")).clicked() { ui.close(); } }); - ui.separator(); - if ui.button(app.ui.language.tr("Hide")).clicked() { - app.run_or_alert("sheet.hide", json!({"sheet": i})); - } - if ui.button(app.ui.language.tr("Unhide…")).clicked() { - app.run_or_alert("sheet.unhide", json!({})); - } - ui.separator(); - let protected = app.active_sheet_protected(); - if ui - .button(if protected { - app.ui.language.tr("Unprotect Sheet…") - } else { - app.ui.language.tr("Protect Sheet…") - }) - .clicked() - { - app.open_dialog("protectSheet", json!({})); - } - if ui.button(app.ui.language.tr("Select All Sheets")).clicked() { - ui.close(); - } - }); - } + } + }); }); - }); - }); - ui.add_space(6.0); - if crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, app.ui.language.tr("New sheet (⇧F11)"), vec2(26.0, 26.0)).clicked() { - app.run_or_alert("home.insertSheet", json!({})); + }); + ui.add_space(6.0); + if crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, app.ui.language.tr("New sheet (⇧F11)"), vec2(26.0, 26.0)).clicked() { + app.run_or_alert("home.insertSheet", json!({})); + } + } + if !view.show_horizontal_scroll { + return; } // The track ends where the grid does, so it stops under the vertical scrollbar. let avail = ui.available_rect_before_wrap(); @@ -312,8 +327,14 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let split_left = share.map_or(avail.left(), |share| (bar_left + bar_width * share).min(right - SPLIT_WIDTH - MIN_TRACK).max(avail.left())); let split = Rect::from_min_max(pos2(split_left, avail.top()), pos2(split_left + SPLIT_WIDTH, avail.bottom())); - tab_split_handle(app, ui, split, (bar_left, bar_width), right, uid, &t); - let track = Rect::from_min_max(pos2(split.right(), avail.center().y - 7.0), pos2(right, avail.center().y + 7.0)); + // Without sheet tabs there is nothing to split: the scroll bar takes the bar. + let track_left = if view.show_sheet_tabs { + tab_split_handle(app, ui, split, (bar_left, bar_width), right, uid, &t); + split.right() + } else { + avail.left() + }; + let track = Rect::from_min_max(pos2(track_left, avail.center().y - 7.0), pos2(right, avail.center().y + 7.0)); crate::grid::horizontal_scrollbar(app, ui, track, &t); }); }); diff --git a/crates/ui-egui/tests/it/tab_split.rs b/crates/ui-egui/tests/it/tab_split.rs index 03ca5e9c5..7189358ac 100644 --- a/crates/ui-egui/tests/it/tab_split.rs +++ b/crates/ui-egui/tests/it/tab_split.rs @@ -151,3 +151,49 @@ fn each_workbook_keeps_its_own_tab_share() { assert!(track(&h).left() < 400.0, "a new workbook starts at the default share: {:?}", track(&h)); assert_eq!(h.state().grid.tab_shares.len(), 1, "only the dragged workbook has a share"); } + +/// The tab bar of a workbook whose window has `command` run with `{on: false}` first. +fn harness_without(command: &str) -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("sheet.rename", json!({"sheet": 0, "name": "Tab00"})).unwrap(); + s.execute(command, json!({"on": false})).unwrap(); + let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(900.0, 120.0)).build_ui_state( + move |ui, app: &mut SheetApp| { + app.grid.cells_rect = Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(ui.max_rect().width(), 80.0))); + gridcraft_ui_egui::tabs::sheet_tabs(app, ui); + }, + crate::common::app(s), + ); + h.run_steps(20); + h +} + +fn shows_tab_name(h: &Harness) -> bool { + h.output().shapes.iter().any(|s| matches!(&s.shape, Shape::Text(t) if t.galley.job.text == "Tab00")) +} + +#[test] +fn display_options_hide_the_sheet_tabs_and_the_horizontal_scroll_bar() { + // #3101: File › Options › Advanced, Display options for this workbook. + let h = harness_without("view.showSheetTabs"); + assert!(!shows_tab_name(&h), "no sheet tabs"); + assert!(h.state().grid.tab_split.is_none(), "nothing to split without tabs"); + let bar = h.state().grid.hscroll.expect("the scroll bar stays"); + assert!(bar.left() < 40.0, "the scroll bar takes the bar from its left edge: {bar:?}"); + + let h = harness_without("view.showHorizontalScrollBar"); + assert!(shows_tab_name(&h), "the tabs stay"); + assert!(h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no scroll bar and no split handle"); + + // Both off: no bar at all. + let mut s = Session::new(); + s.new_workbook(); + s.execute("view.showSheetTabs", json!({"on": false})).unwrap(); + s.execute("view.showHorizontalScrollBar", json!({"on": false})).unwrap(); + let mut h = crate::common::builder() + .with_size(vec2(900.0, 120.0)) + .build_ui_state(|ui, app: &mut SheetApp| gridcraft_ui_egui::tabs::sheet_tabs(app, ui), crate::common::app(s)); + h.run_steps(5); + assert!(!shows_tab_name(&h) && h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no bar at all"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..503a77179 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Display options for this workbook: sheet tabs and scroll bars; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | File › Options › Display options for this workbook: Show horizontal scroll bar, Show vertical scroll bar and Show sheet tabs (`view.showHorizontalScrollBar`, `view.showVerticalScrollBar`, `view.showSheetTabs`), saved with the workbook; with the sheet tabs off the scroll bar takes the bar, with the scroll bar off the tabs do, with both off the bar goes, and without the vertical scroll bar the grid takes its width (#3101) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From c35ab0f4f4e61c442803114e639db8113ab1fdac Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:41:55 -0600 Subject: [PATCH 33/80] #1482: every Paste Special option has its Excel access key Paste Special only knew the letters F, V, T, C and E. Excel gives every option one: A (All), N (Validation), X (All except borders), W (Column widths), R (Formulas and number formats), U (Values and number formats); O, D, S, M, I for the operations; B for Skip blanks; and L for the Paste Link button, which pastes at once. One table now maps each letter to what it does; Enter still pastes with the chosen options. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/paste.rs | 75 ++++++++++++++----- .../ui-egui/tests/it/paste_special_dialog.rs | 47 ++++++++++++ docs/ui-parity.md | 3 +- 3 files changed, 104 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/dialogs/paste.rs b/crates/ui-egui/src/dialogs/paste.rs index 9ac0bc579..253729b2a 100644 --- a/crates/ui-egui/src/dialogs/paste.rs +++ b/crates/ui-egui/src/dialogs/paste.rs @@ -5,6 +5,8 @@ use super::{Dialog, Field, Json, Key, SheetApp, Stroke, json, theme, vec2}; /// come from the dialog's `fields`, so the letter shortcuts and the control channel see the same. pub(super) fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { let lang = app.ui.language; + // L, the Paste Link button's access key, pastes at once as the button does. + let link_now = d.values.remove(PASTE_LINK_NOW).is_some_and(|v| v == json!(true)); let t = theme::Tokens::get(ui.ctx()); type Choice = (&'static str, &'static str, Vec<(&'static str, &'static str)>); let choices: Vec = d @@ -56,7 +58,7 @@ pub(super) fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialo ui.separator(); ui.horizontal(|ui| { // Excel's Paste Link pastes references to the copied cells straight away. - if ui.button(lang.tr("Paste Link")).on_hover_text(lang.tr("Paste formulas that refer to the copied cells")).clicked() { + if ui.button(lang.tr("Paste Link")).on_hover_text(lang.tr("Paste formulas that refer to the copied cells")).clicked() || link_now { d.values.insert("what".into(), json!("all")); d.values.insert("operation".into(), json!("none")); d.values.insert("link".into(), json!(true)); @@ -74,6 +76,45 @@ pub(super) fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialo }); } +/// Set by the Paste Link access key for the next frame of the dialog, which then pastes. +const PASTE_LINK_NOW: &str = "pasteLinkNow"; + +/// What one of Paste Special's access keys does: choose an option of a group, or flip a box. +#[derive(Clone, Copy)] +enum Mnemonic { + Set(&'static str, &'static str), + Toggle(&'static str), + /// The Paste Link button: pastes at once. + PasteLink, +} + +/// Excel's access keys in Paste Special (#1482): the Paste choices, the operations, the two check +/// boxes and the Paste Link button, each the letter Excel underlines. +fn mnemonic_action(key: Key) -> Option<(char, Mnemonic)> { + use Mnemonic::{Set, Toggle}; + Some(match key { + Key::A => ('a', Set("what", "all")), + Key::F => ('f', Set("what", "formulas")), + Key::V => ('v', Set("what", "values")), + Key::T => ('t', Set("what", "formats")), + Key::C => ('c', Set("what", "comments")), + Key::N => ('n', Set("what", "validation")), + Key::X => ('x', Set("what", "allExceptBorders")), + Key::W => ('w', Set("what", "columnWidths")), + Key::R => ('r', Set("what", "formulasAndNumberFormats")), + Key::U => ('u', Set("what", "valuesAndNumberFormats")), + Key::O => ('o', Set("operation", "none")), + Key::D => ('d', Set("operation", "add")), + Key::S => ('s', Set("operation", "subtract")), + Key::M => ('m', Set("operation", "multiply")), + Key::I => ('i', Set("operation", "divide")), + Key::B => ('b', Toggle("skipBlanks")), + Key::E => ('e', Toggle("transpose")), + Key::L => ('l', Mnemonic::PasteLink), + _ => return None, + }) +} + /// Paste Special's legacy mnemonics select options; Enter still performs the paste. pub(super) fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) { if ctx.text_edit_focused() { @@ -88,14 +129,7 @@ pub(super) fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) { }); let mac = ctx.os() == egui::os::OperatingSystem::Mac; let allowed = |m: egui::Modifiers| !m.ctrl && !m.command && !m.mac_cmd && !(m.alt && (mac || right_alt)); - let mnemonic = |key| match key { - Key::T => Some('t'), - Key::V => Some('v'), - Key::F => Some('f'), - Key::C => Some('c'), - Key::E => Some('e'), - _ => None, - }; + let mnemonic = |key| mnemonic_action(key).map(|(letter, _)| letter); let mut text_to_consume = Vec::new(); for event in ctx.input(|i| i.events.clone()) { let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = event else { continue }; @@ -107,17 +141,18 @@ pub(super) fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) { if repeat { continue; } - if key == Key::E { - let transpose = d.values.get("transpose").and_then(Json::as_bool).unwrap_or(false); - d.values.insert("transpose".into(), json!(!transpose)); - } else { - let what = match key { - Key::T => "formats", - Key::V => "values", - Key::F => "formulas", - _ => "comments", - }; - d.values.insert("what".into(), json!(what)); + match mnemonic_action(key).map(|(_, action)| action) { + Some(Mnemonic::Set(field, value)) => { + d.values.insert(field.into(), json!(value)); + } + Some(Mnemonic::Toggle(field)) => { + let on = d.values.get(field).and_then(Json::as_bool).unwrap_or(false); + d.values.insert(field.into(), json!(!on)); + } + Some(Mnemonic::PasteLink) => { + d.values.insert(PASTE_LINK_NOW.into(), json!(true)); + } + None => {} } } // Browsers can deliver Text before or after Key. Act only on Key, then remove diff --git a/crates/ui-egui/tests/it/paste_special_dialog.rs b/crates/ui-egui/tests/it/paste_special_dialog.rs index 161d35292..822503747 100644 --- a/crates/ui-egui/tests/it/paste_special_dialog.rs +++ b/crates/ui-egui/tests/it/paste_special_dialog.rs @@ -54,3 +54,50 @@ fn paste_link_pastes_references_at_once() { assert_eq!(c1["value"], json!(2.0)); assert!(c1["formula"].as_str().is_some_and(|f| f.contains("A1")), "{c1}"); } + +fn press(h: &mut Harness, key: egui::Key) { + for pressed in [true, false] { + h.input_mut().events.push(egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(2); +} + +fn value(h: &Harness, field: &str) -> serde_json::Value { + h.state().dialog.as_ref().and_then(|d| d.values.get(field).cloned()).unwrap_or_default() +} + +#[test] +fn every_option_has_its_excel_access_key() { + // #1482: only F, V, T, C and E worked. + let mut h = harness(); + for (key, field, want) in [ + (egui::Key::N, "what", json!("validation")), + (egui::Key::X, "what", json!("allExceptBorders")), + (egui::Key::W, "what", json!("columnWidths")), + (egui::Key::R, "what", json!("formulasAndNumberFormats")), + (egui::Key::U, "what", json!("valuesAndNumberFormats")), + (egui::Key::A, "what", json!("all")), + (egui::Key::D, "operation", json!("add")), + (egui::Key::S, "operation", json!("subtract")), + (egui::Key::I, "operation", json!("divide")), + (egui::Key::O, "operation", json!("none")), + (egui::Key::M, "operation", json!("multiply")), + (egui::Key::B, "skipBlanks", json!(true)), + ] { + press(&mut h, key); + assert_eq!(value(&h, field), want, "{key:?}"); + } + press(&mut h, egui::Key::B); + assert_eq!(value(&h, "skipBlanks"), json!(false), "B flips Skip blanks back"); + // V then Enter: values multiplied, as with the mouse. + press(&mut h, egui::Key::V); + press(&mut h, egui::Key::Enter); + assert!(h.state().dialog.is_none()); + assert_eq!(cell(&mut h, "C1")["value"], json!(20.0)); + // L is the Paste Link button: it pastes at once. + let mut h = harness(); + press(&mut h, egui::Key::L); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert!(cell(&mut h, "C1")["formula"].as_str().is_some_and(|f| f.contains("A1"))); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..09f29b9a9 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (every Paste Special option has its access key; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Paste Special: every option has Excel's access key (A, F, V, T, C, N, X, W, R, U; O, D, S, M, I; B, E; L pastes the link at once), not only F, V, T, C and E (#1482) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From 56f0679fed05aa521d2e3665870a5d3456b9a098 Mon Sep 17 00:00:00 2001 From: adifiedio Date: Tue, 3 Nov 2026 13:13:29 +0530 Subject: [PATCH 34/80] Frozen panes: test the scrolled freeze-handle drag and the frozen text's painted extent (#2544) Review follow-up: a column-handle drop on the last frozen column while scrolled, which fails if freeze_boundary measures with x(c + 1), and a check that frozen A1's text lies inside its clip, not only that the clip is A1. --- crates/ui-egui/tests/it/freeze_handles.rs | 17 +++++++++++++++++ crates/ui-egui/tests/it/text_overflow_scroll.rs | 14 ++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/crates/ui-egui/tests/it/freeze_handles.rs b/crates/ui-egui/tests/it/freeze_handles.rs index e3ccf8c65..141d0b9ef 100644 --- a/crates/ui-egui/tests/it/freeze_handles.rs +++ b/crates/ui-egui/tests/it/freeze_handles.rs @@ -82,3 +82,20 @@ fn last_frozen_column_ends_at_the_freeze_line_when_scrolled() { assert!(g.x(&sh, 2) < split.x && g.y(&sh, 2) < split.y, "C and row 3 are scrolled under the frozen panes"); assert!(g.same_col_pane(0, 1) && g.same_col_pane(2, 9) && !g.same_col_pane(1, 2)); } + +/// Dropping the column handle on the left part of the last frozen column while scrolled freezes +/// up to its left edge: the column's right edge is the freeze line, not the next column's +/// scrolled position (#2544). +#[test] +fn dragging_the_column_handle_when_scrolled_measures_the_last_frozen_column() { + let mut h = harness(); + h.state_mut().run("view.freezePanes", serde_json::json!({"cell": "C1"})).unwrap(); + h.state_mut().view_mut().unwrap().scroll = egui::vec2(300.0, 0.0); + h.run_steps(2); + let g = geo(&h); + let sh = h.state().session.active().unwrap().wb.active().unwrap().clone(); + let (cols, _) = freeze_handles(&g); + assert!(g.x(&sh, 2) < g.x(&sh, 1), "C is scrolled left of B"); + drag(&mut h, cols.center(), pos2(g.x(&sh, 1) + 10.0, g.cells.top() + 40.0)); + assert_eq!(freeze(&h), Some((0, 1))); +} diff --git a/crates/ui-egui/tests/it/text_overflow_scroll.rs b/crates/ui-egui/tests/it/text_overflow_scroll.rs index 076478e52..371c0aeee 100644 --- a/crates/ui-egui/tests/it/text_overflow_scroll.rs +++ b/crates/ui-egui/tests/it/text_overflow_scroll.rs @@ -53,6 +53,18 @@ fn text_clips(h: &Harness, text: &str) -> Vec { .collect() } +/// Painted extents of the text shapes whose text is `text`, cut to their clip rects. +fn text_visible_rects(h: &Harness, text: &str) -> Vec { + h.output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == text => Some(t.visual_bounding_rect().intersect(s.clip_rect)), + _ => None, + }) + .collect() +} + fn cell_rect(h: &Harness, cell: &str) -> Rect { let app = h.state(); let sheet = app.session.active().unwrap().wb.active().unwrap(); @@ -408,6 +420,8 @@ fn frozen_column_text_keeps_its_cell_when_scrolled() { (clips[0].left() - a1.left()).abs() <= 0.5 && (clips[0].right() - a1.right()).abs() <= 0.5, "A1's text spans A1 at column {col}: {clips:?} vs {a1:?}" ); + let visible = text_visible_rects(&h, LEFT); + assert!(visible.len() == 1 && visible[0].is_positive(), "A1's text shows inside its clip at column {col}: {visible:?}"); } } From e89ba799e916d60fa3286c84f5dd859d5e5b0cbf Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:49:01 -0600 Subject: [PATCH 35/80] #2012: chart names and macro examples unquote sheet names like formulas do Three places still split a `Sheet!Name` reference and stripped the sheet's quotes by hand: the chart resolver (is a series name a defined name?), the XLSX chart writer (the name's cache) and the macro editor's example references. The first two left a doubled quote in the name, so a series named `'Bob''s'!Title` (a name on the sheet "Bob's") wasn't found and showed that text instead of the name's value; they also split at the first `!`, so a quoted sheet holding one broke. All three now use core's split_sheet, the one reading formulas use. Co-Authored-By: Claude Opus 5.5 --- crates/chart/src/resolve.rs | 7 +++--- crates/chart/src/tests.rs | 36 ++++++++++++++++++++++++++++++ crates/ui-egui/src/macro_editor.rs | 14 ++++++++---- crates/xlsx/src/chart.rs | 9 ++++---- 4 files changed, 55 insertions(+), 11 deletions(-) diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index 6eee201ae..de3cdef1b 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -179,9 +179,10 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> ( /// Whether `t` names a defined name seen from `sheet` (`Name`, or `Sheet1!Name` looked up on /// that sheet), the way the evaluator looks names up. fn is_defined_name(wb: &Workbook, sheet: usize, t: &str) -> bool { - match t.split_once('!') { - Some((s, bare)) => wb.sheet_index(s.trim_matches('\'')).is_some_and(|i| wb.name(bare, i).is_some()), - None => wb.name(t, sheet).is_some(), + // `'Bob''s'!Name`: the sheet is unquoted as everywhere else (#2012). + match gridcraft_core::split_sheet(t) { + (Some(s), bare) => wb.sheet_index(&s).is_some_and(|i| wb.name(bare, i).is_some()), + (None, _) => wb.name(t, sheet).is_some(), } } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 46d047afc..5d5876af0 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -1258,3 +1258,39 @@ fn box_whisker_draws_each_series_quartile_method() { // The boxes span 3.5 and 2.5 data units on the same value axis. assert!((heights[0] / heights[1] - 1.4).abs() < 0.001); } + +#[test] +fn a_series_named_by_a_name_on_a_sheet_with_an_apostrophe_resolves() { + // #2012: `'Bob''s'!Title` kept the doubled quote, so the sheet wasn't found and the series + // was named by the literal text. + let mut wb = Workbook::new(); + { + let s = wb.sheet_mut(0).unwrap(); + s.name = "Bob's".into(); + s.set_value(CellRef::new(0, 0), Value::from("Takings")); + s.set_value(CellRef::new(1, 0), Value::Number(4.0)); + s.set_value(CellRef::new(2, 0), Value::Number(9.0)); + } + wb.names.push(gridcraft_model::DefinedName { + name: "Title".into(), + scope: Some(0), + formula: "'Bob''s'!$A$1".into(), + comment: String::new(), + hidden: false, + }); + let mut c = chart(ChartKind::ColumnClustered); + c.series = vec![Series { + name: Some("'Bob''s'!Title".into()), + categories: None, + values: "'Bob''s'!$A$2:$A$3".into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + quartiles: Default::default(), + smooth: false, + trendlines: Vec::new(), + }]; + let d = resolve(&wb, 0, &c); + assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings")); +} diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 1f64d55ad..2dc52edf5 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -459,10 +459,7 @@ pub fn parse_refs(text: &str) -> Vec<(Option, RangeRef)> { text.split(',') .filter_map(|part| { let part = part.trim(); - let (sheet, body) = match part.rsplit_once('!') { - Some((s, b)) => (Some(s.trim().trim_matches('\'').replace("''", "'")), b), - None => (None, part), - }; + let (sheet, body) = gridcraft_engine::core::split_sheet(part); RangeRef::parse(&body.replace('$', "")).map(|r| (sheet, r)) }) .take(500) @@ -2399,6 +2396,15 @@ pub fn paint_overlay(p: &egui::Painter, geo: &crate::grid::Geo, sh: &gridcraft_e mod tests { use super::*; + #[test] + fn example_references_unquote_sheet_names_as_formulas_do() { + // #2012: one shared reading of `'Bob''s'!A1` and `'a!b'!A1`. + let refs = parse_refs("'Bob''s'!$A$1:$B$2, 'a!b'!C3, D4"); + let sheets: Vec> = refs.iter().map(|(s, _)| s.clone()).collect(); + assert_eq!(sheets, [Some("Bob's".to_string()), Some("a!b".to_string()), None]); + assert_eq!(refs.iter().map(|(_, r)| r.a1()).collect::>(), ["A1:B2", "C3", "D4"]); + } + #[test] fn an_edited_input_keeps_a_shape_a_macro_may_declare() { let range = NamedInput { rows: [2, 10], cols: [3, 3], header: true, col_types: vec![ColType::Number; 3], ..NamedInput::default() }; diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index 6fa9e0144..c36420043 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -366,12 +366,13 @@ fn is_ref(f: &str) -> bool { /// when it isn't a name, or names a sheet the workbook doesn't have. fn chart_name<'a>(wb: &'a Workbook, si: usize, f: &str) -> Option<(Option, String, Option<&'a DefinedName>)> { let Ok(Expr::Name(n)) = gridcraft_formula::parse(f.trim()) else { return None }; - match n.split_once('!') { - Some((s, bare)) => { - let i = wb.sheet_index(s.trim_matches('\''))?; + // `'Bob''s'!Name`: the sheet is unquoted as everywhere else (#2012). + match gridcraft_core::split_sheet(&n) { + (Some(s), bare) => { + let i = wb.sheet_index(&s)?; Some((Some(i), bare.to_string(), wb.name(bare, i))) } - None => { + (None, _) => { let def = wb.name(&n, si); Some((None, n, def)) } From a774d18350166477b8385799ada7c560d4a078b3 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:53:43 -0600 Subject: [PATCH 36/80] #3089: the personal-information flag's doc names the XLSX writer Addresses CodeRabbit review on #3238, "Limit the remove_personal_info doc comment to XLSX saves": only the XLSX writer leaves out the author and company; the workbook's own serialization still holds them. The comment now says so. Co-Authored-By: Claude Opus 5.5 --- crates/model/src/workbook.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index dbd011427..c8975eae0 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -202,7 +202,8 @@ pub struct Workbook { pub show_objects: ShowObjects, /// Trust Center › Privacy Options › "Remove personal information from file properties on save" /// (``): kept with the file, and while it is on the author and - /// company are left out of what GridCraft saves, as Excel leaves them empty. + /// company are left out of the XLSX files GridCraft writes, as Excel leaves them empty. Other + /// formats aren't filtered: the workbook's own serialization still holds them. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub remove_personal_info: bool, /// Read and set with [`Workbook::active_sheet`] and [`Workbook::set_active_sheet`]. From f9edafa800752fef25602dcaf84a0e1e080daa17 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:54:50 -0600 Subject: [PATCH 37/80] #2120: a linked cell's menu edits, opens or removes its link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking a cell with a link only offered "Link…", whose dialog was titled Insert Hyperlink, so changing a link wasn't obvious. As in Excel, the cell menu of a linked cell now offers Edit Hyperlink… (the same dialog, filled with the link and titled Edit Hyperlink), Open Hyperlink (as clicking the link: a reference selects its cell, a web or e-mail address asks first) and Remove Hyperlink (edit.clearHyperlinks). A cell without a link keeps "Link…". Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 ++ crates/ui-egui/src/grid/menus.rs | 16 +++++++++ .../tests/it/context_menu_stays_open.rs | 33 +++++++++++++++++++ docs/ui-parity.md | 3 +- 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 33be245a5..ec61c5c56 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -564,6 +564,8 @@ impl Dialog { d.values.insert("text".into(), json!(text)); d.values.insert("tooltip".into(), json!(link.tooltip)); d.original_link_text = Some(text); + // As in Excel, the dialog on a linked cell edits that link. + d.title = "Edit Hyperlink".into(); } d } diff --git a/crates/ui-egui/src/grid/menus.rs b/crates/ui-egui/src/grid/menus.rs index b05a04bc7..0e7872334 100644 --- a/crates/ui-egui/src/grid/menus.rs +++ b/crates/ui-egui/src/grid/menus.rs @@ -129,6 +129,9 @@ pub(super) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(200.0); let insert_copied = insert_copied_item(app); + // On a cell with a link Excel offers to edit, open or remove it (#2120). + let link = + app.session.active().and_then(|d| d.wb.active().and_then(|sh| sh.hyperlinks.get(&d.selection.active)).map(|l| l.target.clone())); let mut items: Vec<(&str, &str)> = vec![ ("Cut", "edit.cut"), ("Copy", "edit.copy"), @@ -154,6 +157,14 @@ pub(super) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { if let Some(item) = insert_copied { items.insert(6, item); } + if link.is_some() + && let Some(i) = items.iter().position(|(_, id)| *id == "ui:insertLink") + { + items.splice( + i..=i, + [("Edit Hyperlink…", "ui:insertLink"), ("Open Hyperlink", "ui:openLink"), ("Remove Hyperlink", "edit.clearHyperlinks")], + ); + } if show_details_offered(app) && let Some(i) = items.iter().position(|(_, id)| *id == "data.sortDescending") { @@ -178,6 +189,11 @@ pub(super) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { "ui:newNote" => app.open_dialog("comment", json!({"threaded": false})), "ui:defineName" => app.open_dialog("defineName", json!({})), "ui:insertLink" => app.open_dialog("insertLink", json!({})), + "ui:openLink" => { + if let Some(target) = &link { + super::interact::follow_link(app, target); + } + } "ui:pickList" => app.open_dialog("pickList", json!({})), "ui:filterValue" => { if let Some(d) = app.session.active() { diff --git a/crates/ui-egui/tests/it/context_menu_stays_open.rs b/crates/ui-egui/tests/it/context_menu_stays_open.rs index c48c659d0..365c28c92 100644 --- a/crates/ui-egui/tests/it/context_menu_stays_open.rs +++ b/crates/ui-egui/tests/it/context_menu_stays_open.rs @@ -100,3 +100,36 @@ fn a_quick_right_click_between_pixels_keeps_the_menu_open_on_retina() { h.run_steps(4); assert!(h.state().grid.context_menu.is_some(), "the menu stays open"); } + +#[test] +fn a_linked_cell_offers_to_edit_open_and_remove_its_link() { + // #2120: the menu only offered "Link…", and its dialog was titled Insert Hyperlink. + use egui_kittest::kittest::Queryable as _; + let mut h = harness(); + h.state_mut().session.execute("insert.link", serde_json::json!({"cell": "B2", "target": "#Sheet1!D9", "text": "Go"})).unwrap(); + h.run_steps(2); + quick_right_click(&mut h, "B2"); + assert!(h.query_by_label("Edit Hyperlink…").is_some() && h.query_by_label("Open Hyperlink").is_some(), "linked cell menu"); + assert!(h.query_by_label("Link…").is_none()); + // Open Hyperlink follows it (an internal reference selects its cell). + h.get_by_label("Open Hyperlink").click(); + h.run_steps(4); + assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::parse("D9").unwrap()); + // Edit Hyperlink opens the link's own dialog. + quick_right_click(&mut h, "B2"); + h.get_by_label("Edit Hyperlink…").click(); + h.run_steps(4); + let d = h.state().dialog.as_ref().expect("the link dialog"); + assert_eq!(d.title, "Edit Hyperlink"); + assert_eq!(d.values.get("target").and_then(|v| v.as_str()), Some("#Sheet1!D9")); + h.state_mut().dialog = None; + h.run_steps(2); + // Remove Hyperlink takes the link away; a cell without one offers Link… again. + quick_right_click(&mut h, "B2"); + h.get_by_label("Remove Hyperlink").click(); + h.run_steps(4); + let sh = h.state().session.active().unwrap().wb.active().unwrap().clone(); + assert!(!sh.hyperlinks.contains_key(&CellRef::parse("B2").unwrap())); + quick_right_click(&mut h, "B2"); + assert!(h.query_by_label("Link…").is_some() && h.query_by_label("Edit Hyperlink…").is_none()); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..e34aac280 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (a linked cell's menu edits, opens or removes the link; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Cell context menu on a cell with a link: Edit Hyperlink… (the link dialog, titled Edit Hyperlink and filled with the link), Open Hyperlink and Remove Hyperlink, as in Excel, instead of Link… (#2120) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From 3c0749e685861b07916db57c7368830541b1b0d7 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 02:31:45 -0600 Subject: [PATCH 38/80] #941: Excel 2003 menu access keys (Alt+I, R; Alt+D, S; ...) Adds 43 legacy sequences to keytips.json (Insert, Edit, Format, Data, View, Tools menus and File > Page Setup), each running the command or dialog the modern key tip already uses. Alt+I, D, V and T are implicit levels with no ribbon tab; badges are not painted under them, as in Excel. The generator checks Options (a Backstage page) by its page. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/keytips/keytip_table.rs | 23 ++ .../src/keytips/keytip_table/generated.rs | 45 ++- crates/ui-egui/src/keytips/keytips.json | 301 +++++++++++++++++ crates/ui-egui/src/ribbon/controls.rs | 10 +- crates/ui-egui/tests/it/keytips_generated.rs | 303 +++++++++++++++++- docs/ui-parity.md | 3 +- xtask/src/keytips.rs | 5 + 7 files changed, 686 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/keytips/keytip_table.rs b/crates/ui-egui/src/keytips/keytip_table.rs index 87c85befd..502e7336f 100644 --- a/crates/ui-egui/src/keytips/keytip_table.rs +++ b/crates/ui-egui/src/keytips/keytip_table.rs @@ -48,6 +48,13 @@ pub fn is_group_level(seq: &str) -> bool { matches!(lookup(seq), Some(Entry::Continue)) || (lookup(seq).is_none() && has_deeper(seq)) } +/// False under a legacy Excel 2003 menu letter that has no ribbon tab (Alt+I, D, V, T): Excel +/// paints no badges for those, so the ribbon stays as it is while the sequence is typed. +pub fn paints_badges(prefix: &str) -> bool { + let Some(root) = prefix.get(..1) else { return true }; + SEQUENCES.iter().any(|(seq, entry)| matches!(entry, Entry::Tab(_)) && seq.starts_with(root)) +} + fn matches_target(action: &Action, target: &str) -> bool { match action { Action::Command(id) | Action::CommandWith(id, _) | Action::Pending(id) | Action::Control(id) => *id == target, @@ -214,6 +221,22 @@ mod tests { assert!(!has_deeper("X")); } + #[test] + fn legacy_menu_letters_finish_without_badges() { + assert_eq!(lookup("IR"), Some(Entry::Finish(Action::Command("home.insertRows")))); + assert_eq!(lookup("DS"), Some(Entry::Finish(Action::Dialog("sort")))); + assert_eq!(lookup("EAA"), Some(Entry::Finish(Action::Command("edit.clearAll")))); + for root in ["I", "D", "V", "T"] { + assert!(is_group_level(root), "{root} waits for the menu letter"); + assert!(!paints_badges(root), "{root} has no tab, so no badges"); + } + assert!(paints_badges(""), "tab badges"); + assert!(paints_badges("HO")); + assert!(paints_badges("J"), "the Draw tab's two-letter unit"); + assert!(paints_badges("E"), "E and O stay aliases of Home"); + assert_eq!(sequences_for("home.insertRows"), vec!["HIR", "IR"], "the ribbon sequence stays first"); + } + #[test] fn group_extension_paints_only_continue_levels() { assert_eq!(group_extension("H", &["dialog:pasteSpecial"]), vec!["HV"]); diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 3124a4b1f..c3a343671 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -372,5 +372,48 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("FN", Entry::Finish(Action::Command("file.new"))), // Excel: New page; New page ("FO", Entry::Finish(Action::Command("file.open"))), // Excel: Open page; fires immediately in Excel; deeper aliases dropped as unreachable ("FP", Entry::Finish(Action::Command("file.print"))), // Excel: Print page; containment(Print) - ("Q", Entry::Finish(Action::Pending("ui.search"))), // pending; Excel: Search / Tell Me; Alt+Q search box does not exist yet (issue #49) + ("IR", Entry::Finish(Action::Command("home.insertRows"))), // Excel: Insert > Rows; Legacy Excel 2003 menu path (#941). + ("IC", Entry::Finish(Action::Command("home.insertColumns"))), // Excel: Insert > Columns; Legacy Excel 2003 menu path (#941). + ("IE", Entry::Finish(Action::Dialog("insertCells"))), // Excel: Insert > Cells; Legacy Excel 2003 menu path (#941). + ("IW", Entry::Finish(Action::Command("home.insertSheet"))), // Excel: Insert > Worksheet; Legacy Excel 2003 menu path (#941). + ("IF", Entry::Finish(Action::Dialog("insertFunction"))), // Excel: Insert > Function; Legacy Excel 2003 menu path (#941). + ("IND", Entry::Finish(Action::Dialog("defineName"))), // Excel: Insert > Name > Define; Legacy Excel 2003 menu path (#941). + ("IS", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Insert > Symbol; Legacy Excel 2003 menu path (#941). + ("ED", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Edit > Delete; Legacy Excel 2003 menu path (#941). + ("EM", Entry::Finish(Action::Dialog("moveSheet"))), // Excel: Edit > Move or Copy Sheet; Legacy Excel 2003 menu path (#941). + ("EL", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Edit > Delete Sheet; Legacy Excel 2003 menu path (#941). + ("EAA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Edit > Clear > All; Legacy Excel 2003 menu path (#941). + ("EAC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Edit > Clear > Contents; Legacy Excel 2003 menu path (#941). + ("EAM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Edit > Clear > Comments; Legacy Excel 2003 menu path (#941). + ("EF", Entry::Finish(Action::Dialog("find"))), // Excel: Edit > Find; Legacy Excel 2003 menu path (#941). + ("EG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Edit > Go To; Legacy Excel 2003 menu path (#941). + ("ORA", Entry::Finish(Action::Command("home.autofitRowHeight"))), // Excel: Format > Row > AutoFit; Legacy Excel 2003 menu path (#941). + ("ORE", Entry::Finish(Action::Dialog("rowHeight"))), // Excel: Format > Row > Height; Legacy Excel 2003 menu path (#941). + ("ORH", Entry::Finish(Action::Command("home.hideRows"))), // Excel: Format > Row > Hide; Legacy Excel 2003 menu path (#941). + ("ORU", Entry::Finish(Action::Command("home.unhideRows"))), // Excel: Format > Row > Unhide; Legacy Excel 2003 menu path (#941). + ("OCW", Entry::Finish(Action::Dialog("columnWidth"))), // Excel: Format > Column > Width; Legacy Excel 2003 menu path (#941). + ("OCH", Entry::Finish(Action::Command("home.hideColumns"))), // Excel: Format > Column > Hide; Legacy Excel 2003 menu path (#941). + ("OCU", Entry::Finish(Action::Command("home.unhideColumns"))), // Excel: Format > Column > Unhide; Legacy Excel 2003 menu path (#941). + ("OHR", Entry::Finish(Action::Dialog("renameSheet"))), // Excel: Format > Sheet > Rename; Legacy Excel 2003 menu path (#941). + ("OHH", Entry::Finish(Action::Command("sheet.hide"))), // Excel: Format > Sheet > Hide; Legacy Excel 2003 menu path (#941). + ("OHU", Entry::Finish(Action::Command("sheet.unhide"))), // Excel: Format > Sheet > Unhide; Legacy Excel 2003 menu path (#941). + ("OE", Entry::Finish(Action::Command("home.formatCells"))), // Excel: Format > Cells; Legacy Excel 2003 menu path (#941). + ("OD", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Format > Conditional Formatting; Legacy Excel 2003 menu path (#941). + ("DFF", Entry::Finish(Action::Command("data.filter"))), // Excel: Data > Filter > AutoFilter; Legacy Excel 2003 menu path (#941). + ("DS", Entry::Finish(Action::Dialog("sort"))), // Excel: Data > Sort; Legacy Excel 2003 menu path (#941). + ("DGG", Entry::Finish(Action::Command("data.group"))), // Excel: Data > Group and Outline > Group; Legacy Excel 2003 menu path (#941). + ("DGU", Entry::Finish(Action::Command("data.ungroup"))), // Excel: Data > Group and Outline > Ungroup; Legacy Excel 2003 menu path (#941). + ("DGS", Entry::Finish(Action::Command("data.showDetail"))), // Excel: Data > Group and Outline > Show Detail; Legacy Excel 2003 menu path (#941). + ("DGH", Entry::Finish(Action::Command("data.hideDetail"))), // Excel: Data > Group and Outline > Hide Detail; Legacy Excel 2003 menu path (#941). + ("DL", Entry::Finish(Action::Command("data.validation"))), // Excel: Data > Validation; Legacy Excel 2003 menu path (#941). + ("DT", Entry::Finish(Action::Command("data.dataTable"))), // Excel: Data > Table (what-if data table); Legacy Excel 2003 menu path (#941). + ("VN", Entry::Finish(Action::Command("view.normal"))), // Excel: View > Normal; Legacy Excel 2003 menu path (#941). + ("VP", Entry::Finish(Action::Command("view.pageBreakPreview"))), // Excel: View > Page Break Preview; Legacy Excel 2003 menu path (#941). + ("VZ", Entry::Finish(Action::Dialog("zoom"))), // Excel: View > Zoom; Legacy Excel 2003 menu path (#941). + ("TO", Entry::Finish(Action::Dialog("options"))), // Excel: Tools > Options; Legacy Excel 2003 menu path (#941). + ("TUA", Entry::Finish(Action::Command("formulas.removeArrows"))), // Excel: Tools > Formula Auditing > Remove All Arrows; Legacy Excel 2003 menu path (#941). + ("TUD", Entry::Finish(Action::Command("formulas.traceDependents"))), // Excel: Tools > Formula Auditing > Trace Dependents; Legacy Excel 2003 menu path (#941). + ("TUT", Entry::Finish(Action::Command("formulas.tracePrecedents"))), // Excel: Tools > Formula Auditing > Trace Precedents; Legacy Excel 2003 menu path (#941). + ("FU", Entry::Finish(Action::Dialog("pageSetup"))), // Excel: File > Page Setup; Legacy Excel 2003 menu path (#941). + ("Q", Entry::Finish(Action::Pending("ui.search"))), // pending; Excel: Search / Tell Me; Alt+Q search box does not exist yet (issue #49) ]; diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 90eab4909..5c96d1d9f 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -2582,6 +2582,307 @@ "excel": "Print page", "note": "containment(Print)" }, + { + "seq": "IR", + "kind": "command", + "target": "home.insertRows", + "excel": "Insert > Rows", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "IC", + "kind": "command", + "target": "home.insertColumns", + "excel": "Insert > Columns", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "IE", + "kind": "dialog", + "target": "insertCells", + "excel": "Insert > Cells", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "IW", + "kind": "command", + "target": "home.insertSheet", + "excel": "Insert > Worksheet", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "IF", + "kind": "dialog", + "target": "insertFunction", + "excel": "Insert > Function", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "IND", + "kind": "dialog", + "target": "defineName", + "excel": "Insert > Name > Define", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "IS", + "kind": "command", + "target": "insert.symbol", + "excel": "Insert > Symbol", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "ED", + "kind": "dialog", + "target": "deleteCells", + "excel": "Edit > Delete", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EM", + "kind": "dialog", + "target": "moveSheet", + "excel": "Edit > Move or Copy Sheet", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EL", + "kind": "command", + "target": "home.deleteSheet", + "excel": "Edit > Delete Sheet", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EAA", + "kind": "command", + "target": "edit.clearAll", + "excel": "Edit > Clear > All", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EAC", + "kind": "command", + "target": "edit.clearContents", + "excel": "Edit > Clear > Contents", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EAM", + "kind": "command", + "target": "edit.clearComments", + "excel": "Edit > Clear > Comments", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EF", + "kind": "dialog", + "target": "find", + "excel": "Edit > Find", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EG", + "kind": "dialog", + "target": "goTo", + "excel": "Edit > Go To", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "ORA", + "kind": "command", + "target": "home.autofitRowHeight", + "excel": "Format > Row > AutoFit", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "ORE", + "kind": "dialog", + "target": "rowHeight", + "excel": "Format > Row > Height", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "ORH", + "kind": "command", + "target": "home.hideRows", + "excel": "Format > Row > Hide", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "ORU", + "kind": "command", + "target": "home.unhideRows", + "excel": "Format > Row > Unhide", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OCW", + "kind": "dialog", + "target": "columnWidth", + "excel": "Format > Column > Width", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OCH", + "kind": "command", + "target": "home.hideColumns", + "excel": "Format > Column > Hide", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OCU", + "kind": "command", + "target": "home.unhideColumns", + "excel": "Format > Column > Unhide", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OHR", + "kind": "dialog", + "target": "renameSheet", + "excel": "Format > Sheet > Rename", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OHH", + "kind": "command", + "target": "sheet.hide", + "excel": "Format > Sheet > Hide", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OHU", + "kind": "command", + "target": "sheet.unhide", + "excel": "Format > Sheet > Unhide", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OE", + "kind": "command", + "target": "home.formatCells", + "excel": "Format > Cells", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OD", + "kind": "command", + "target": "home.conditionalFormat", + "excel": "Format > Conditional Formatting", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DFF", + "kind": "command", + "target": "data.filter", + "excel": "Data > Filter > AutoFilter", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DS", + "kind": "dialog", + "target": "sort", + "excel": "Data > Sort", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DGG", + "kind": "command", + "target": "data.group", + "excel": "Data > Group and Outline > Group", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DGU", + "kind": "command", + "target": "data.ungroup", + "excel": "Data > Group and Outline > Ungroup", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DGS", + "kind": "command", + "target": "data.showDetail", + "excel": "Data > Group and Outline > Show Detail", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DGH", + "kind": "command", + "target": "data.hideDetail", + "excel": "Data > Group and Outline > Hide Detail", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DL", + "kind": "command", + "target": "data.validation", + "excel": "Data > Validation", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DT", + "kind": "command", + "target": "data.dataTable", + "excel": "Data > Table (what-if data table)", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "VN", + "kind": "command", + "target": "view.normal", + "excel": "View > Normal", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "VP", + "kind": "command", + "target": "view.pageBreakPreview", + "excel": "View > Page Break Preview", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "VZ", + "kind": "dialog", + "target": "zoom", + "excel": "View > Zoom", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "TO", + "kind": "dialog", + "target": "options", + "excel": "Tools > Options", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "TUA", + "kind": "command", + "target": "formulas.removeArrows", + "excel": "Tools > Formula Auditing > Remove All Arrows", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "TUD", + "kind": "command", + "target": "formulas.traceDependents", + "excel": "Tools > Formula Auditing > Trace Dependents", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "TUT", + "kind": "command", + "target": "formulas.tracePrecedents", + "excel": "Tools > Formula Auditing > Trace Precedents", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "FU", + "kind": "dialog", + "target": "pageSetup", + "excel": "File > Page Setup", + "note": "Legacy Excel 2003 menu path (#941)." + }, { "seq": "Q", "kind": "command", diff --git a/crates/ui-egui/src/ribbon/controls.rs b/crates/ui-egui/src/ribbon/controls.rs index 774a2b40b..5d10e13e0 100644 --- a/crates/ui-egui/src/ribbon/controls.rs +++ b/crates/ui-egui/src/ribbon/controls.rs @@ -27,7 +27,15 @@ pub(super) fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) { /// Keytips are painted, not focusable widgets: the grid retains keyboard focus. pub(super) fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) { - let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return }; + let Some(rest) = app + .keytips + .prefix() + .filter(|prefix| keytip_table::paints_badges(prefix)) + .and_then(|prefix| sequence.strip_prefix(prefix)) + .filter(|s| !s.is_empty()) + else { + return; + }; let t = Tokens::get(ui.ctx()); let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("ribbon_keytips"))).with_clip_rect(ui.clip_rect()); let text = painter.layout_no_wrap(rest.into(), theme::ui_bold(10.5), t.text); diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 3dc225a76..976e1fdbf 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -2754,6 +2754,307 @@ fn keytip_fp_() { assert!(h.state().session.journal.iter().any(|(id, _)| id == "file.print"), "FP must journal file.print"); } +#[test] +fn keytip_ir_() { + let mut h = harness(blank()); + sequence(&mut h, "IR", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertRows"), "IR must journal home.insertRows"); +} + +#[test] +fn keytip_ic_() { + let mut h = harness(blank()); + sequence(&mut h, "IC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertColumns"), "IC must journal home.insertColumns"); +} + +#[test] +fn keytip_ie_() { + let mut h = harness(blank()); + sequence(&mut h, "IE", false); + assert!(h.state().dialog.is_some(), "IE must open the insertCells dialog"); +} + +#[test] +fn keytip_iw_() { + let mut h = harness(blank()); + sequence(&mut h, "IW", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertSheet"), "IW must journal home.insertSheet"); +} + +#[test] +fn keytip_if_() { + let mut h = harness(blank()); + sequence(&mut h, "IF", false); + assert!(h.state().dialog.is_some(), "IF must open the insertFunction dialog"); +} + +#[test] +fn keytip_ind_() { + let mut h = harness(blank()); + sequence(&mut h, "IND", false); + assert!(h.state().dialog.is_some(), "IND must open the defineName dialog"); +} + +#[test] +fn keytip_is_() { + let mut h = harness(blank()); + sequence(&mut h, "IS", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol"), "IS must journal insert.symbol"); +} + +#[test] +fn keytip_ed_() { + let mut h = harness(blank()); + sequence(&mut h, "ED", false); + assert!(h.state().dialog.is_some(), "ED must open the deleteCells dialog"); +} + +#[test] +fn keytip_em_() { + let mut h = harness(blank()); + sequence(&mut h, "EM", false); + assert!(h.state().dialog.is_some(), "EM must open the moveSheet dialog"); +} + +#[test] +fn keytip_el_() { + let mut h = harness(blank()); + sequence(&mut h, "EL", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "EL must invoke home.deleteSheet and surface its missing-params error"); +} + +#[test] +fn keytip_eaa_() { + let mut h = harness(blank()); + sequence(&mut h, "EAA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearAll"), "EAA must journal edit.clearAll"); +} + +#[test] +fn keytip_eac_() { + let mut h = harness(blank()); + sequence(&mut h, "EAC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearContents"), "EAC must journal edit.clearContents"); +} + +#[test] +fn keytip_eam_() { + let mut h = harness(blank()); + sequence(&mut h, "EAM", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearComments"), "EAM must journal edit.clearComments"); +} + +#[test] +fn keytip_ef_() { + let mut h = harness(blank()); + sequence(&mut h, "EF", false); + assert!(h.state().dialog.is_some(), "EF must open the find dialog"); +} + +#[test] +fn keytip_eg_() { + let mut h = harness(blank()); + sequence(&mut h, "EG", false); + assert!(h.state().dialog.is_some(), "EG must open the goTo dialog"); +} + +#[test] +fn keytip_ora_() { + let mut h = harness(blank()); + sequence(&mut h, "ORA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.autofitRowHeight"), "ORA must journal home.autofitRowHeight"); +} + +#[test] +fn keytip_ore_() { + let mut h = harness(blank()); + sequence(&mut h, "ORE", false); + assert!(h.state().dialog.is_some(), "ORE must open the rowHeight dialog"); +} + +#[test] +fn keytip_orh_() { + let mut h = harness(blank()); + sequence(&mut h, "ORH", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.hideRows"), "ORH must journal home.hideRows"); +} + +#[test] +fn keytip_oru_() { + let mut h = harness(blank()); + sequence(&mut h, "ORU", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.unhideRows"), "ORU must journal home.unhideRows"); +} + +#[test] +fn keytip_ocw_() { + let mut h = harness(blank()); + sequence(&mut h, "OCW", false); + assert!(h.state().dialog.is_some(), "OCW must open the columnWidth dialog"); +} + +#[test] +fn keytip_och_() { + let mut h = harness(blank()); + sequence(&mut h, "OCH", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.hideColumns"), "OCH must journal home.hideColumns"); +} + +#[test] +fn keytip_ocu_() { + let mut h = harness(blank()); + sequence(&mut h, "OCU", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.unhideColumns"), "OCU must journal home.unhideColumns"); +} + +#[test] +fn keytip_ohr_() { + let mut h = harness(blank()); + sequence(&mut h, "OHR", false); + assert!(h.state().dialog.is_some(), "OHR must open the renameSheet dialog"); +} + +#[test] +fn keytip_ohh_() { + let mut h = harness(blank()); + sequence(&mut h, "OHH", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "OHH must invoke sheet.hide and surface its missing-params error"); +} + +#[test] +fn keytip_ohu_() { + let mut h = harness(blank()); + sequence(&mut h, "OHU", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "OHU must invoke sheet.unhide and surface its missing-params error"); +} + +#[test] +fn keytip_oe_() { + let mut h = harness(blank()); + sequence(&mut h, "OE", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.formatCells"), "OE must journal home.formatCells"); +} + +#[test] +fn keytip_od_() { + let mut h = harness(blank()); + sequence(&mut h, "OD", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.conditionalFormat"), "OD must journal home.conditionalFormat"); +} + +#[test] +fn keytip_dff_() { + let mut h = harness(blank()); + sequence(&mut h, "DFF", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.filter"), "DFF must journal data.filter"); +} + +#[test] +fn keytip_ds_() { + let mut h = harness(blank()); + sequence(&mut h, "DS", false); + assert!(h.state().dialog.is_some(), "DS must open the sort dialog"); +} + +#[test] +fn keytip_dgg_() { + let mut h = harness(blank()); + sequence(&mut h, "DGG", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.group"), "DGG must journal data.group"); +} + +#[test] +fn keytip_dgu_() { + let mut h = harness(blank()); + sequence(&mut h, "DGU", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.ungroup"), "DGU must journal data.ungroup"); +} + +#[test] +fn keytip_dgs_() { + let mut h = harness(blank()); + sequence(&mut h, "DGS", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.showDetail"), "DGS must journal data.showDetail"); +} + +#[test] +fn keytip_dgh_() { + let mut h = harness(blank()); + sequence(&mut h, "DGH", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.hideDetail"), "DGH must journal data.hideDetail"); +} + +#[test] +fn keytip_dl_() { + let mut h = harness(blank()); + sequence(&mut h, "DL", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.validation"), "DL must journal data.validation"); +} + +#[test] +fn keytip_dt_() { + let mut h = harness(blank()); + sequence(&mut h, "DT", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "DT must invoke data.dataTable and surface its missing-params error"); +} + +#[test] +fn keytip_vn_() { + let mut h = harness(blank()); + sequence(&mut h, "VN", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.normal"), "VN must journal view.normal"); +} + +#[test] +fn keytip_vp_() { + let mut h = harness(blank()); + sequence(&mut h, "VP", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.pageBreakPreview"), "VP must journal view.pageBreakPreview"); +} + +#[test] +fn keytip_vz_() { + let mut h = harness(blank()); + sequence(&mut h, "VZ", false); + assert!(h.state().dialog.is_some(), "VZ must open the zoom dialog"); +} + +#[test] +fn keytip_to_() { + let mut h = harness(blank()); + sequence(&mut h, "TO", false); + assert!(h.state().backstage.is_some(), "TO must open the Options page"); +} + +#[test] +fn keytip_tua_() { + let mut h = harness(blank()); + sequence(&mut h, "TUA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.removeArrows"), "TUA must journal formulas.removeArrows"); +} + +#[test] +fn keytip_tud_() { + let mut h = harness(blank()); + sequence(&mut h, "TUD", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "TUD must run formulas.traceDependents cleanly"); +} + +#[test] +fn keytip_tut_() { + let mut h = harness(blank()); + sequence(&mut h, "TUT", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "TUT must run formulas.tracePrecedents cleanly"); +} + +#[test] +fn keytip_fu_() { + let mut h = harness(blank()); + sequence(&mut h, "FU", false); + assert!(h.state().dialog.is_some(), "FU must open the pageSetup dialog"); +} + #[test] fn keytip_q_() { let mut h = harness(blank()); @@ -2766,7 +3067,7 @@ fn keytip_q_() { #[test] fn generated_test_count_matches_the_table() { - const EXPECTED: usize = 367; + const EXPECTED: usize = 410; assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED); } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..3d51e0e65 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -21,7 +21,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Formulas › Function Library | Insert Function, AutoSum, Recently Used and a menu per function category | yes (10-24): a big Insert Function button and three columns of labelled small buttons, the layout Excel uses when the window is not wide enough for a big button per category; each category's book icon has its own colour; a category menu shows each function's description on hover and ends with Insert Function…, which opens the dialog on that category; More Functions cascades into Statistical, Engineering, Cube, Information, Compatibility and Web; AutoSum is a split button whose menu (Sum, Average, Count Numbers, Max, Min, More Functions…) Home › Editing shares; Recently Used has the MR key tip | the wide layout with a big button per category is not offered; a function counts as recently used once its formula is entered, not when Insert Function's OK is pressed | | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | -| Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | +| Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus the Excel 2003 menu keys (Alt+I, E, O, D, V, T and Alt+F, U, such as Alt+I, R or Alt+D, S), which paint no badges, as in Excel (#941); other tabs and controls not yet (#33, #49) | 3–5 h | | Formula bar with Name Box, expand, function hints | yes | yes; grows with its content from one to five lines (Alt+Enter, wrapped text, or viewing a multi-line cell), then scrolls with the caret in view, the Name Box and buttons staying on the first line; the chevron pins it at five; the in-cell editor shows every line | done; no drag-to-resize handle | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | Ctrl-drag (Cmd or Option on macOS) drops a copy (#971); active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Formula bar with Name Box, expand, function hints | yes | yes | done; dynamic-array spill children show the source formula muted and read-only, using current spill ownership | @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Excel 2003 menu access keys: 43 legacy sequences (Insert, Edit, Format, Data, View, Tools and File ▸ Page Setup, e.g. Alt+I, R inserts rows, Alt+E, A, A clears all, Alt+D, S sorts, Alt+T, O opens Options) run the existing commands and dialogs; Alt+I, D, V and T paint no badges, as in Excel (#941, #2311) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs index 5b7137455..b662dd101 100644 --- a/xtask/src/keytips.rs +++ b/xtask/src/keytips.rs @@ -323,6 +323,11 @@ fn render_tests(entries: &[Entry], probe: &Probe) -> String { id = e.target, ), }, + // Options is a Backstage page rather than a dialog box. + "dialog" if e.target == "options" => format!( + "sequence(&mut h, {seq:?}, false);\n assert!(h.state().backstage.is_some(), \"{seq} must open the Options page\");", + seq = e.seq, + ), "dialog" => format!( "sequence(&mut h, {seq:?}, false);\n assert!(h.state().dialog.is_some(), \"{seq} must open the {dialog} dialog\");", seq = e.seq, From dea1359c7e69e975fabd6db5fc15063eb149968e Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 02:41:57 -0600 Subject: [PATCH 39/80] #1246: drag the formula bar's bottom edge to resize it The bottom edge of the formula bar is a resize handle: dragging it sets the height in whole lines (up to half the window; one line collapses the bar). Expanding again (the chevron, Ctrl+Shift+U) returns to the dragged height instead of a fixed five lines. The drag previews live and commits once through view.expandFormulaBar {lines}, which is also scriptable; the height is kept with the UI preferences. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/commands.rs | 15 +++- crates/ui-egui/src/control.rs | 6 +- crates/ui-egui/src/formula_bar.rs | 77 +++++++++++++++++-- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/tests/it/formula_bar_expand.rs | 65 +++++++++++++++- docs/ui-parity.md | 3 +- 6 files changed, 160 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 86a4a6cc5..4f47cf085 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -163,8 +163,19 @@ impl SheetApp { Ok(Json::Null) } "view.expandFormulaBar" => { - self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded); - Ok(json!({"on": self.ui.formula_bar_expanded})) + // `lines` is where a drag of the bar's bottom edge left it: one line collapses it, + // more expands it to that height, which expanding returns to afterwards. + if let Some(lines) = p.get("lines").and_then(Json::as_u64) { + let lines = usize::try_from(lines).unwrap_or(usize::MAX).clamp(1, crate::formula_bar::MAX_DRAG_LINES); + self.ui.formula_bar_expanded = lines > 1; + if lines > 1 { + self.ui.formula_bar_lines = lines; + } + Ok(json!({"on": self.ui.formula_bar_expanded, "lines": self.ui.formula_bar_lines})) + } else { + self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded); + Ok(json!({"on": self.ui.formula_bar_expanded})) + } } "view.darkMode" => { let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark }; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2d46b21b8..38e86f106 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -263,7 +263,11 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("ui.ribbonTab", "Ribbon Tab", ""), ("app.checkForUpdates", "Check for Updates…", ""), ("view.collapseRibbon", "Collapse Ribbon", ""), - ("view.expandFormulaBar", "Expand Formula Bar", "{on?: bool} (toggles without it) → {on}"), + ( + "view.expandFormulaBar", + "Expand Formula Bar", + "{on?: bool} (toggles without it) → {on}; {lines: int} sets the expanded height (1 collapses) → {on, lines}", + ), ("ui.pythonMode", "Python Mode", "{on?: bool} (toggles without it) → {on}; a refused start is an error"), ("view.darkMode", "Dark Mode", ""), ("view.theme", "Display Theme", ""), diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 25178ce50..9be205a4f 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -12,6 +12,11 @@ use crate::theme::{self, Tokens}; /// Lines the formula bar (and the in-cell editor) grows to before it scrolls. pub const MAX_LINES: usize = 5; +/// The most lines a drag of the bar's bottom edge can give it (the window caps it at half its +/// height first). +pub const MAX_DRAG_LINES: usize = 100; +/// How far the bar's bottom edge reaches up into the bar as a resize handle (its bottom margin). +const HANDLE: f32 = 4.0; /// The bar's height at one line. const ONE_LINE: f32 = 30.0; /// What the bar adds around the field's rows: the panel's margins (3 + 3), the field's (3 + 3) @@ -40,11 +45,22 @@ pub struct BarLayout { pub visible_lines: usize, /// The caret while editing in the bar. pub caret: Option, + /// The bottom edge that resizes the bar. + pub handle: Rect, } impl Default for BarLayout { fn default() -> Self { - BarLayout { name_box: Rect::NOTHING, buttons: Rect::NOTHING, field: Rect::NOTHING, height: 0.0, lines: 1, visible_lines: 1, caret: None } + BarLayout { + name_box: Rect::NOTHING, + buttons: Rect::NOTHING, + field: Rect::NOTHING, + height: 0.0, + lines: 1, + visible_lines: 1, + caret: None, + handle: Rect::NOTHING, + } } } @@ -73,13 +89,15 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let font = theme::ui_font(13.0); let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font)); let last = BarLayout::get(ui.ctx()); - // Height follows the content's line count (last frame's), 1 to MAX_LINES; the chevron pins it - // at MAX_LINES. It eases only when that target changes. - let lines = if app.ui.formula_bar_expanded { MAX_LINES } else { last.lines.clamp(1, MAX_LINES) }; + // Height follows the content's line count (last frame's), 1 to MAX_LINES; expanded (the + // chevron), it is the height the bottom edge was dragged to. It eases only when that target + // changes. + let most = max_lines(ui.ctx().content_rect().height(), row_h); + let lines = if app.ui.formula_bar_expanded { app.ui.formula_bar_lines.clamp(1, most) } else { last.lines.clamp(1, MAX_LINES) }; let target = one_line(row_h) + (lines - 1) as f32 * row_h; let h = ui.ctx().animate_value_with_time(egui::Id::unique("formula_bar.height"), target, EASE_SECONDS); let mut layout = BarLayout { height: h, ..Default::default() }; - egui::Panel::top("formula_bar") + let panel = egui::Panel::top("formula_bar") .exact_size(h) .frame(egui::Frame::NONE.fill(t.window).inner_margin(egui::Margin { left: 8, right: 8, top: 3, bottom: 3 })) .show(ui, |ui| { @@ -156,9 +174,58 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } }); }); + layout.handle = resize_handle(app, ui, panel.response.rect, row_h, most); ui.ctx().data_mut(|d| d.insert_temp(BarLayout::id(), layout)); } +/// The most lines the bar takes in a window `window_h` tall: half the window, as in Excel, and +/// never fewer than the chevron's [`MAX_LINES`]. +fn max_lines(window_h: f32, row_h: f32) -> usize { + let fit = ((window_h * 0.5 - one_line(row_h)) / row_h).floor(); + if fit.is_finite() && fit > 0.0 { (fit as usize + 1).clamp(MAX_LINES, MAX_DRAG_LINES) } else { MAX_LINES } +} + +/// The lines a bar whose top is at `top` shows with its bottom edge dragged to `y`, in whole lines. +fn dragged_lines(y: f32, top: f32, row_h: f32, most: usize) -> usize { + let extra = ((y - top - one_line(row_h)) / row_h).round(); + if extra.is_finite() && extra > 0.0 { (extra as usize + 1).min(most) } else { 1 } +} + +/// The bar's bottom edge: dragging it sets the bar's height in whole lines, as in Excel. The bar +/// follows the pointer; the release runs `view.expandFormulaBar` with the lines, so one drag is +/// one step (one line collapses the bar). +fn resize_handle(app: &mut SheetApp, ui: &mut egui::Ui, bar: Rect, row_h: f32, most: usize) -> Rect { + let handle = Rect::from_x_y_ranges(bar.x_range(), (bar.bottom() - HANDLE)..=bar.bottom()); + let resp = ui.interact(handle, egui::Id::unique("formula_bar.resize"), Sense::drag()); + let name = app.ui.language.tr("Resize formula bar"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name)); + if resp.hovered() || resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + // The state before the drag: the preview changes it, the release puts it back for the command. + let before = resp.id.with("before"); + if resp.drag_started() { + let state = (app.ui.formula_bar_expanded, app.ui.formula_bar_lines); + ui.ctx().data_mut(|d| d.insert_temp(before, state)); + } + if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.drag_stopped()) { + let lines = dragged_lines(p.y, bar.top(), row_h, most); + if resp.drag_stopped() { + if let Some((expanded, kept)) = ui.ctx().data_mut(|d| d.remove_temp::<(bool, usize)>(before)) { + (app.ui.formula_bar_expanded, app.ui.formula_bar_lines) = (expanded, kept); + } + app.run_or_alert("view.expandFormulaBar", json!({"lines": lines})); + } else { + app.ui.formula_bar_expanded = lines > 1; + if lines > 1 { + app.ui.formula_bar_lines = lines; + } + ui.ctx().request_repaint(); + } + } + handle +} + /// Resolve ownership on every frame; never cache a spill range in presentation state. pub(crate) fn active_spill_anchor(app: &SheetApp) -> Option { let d = app.session.active()?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 95f861f14..d76787796 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -88,6 +88,9 @@ pub struct UiState { pub ribbon_tab: String, pub ribbon_collapsed: bool, pub formula_bar_expanded: bool, + /// Lines the expanded formula bar shows: the height its bottom edge was last dragged to. + /// Expanding it again (the chevron, Ctrl+Shift+U) returns to that height. + pub formula_bar_lines: usize, pub status_bar: bool, /// Motion in the UI: the selection outline glides to a new selection (View ▸ Animations). pub animations: bool, @@ -137,6 +140,7 @@ impl Default for UiState { ribbon_tab: "Home".into(), ribbon_collapsed: false, formula_bar_expanded: false, + formula_bar_lines: formula_bar::MAX_LINES, status_bar: true, animations: true, autocomplete: true, diff --git a/crates/ui-egui/tests/it/formula_bar_expand.rs b/crates/ui-egui/tests/it/formula_bar_expand.rs index a71a979cd..cabf7ee70 100644 --- a/crates/ui-egui/tests/it/formula_bar_expand.rs +++ b/crates/ui-egui/tests/it/formula_bar_expand.rs @@ -1,6 +1,6 @@ //! Ctrl+Shift+U (Control+Shift+U in Excel for Mac) expands and collapses the formula bar. -use egui::{Event, Key, Modifiers}; +use egui::{Event, Key, Modifiers, vec2}; use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; use gridcraft_ui_egui::keymap::{Shortcut, lookup}; @@ -84,3 +84,66 @@ fn expand_formula_bar_command_sets_or_toggles_the_state() { assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({})), Ok(json!({"on": false}))); assert!(!expanded(&h)); } + +fn drag(h: &mut Harness, from: egui::Pos2, to: egui::Pos2) { + use egui::PointerButton; + h.input_mut().events.extend([ + Event::PointerMoved(from), + Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.step(); + for i in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 6.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(20); +} + +fn bar(h: &Harness) -> gridcraft_ui_egui::formula_bar::BarLayout { + gridcraft_ui_egui::formula_bar::BarLayout::get(&h.ctx) +} + +#[test] +fn dragging_the_bottom_edge_sets_the_height_that_expanding_returns_to() { + let mut h = harness(); + h.run_steps(20); + let start = bar(&h); + assert_eq!(start.visible_lines, 1, "one line of text shows a one-line bar"); + assert!( + start.handle.height() > 0.0 && (start.handle.bottom() - start.field.bottom()).abs() <= 4.0, + "the handle is the bar's bottom edge: {start:?}" + ); + // Drag the edge down about five and a half rows: the bar snaps to whole lines. + let row = 17.0; + drag(&mut h, start.handle.center(), start.handle.center() + vec2(0.0, 5.5 * row)); + let lines = h.state().ui.formula_bar_lines; + assert!(expanded(&h), "a taller bar is expanded"); + assert!((6..=7).contains(&lines), "about six more lines: {lines}"); + assert_eq!(bar(&h).visible_lines, lines, "the field shows the dragged lines: {:?}", bar(&h)); + // Collapse, then expand: back to the dragged height, not the default five lines. + ctrl_shift_u(&mut h, CTRL_SHIFT); + h.run_steps(20); + assert!(!expanded(&h)); + assert_eq!(bar(&h).visible_lines, 1); + ctrl_shift_u(&mut h, CTRL_SHIFT); + h.run_steps(20); + assert_eq!(bar(&h).visible_lines, lines, "expanding returns to the dragged height"); + // Dragging the edge up to one line collapses the bar and keeps the expanded height. + let edge = bar(&h).handle.center(); + drag(&mut h, edge, edge - vec2(0.0, 20.0 * row)); + assert!(!expanded(&h), "a one-line bar is collapsed"); + assert_eq!(bar(&h).visible_lines, 1); + assert_eq!(h.state().ui.formula_bar_lines, lines); +} + +#[test] +fn expand_formula_bar_with_lines_sets_the_expanded_height() { + let mut h = harness(); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 8})), Ok(json!({"on": true, "lines": 8}))); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 1})), Ok(json!({"on": false, "lines": 8})), "one line collapses"); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({})), Ok(json!({"on": true}))); + assert_eq!(h.state().ui.formula_bar_lines, 8, "expanding returns to the set height"); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 0})), Ok(json!({"on": false, "lines": 8})), "zero lines is one line"); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": u64::MAX})), Ok(json!({"on": true, "lines": 100})), "capped"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..5c1fc5543 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -22,7 +22,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | | Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | -| Formula bar with Name Box, expand, function hints | yes | yes; grows with its content from one to five lines (Alt+Enter, wrapped text, or viewing a multi-line cell), then scrolls with the caret in view, the Name Box and buttons staying on the first line; the chevron pins it at five; the in-cell editor shows every line | done; no drag-to-resize handle | +| Formula bar with Name Box, expand, function hints | yes | yes; grows with its content from one to five lines (Alt+Enter, wrapped text, or viewing a multi-line cell), then scrolls with the caret in view, the Name Box and buttons staying on the first line; dragging its bottom edge sets the height in whole lines (up to half the window; one line collapses it), and the chevron or Ctrl+Shift+U expands it back to that height (five lines until it is dragged); the in-cell editor shows every line | done; the Name Box width is fixed | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | Ctrl-drag (Cmd or Option on macOS) drops a copy (#971); active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Formula bar with Name Box, expand, function hints | yes | yes | done; dynamic-array spill children show the source formula muted and read-only, using current spill ownership | | Python cells: Python mode | yes | yes (11-01): Ctrl+Alt+Shift+P (Cmd+Option+Shift+P on a Mac), `=PY` then Tab, or editing a Python cell edits the code in the formula bar's code editor, with a PY badge beside fx; Enter starts a new line with the indentation, Tab and Shift+Tab indent, Ctrl+Enter (Cmd+Enter) puts the code in the cell through `python.set`, Esc cancels. Deleting all the code and committing clears a Python cell, as an empty entry clears any other cell. The formula bar shows while a Python edit is open, also when View › Formula Bar hides it. On a protected sheet, a Python cell formatted Hidden shows no code in the formula bar and starts no edit, as a hidden formula shows none | this build stores Python code and doesn't run it: new or changed code shows `#BLOCKED!`; no `xl()` pointing on the sheet yet | @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | The formula bar's bottom edge is a resize handle: dragging it sets the bar's height in whole lines, up to half the window, and dragging it to one line collapses it; expanding (the chevron, Ctrl+Shift+U) returns to the dragged height, which is kept with the UI preferences. `view.expandFormulaBar {lines}` sets it (#1246) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From ac27137cd80cab628d5dc1ae93ddb55a0dd40a8b Mon Sep 17 00:00:00 2001 From: adifiedio Date: Tue, 3 Nov 2026 14:14:34 +0530 Subject: [PATCH 40/80] Clipboard: paste a picture from the system clipboard at the active cell (#699) Cmd/Ctrl+V dropped egui's PasteImage event, and the ribbon/menu Paste only read text, so screenshots and images copied in a browser reported "Nothing to paste". Both now encode the pixels as PNG (bounded to 16384 px per side, 64 Mpx) and run insert.picture as a floating picture anchored at the active cell, as Excel does. Text on the clipboard still wins. New host service: Services::paste_image (arboard). Fixes #699, #3018, #3182. --- apps/gridcraft/Cargo.toml | 4 +- apps/gridcraft/src/main.rs | 6 ++- crates/ui-egui/src/app/clipboard.rs | 50 ++++++++++++++++++-- crates/ui-egui/src/grid/keyboard.rs | 8 ++-- crates/ui-egui/src/services.rs | 3 ++ crates/ui-egui/tests/it/clipboard.rs | 68 ++++++++++++++++++++++++++++ docs/cell-pictures.md | 7 +-- docs/ui-parity.md | 3 +- 8 files changed, 134 insertions(+), 15 deletions(-) diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index aaa012934..a7472821f 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -37,13 +37,13 @@ winit = { version = "0.30", default-features = false, features = ["x11", "waylan craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } craft-update = { workspace = true } rfd = { workspace = true } -arboard = { workspace = true } +arboard = { workspace = true, features = ["image-data"] } gridcraft-mcp = { workspace = true } # The control channel's token (OS random numbers); already in the tree. getrandom = "0.4" [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] -arboard = { workspace = true, features = ["wayland-data-control"] } +arboard = { workspace = true, features = ["image-data", "wayland-data-control"] } [target.'cfg(target_os = "windows")'.dependencies] # Blocks on the start-up DirectX 12 adapter probe (`graphics.rs`); already in the tree via eframe. diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 3aab1ccfc..e99ef15de 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -154,7 +154,7 @@ fn read_clipboard( fn services() -> Services { // Keep the owner alive: X11/Wayland serve clipboard data from this handle. let clipboard: Rc>> = Rc::default(); - let (html_clipboard, text_clipboard) = (clipboard.clone(), clipboard.clone()); + let (html_clipboard, text_clipboard, image_clipboard) = (clipboard.clone(), clipboard.clone(), clipboard.clone()); Services { copy_html: Some(Box::new(move |html, text| { let mut clipboard = clipboard.borrow_mut(); @@ -165,6 +165,10 @@ fn services() -> Services { })), paste_html: Some(Box::new(move || read_clipboard(&html_clipboard, |clipboard| clipboard.get().html()))), paste_text: Some(Box::new(move || read_clipboard(&text_clipboard, |clipboard| clipboard.get_text()))), + paste_image: Some(Box::new(move || { + let image = read_clipboard(&image_clipboard, |clipboard| clipboard.get_image())?; + image::RgbaImage::from_raw(u32::try_from(image.width).ok()?, u32::try_from(image.height).ok()?, image.bytes.into_owned()) + })), pick_open: Some(Box::new(|| { rfd::FileDialog::new() .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"]) diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index da75d8b73..3ef59a8af 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -48,12 +48,36 @@ impl SheetApp { } /// Ribbon and menu Paste: paste the text on the system clipboard, as Cmd/Ctrl+V does. With no - /// text there, no host clipboard (web), or the text that was there when GridCraft last copied - /// without publishing, GridCraft's own clipboard is pasted. + /// text there but a picture, the picture is pasted. With neither, no host clipboard (web), or + /// the text that was there when GridCraft last copied without publishing, GridCraft's own + /// clipboard is pasted. pub fn paste_from_clipboard(&mut self) { match self.system_clipboard_text() { Some(text) if self.session.clipboard.is_none() || self.clipboard_text_at_copy.as_ref() != Some(&text) => self.paste_external(text), - _ => self.run_or_alert("edit.paste", json!({})), + Some(_) => self.run_or_alert("edit.paste", json!({})), + None => match self.services.paste_image.as_mut().and_then(|read| read()) { + Some(image) => self.paste_picture(image), + None => self.run_or_alert("edit.paste", json!({})), + }, + } + } + + /// Paste a picture from the system clipboard (a screenshot, an image copied in a browser) as + /// a floating picture whose top-left corner is the active cell, as Excel does. + pub fn paste_picture(&mut self, image: image::RgbaImage) { + match encode_png(&image) { + Ok(png) => self.run_or_alert("insert.picture", json!({"base64": gridcraft_engine::io::base64_encode(&png), "mime": "image/png"})), + Err(e) => self.alert(e), + } + } + + /// Cmd/Ctrl+V with a picture and no text on the clipboard (egui's `Event::PasteImage`). + pub fn paste_color_image(&mut self, image: &egui::ColorImage) { + let [w, h] = image.size; + let rgba = image.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect(); + match u32::try_from(w).ok().zip(u32::try_from(h).ok()).and_then(|(w, h)| image::RgbaImage::from_raw(w, h, rgba)) { + Some(image) => self.paste_picture(image), + None => self.alert("The picture on the clipboard can't be read.".into()), } } @@ -75,3 +99,23 @@ impl SheetApp { } } } + +/// Largest clipboard picture pasted, per side and in total (an 8K screenshot fits). +const MAX_PICTURE_SIDE: u32 = 16_384; +const MAX_PICTURE_PIXELS: u64 = 64 * 1024 * 1024; + +/// PNG bytes of a clipboard picture, refusing sizes that would exhaust memory. +fn encode_png(image: &image::RgbaImage) -> Result, String> { + let (w, h) = image.dimensions(); + if w == 0 || h == 0 { + return Err("The picture on the clipboard is empty.".into()); + } + if w > MAX_PICTURE_SIDE || h > MAX_PICTURE_SIDE || u64::from(w) * u64::from(h) > MAX_PICTURE_PIXELS { + return Err("The picture on the clipboard is too large to paste.".into()); + } + let mut png = Vec::new(); + image + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .map_err(|e| format!("The picture on the clipboard can't be pasted: {e}"))?; + Ok(png) +} diff --git a/crates/ui-egui/src/grid/keyboard.rs b/crates/ui-egui/src/grid/keyboard.rs index b8823d570..d70296588 100644 --- a/crates/ui-egui/src/grid/keyboard.rs +++ b/crates/ui-egui/src/grid/keyboard.rs @@ -51,11 +51,9 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res app.copy_to_clipboard(ctx, "edit.cut"); } egui::Event::Paste(text) => app.paste_external(text), - // GridCraft has no image-clipboard pipeline and no cell-picture insert-from-clipboard - // command. Choosing to ignore `PasteImage` is explicit: inserting the bytes as a - // floating picture without a chosen anchor/size would be a new feature, not parity. - // A text representation, when the clipboard has one, still arrives as `Paste` above. - egui::Event::PasteImage(_) => {} + // A picture with no text on the clipboard (a screenshot, a browser image) is pasted as + // a floating picture at the active cell, as Excel does. + egui::Event::PasteImage(image) => app.paste_color_image(&image), // PRIMARY middle-click paste belongs to egui's TextEdit; the grid has no caret to // insert text at, so a middle click on a cell is deliberately not a paste. egui::Event::MiddleClickPaste { .. } => {} diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 748e23ab3..20b8da851 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -32,6 +32,9 @@ pub struct Services { /// gets it from egui's `Event::Paste`). `None` from the host, or no service (web), pastes /// GridCraft's own clipboard. pub paste_text: Option Option>>, + /// Read the picture on the system clipboard for the ribbon and menu Paste commands when it + /// holds no text (Cmd/Ctrl+V gets it from egui's `Event::PasteImage`). + pub paste_image: Option Option>>, /// Web: deliver a file to the user (name, bytes). pub download: Option, /// Web: start an asynchronous file pick; the bytes arrive later in `inbox`. diff --git a/crates/ui-egui/tests/it/clipboard.rs b/crates/ui-egui/tests/it/clipboard.rs index be1983b61..ee52dc162 100644 --- a/crates/ui-egui/tests/it/clipboard.rs +++ b/crates/ui-egui/tests/it/clipboard.rs @@ -482,3 +482,71 @@ fn the_cut_text_pastes_again_once_the_clipboard_has_held_other_text() { h.run_steps(2); assert_eq!(value_at(&h, "H1"), Some(Value::from("Name")), "the same text copied afresh pastes"); } + +/// The floating pictures on the active sheet: (anchor cell, width, height, mime). +fn pictures(h: &Harness) -> Vec<(String, f32, f32, String)> { + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + sh.images.iter().map(|i| (i.anchor.cell.a1(), i.anchor.width, i.anchor.height, i.mime.clone())).collect() +} + +#[test] +fn cmd_v_with_a_picture_on_the_clipboard_inserts_it_at_the_active_cell() { + // A screenshot or a browser image: the clipboard has pixels and no text (#699, #3018, #3182). + let mut h = harness(Services::default()); + h.state_mut().run("selection.set", json!({"cell": "C5"})).unwrap(); + let image = egui::ColorImage::new([4, 3], vec![egui::Color32::from_rgb(200, 30, 30); 12]); + h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(image))); + h.run_steps(2); + assert!(h.state().message.is_none(), "{:?}", h.state().message); + assert_eq!(pictures(&h), vec![("C5".to_string(), 4.0, 3.0, "image/png".to_string())]); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + let png = image::load_from_memory(&sh.images[0].data).unwrap().to_rgba8(); + assert_eq!(png.get_pixel(3, 2).0, [200, 30, 30, 255], "the pixels survive the PNG round trip"); + + // One undo step removes it. + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert!(pictures(&h).is_empty()); +} + +#[test] +fn a_malformed_clipboard_picture_is_refused_without_inserting() { + let mut h = harness(Services::default()); + let mut image = egui::ColorImage::new([4, 3], vec![egui::Color32::WHITE; 12]); + image.pixels.truncate(5); + h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(image))); + h.run_steps(2); + assert!(pictures(&h).is_empty()); + assert!(h.state().message.is_some(), "the user is told the paste failed"); + + dismiss(&mut h); + h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(egui::ColorImage::new([0, 0], Vec::new())))); + h.run_steps(2); + assert!(pictures(&h).is_empty()); +} + +#[test] +fn ribbon_paste_pastes_a_clipboard_picture_when_there_is_no_text() { + let reads = Rc::new(RefCell::new(0)); + let count = reads.clone(); + let (_, mut services) = system_clipboard(""); + services.paste_image = Some(Box::new(move || { + *count.borrow_mut() += 1; + Some(image::RgbaImage::from_pixel(5, 2, image::Rgba([0, 0, 255, 255]))) + })); + let mut h = harness(services); + h.state_mut().run("selection.set", json!({"cell": "B7"})).unwrap(); + click_paste(&mut h); + assert_eq!(*reads.borrow(), 1); + assert_eq!(pictures(&h), vec![("B7".to_string(), 5.0, 2.0, "image/png".to_string())]); +} + +#[test] +fn ribbon_paste_prefers_clipboard_text_over_a_picture() { + let (_, mut services) = system_clipboard("words"); + services.paste_image = Some(Box::new(|| Some(image::RgbaImage::new(1, 1)))); + let mut h = harness(services); + h.state_mut().run("selection.set", json!({"cell": "D2"})).unwrap(); + click_paste(&mut h); + assert_eq!(value_at(&h, "D2"), Some(Value::from("words"))); + assert!(pictures(&h).is_empty()); +} diff --git a/docs/cell-pictures.md b/docs/cell-pictures.md index 586615422..1257476c5 100644 --- a/docs/cell-pictures.md +++ b/docs/cell-pictures.md @@ -17,9 +17,10 @@ The command API accepts the same placement: {"command":"insert.picture","params":{"path":"picture.png","placement":"cell","at":"B2","alt":"Product photo"}} ``` -This initial feature handles static pictures. Pasting an image from the operating system's -clipboard, converting between floating and cell pictures, and the `IMAGE()` function remain -separate work. Formula references and arithmetic do not carry pictures: the calculation +This initial feature handles static pictures. A picture on the operating system's clipboard (a +screenshot, an image copied in a browser) pastes with Cmd/Ctrl+V or Paste as a floating picture +at the active cell, as in Excel. Pasting it into a cell, converting between floating and cell +pictures, and the `IMAGE()` function remain separate work. Formula references and arithmetic do not carry pictures: the calculation engine uses a `#VALUE!` fallback, including when a picture is referenced from another cell. Text-only exports also use that fallback. The picture cell still counts as occupied and blocks a formula's spill range. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..76ecc39e0 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (a picture on the system clipboard pastes as a floating picture at the active cell with Cmd/Ctrl+V or Paste (#699); earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -308,6 +308,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md | | 2026-10-12 | minor | Page Layout view: left/right/top/bottom margins are dragged on the rulers (#792) | | 2026-10-12 | minor | Alt+Down opens the active filter header's existing popup, with validation priority and mirrored anchoring on right-to-left sheets (part of #441) | +| 2026-11-03 | minor | A picture on the system clipboard (a screenshot, an image copied in a browser or an image editor) pastes with Cmd/Ctrl+V, the ribbon's Paste and the Paste menu items as a floating picture whose top-left corner is the active cell, as Excel does; text on the clipboard still wins. Oversized or malformed pictures are refused with a message (#699, #3018, #3182) | | 2026-10-12 | minor | Paste from the ribbon (button and options menu), the cell and header context menus and the macOS menu bar's Edit menu pastes what is on the system clipboard, as Cmd/Ctrl+V does, instead of only GridCraft's own copy (the web build, which can't read the clipboard on request, still pastes GridCraft's copy). Copy and Cut by keytip (H,C / H,X) publish to the system clipboard like the ribbon buttons. After a control-channel or MCP copy, which leaves the system clipboard alone, Paste pastes that copy until other text is copied. Once a cut has been pasted, Paste (ribbon, menus or Cmd/Ctrl+V) doesn't paste the cut's clipboard text again over the moved cells (Excel pastes a cut once), and the Paste keytip (H,V,P) pastes the system clipboard like the button. Regression test for the Paste button click freeze in 0.5.0 (#273, fixed by #272); the Paste button gets an accessible "Paste" name for screen readers | | 2026-10-11 | minor | Ribbon: scroll arrows on a tab wider than the window, Formulas › AutoSum as a split button with its function menu, View › Show in two columns so Headings is no longer cut off | | 2026-10-11 | minor | Theme tokens for icon, reference, on-accent and sheet-control colours; WCAG 2 contrast test over both themes (dark danger red and selected header text adjusted) | From 94b4f6a3a21fc9192ad41766820d927ff55e3b3c Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 02:50:10 -0600 Subject: [PATCH 41/80] #1481: open SYLK (.slk) files A small reader for the SYLK text format: cell values (numbers, text, booleans, errors), formulas converted from R1C1 to A1 as Excel does (relative/absolute references, ranges, whole rows and columns, shared formulas) with their cached results, number formats (P / F;P) and column widths (F;W). Import only; saving as .slk says so. The sheet is named after the file, and damaged records are skipped with a warning. Co-Authored-By: Claude Opus 5.5 --- apps/gridcraft-web/src/web.rs | 2 +- apps/gridcraft/src/main.rs | 2 +- crates/engine/src/io.rs | 15 +- crates/engine/src/io/sylk.rs | 323 ++++++++++++++++++++++++++++++++++ crates/engine/src/io/tests.rs | 78 ++++++++ docs/file-format-parity.md | 6 +- docs/gaps.md | 2 +- 7 files changed, 422 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/io/sylk.rs diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index 10e17dc44..33dc84936 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -193,7 +193,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { let Some(file) = rfd::AsyncFileDialog::new() - .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"]) + .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "slk", "json"]) .pick_file() .await else { diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 3aab1ccfc..d75fe702b 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -167,7 +167,7 @@ fn services() -> Services { paste_text: Some(Box::new(move || read_clipboard(&text_clipboard, |clipboard| clipboard.get_text()))), pick_open: Some(Box::new(|| { rfd::FileDialog::new() - .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"]) + .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "slk", "json"]) .add_filter("Excel Workbook", &["xlsx", "xlsm"]) .add_filter("Excel 97-2003 Workbook (import)", &["xls"]) .add_filter("Excel Binary Workbook (data import)", &["xlsb"]) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index ce1fb40b0..1382ecd07 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1,4 +1,4 @@ -//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export. +//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, SYLK import, CSV/TSV, JSON (debug), HTML export. use std::fmt::Write as _; @@ -7,6 +7,8 @@ use gridcraft_model::{Sheet, Workbook}; use crate::{EngineError, Result}; +mod sylk; + /// File formats GridCraft reads or writes. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FileKind { @@ -19,6 +21,8 @@ pub enum FileKind { Tsv, Json, Html, + /// SYLK symbolic link text (import only). + Sylk, } impl FileKind { @@ -33,6 +37,7 @@ impl FileKind { "tsv" | "tab" | "txt" => FileKind::Tsv, "json" | "scjson" => FileKind::Json, "html" | "htm" => FileKind::Html, + "slk" => FileKind::Sylk, _ => return None, }) } @@ -310,6 +315,11 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, } Ok((wb, warnings)) } + Some(FileKind::Sylk) => { + let (mut wb, warnings) = sylk::read(name, bytes)?; + rename_first_sheet(&mut wb, name); + Ok((wb, warnings)) + } Some(FileKind::Tsv) => { let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() }; let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; @@ -389,6 +399,9 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())), FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())), + FileKind::Sylk => { + Err(EngineError::Other("SYLK (.slk) is supported for import only. Save as .xlsx or another supported export format.".into())) + } // A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`). FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default), FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default), diff --git a/crates/engine/src/io/sylk.rs b/crates/engine/src/io/sylk.rs new file mode 100644 index 000000000..9d8765783 --- /dev/null +++ b/crates/engine/src/io/sylk.rs @@ -0,0 +1,323 @@ +//! SYLK (`.slk`) import: the symbolic link text format older spreadsheets and some legacy systems +//! export. One record per line, fields separated by `;` (`;;` is a literal semicolon): +//! +//! - `ID` starts the file; `E` ends it. +//! - `C;Y;X;K;E` is a cell. `Y` and `X` may be left out to stay on the +//! current row or column. `K` is a number, `"text"`, `TRUE`/`FALSE` or an error; `E` is a +//! formula in R1C1 notation; `S;R;C` shares the formula of another cell. +//! - `P;P` adds a number format to the file's list; `F;P;Y;X` gives a cell the +//! n-th format (0-based), and `F;W ` sets column widths in characters. +//! +//! Other records (fonts, options, window settings) are skipped. Formulas are converted to A1 with +//! the same relative and absolute references, as Excel does when it opens the file. + +use std::collections::HashMap; +use std::sync::Arc; + +use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, Value, col_to_letters}; +use gridcraft_model::{Cell, Formula, NumFmt, Sheet, Style, Workbook}; + +use crate::{EngineError, Result}; + +/// The largest SYLK file read, as for other text imports: past it the file is refused. +const MAX_BYTES: usize = 512 << 20; + +/// Reads a SYLK file into a one-sheet workbook. Returns the workbook and its warnings. +pub fn read(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { + if bytes.len() > MAX_BYTES { + return Err(EngineError::Other(format!("We can't open '{name}': the SYLK file is larger than {} MB.", MAX_BYTES >> 20))); + } + let text = match std::str::from_utf8(bytes) { + Ok(s) => s.to_string(), + // Older exports are in a single-byte code page: keep each byte as its Latin-1 character. + Err(_) => bytes.iter().map(|&b| char::from(b)).collect(), + }; + let text = text.strip_prefix('\u{feff}').unwrap_or(&text); + if !text.starts_with("ID") { + return Err(EngineError::Other(format!("We can't open '{name}': it isn't a SYLK file (it doesn't start with an ID record)."))); + } + let mut sheet = Sheet::new("Sheet1"); + let mut formats: Vec = Vec::new(); + // Cells' number formats by index into `formats`, applied once every format is known. + let mut cell_formats: Vec<(CellRef, usize)> = Vec::new(); + let mut formulas: HashMap = HashMap::new(); + let mut widths: Vec<(u32, u32, f64)> = Vec::new(); + let (mut row, mut col) = (0u32, 0u32); + let mut skipped = 0usize; + for line in text.lines() { + let fields = split_fields(line); + let Some((kind, rest)) = fields.split_first() else { continue }; + match kind.as_str() { + "C" => { + let (mut value, mut formula, mut shared) = (None, None, None::<(Option, Option)>); + let mut bad = false; + for f in rest { + let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8)); + match tag { + "Y" => match position(arg, MAX_ROWS) { + Some(r) => row = r, + None => bad = true, + }, + "X" => match position(arg, MAX_COLS) { + Some(c) => col = c, + None => bad = true, + }, + "K" => value = Some(parse_value(arg)), + "E" => formula = Some(arg.to_string()), + "S" => shared = Some((None, None)), + // In a shared-formula cell, R and C name the cell whose formula it shares. + "R" if shared.is_some() => shared = shared.map(|(_, c)| (position(arg, MAX_ROWS), c)), + "C" if shared.is_some() => shared = shared.map(|(r, _)| (r, position(arg, MAX_COLS))), + _ => {} + } + } + if bad { + skipped += 1; + continue; + } + let at = CellRef::new(row, col); + if let Some((Some(r), Some(c))) = shared { + formula = formulas.get(&CellRef::new(r, c)).cloned(); + } + let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty)); + if let Some(v) = value { + cell.value = v; + } + if let Some(r1c1) = formula { + let a1 = r1c1_formula_to_a1(&r1c1, at); + let f = Formula::new(&a1); + if f.expr.is_some() { + cell.formula = Some(Arc::new(f)); + formulas.insert(at, r1c1); + } else { + skipped += 1; + } + } + sheet.set_cell(at, cell); + } + "F" => { + let mut format = None; + for f in rest { + let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8)); + match tag { + "Y" => row = position(arg, MAX_ROWS).unwrap_or(row), + "X" => col = position(arg, MAX_COLS).unwrap_or(col), + "P" => format = arg.parse::().ok(), + "W" => { + let mut parts = arg.split_whitespace(); + if let (Some(first), Some(last), Some(width)) = (parts.next(), parts.next(), parts.next()) + && let (Some(first), Some(last), Ok(width)) = + (position(first, MAX_COLS), position(last, MAX_COLS), width.parse::()) + && first <= last + && (0.0..=255.0).contains(&width) + { + widths.push((first, last, width)); + } + } + _ => {} + } + } + if let Some(format) = format { + cell_formats.push((CellRef::new(row, col), format)); + } + } + // Number formats only: `P;F…` and `P;E…` are fonts. + "P" => { + if let Some(code) = rest.first().and_then(|f| f.strip_prefix('P')) { + formats.push(code.to_string()); + } + } + "E" => break, + _ => {} + } + } + for (first, last, width) in widths { + let size = crate::cmd::format::chars_to_points(width) as f32; + for c in first..=last { + sheet.cols.entry(c).or_default().size = Some(size); + } + } + let mut wb = Workbook::new(); + let mut styles = HashMap::new(); + for (at, index) in cell_formats { + let Some(code) = formats.get(index).filter(|code| !code.is_empty() && *code != "General") else { continue }; + let style = *styles.entry(index).or_insert_with(|| wb.styles.intern(Style { num_fmt: NumFmt(Arc::from(code.as_str())), ..Style::default() })); + let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty)); + cell.style = style; + sheet.set_cell(at, cell); + } + if let Some(first) = wb.sheet_mut(0) { + sheet.name = std::mem::take(&mut first.name); + *first = sheet; + } + let mut warnings = Vec::new(); + if skipped > 0 { + warnings.push(format!("{name}: {skipped} SYLK records GridCraft couldn't read were skipped")); + } + Ok((wb, warnings)) +} + +/// A record's fields: `;` separates them and `;;` is a literal semicolon. +fn split_fields(line: &str) -> Vec { + let mut fields = vec![String::new()]; + let mut chars = line.trim_end_matches('\r').chars().peekable(); + while let Some(c) = chars.next() { + if c == ';' { + if chars.peek() == Some(&';') { + chars.next(); + if let Some(f) = fields.last_mut() { + f.push(';'); + } + } else { + fields.push(String::new()); + } + } else if let Some(f) = fields.last_mut() { + f.push(c); + } + } + fields +} + +/// A 1-based row or column number → 0-based, within `max`. +fn position(arg: &str, max: u32) -> Option { + arg.trim().parse::().ok().filter(|n| (1..=max).contains(n)).map(|n| n - 1) +} + +/// A `K` value: `"text"` (everything between the first and the last quote, as written), a +/// boolean, an error or a number. +fn parse_value(arg: &str) -> Value { + if let Some(text) = arg.strip_prefix('"') { + let text = text.strip_suffix('"').unwrap_or(text); + return Value::Text(Arc::from(text)); + } + if arg.eq_ignore_ascii_case("TRUE") || arg.eq_ignore_ascii_case("FALSE") { + return Value::Bool(arg.eq_ignore_ascii_case("TRUE")); + } + if let Some(e) = CellError::parse(arg) { + return Value::Error(e); + } + match arg.trim().parse::() { + Ok(n) if n.is_finite() => Value::Number(n), + _ => Value::Text(Arc::from(arg)), + } +} + +/// One part of an R1C1 reference: `R`/`C` followed by an absolute number, a bracketed offset or +/// nothing (the cell's own row or column). +#[derive(Clone, Copy, Debug, PartialEq)] +enum Part { + Absolute(u32), + Relative(i64), +} + +impl Part { + /// The 0-based index and whether it is absolute, or `None` when it falls off the sheet. + fn resolve(self, base: u32, max: u32) -> Option<(u32, bool)> { + match self { + Part::Absolute(n) => (1..=max).contains(&n).then(|| (n - 1, true)), + Part::Relative(k) => u32::try_from(i64::from(base) + k).ok().filter(|v| *v < max).map(|v| (v, false)), + } + } +} + +/// Parses the number after `R` or `C` at `i`: digits, `[offset]` or nothing. Returns the part and +/// the index after it, or `None` when the text isn't a reference part. +fn parse_part(chars: &[char], i: usize) -> Option<(Part, usize)> { + match chars.get(i) { + Some('[') => { + let close = chars[i..].iter().position(|&c| c == ']')? + i; + let inner: String = chars[i + 1..close].iter().collect(); + Some((Part::Relative(inner.parse().ok()?), close + 1)) + } + Some(c) if c.is_ascii_digit() => { + let end = chars[i..].iter().position(|c| !c.is_ascii_digit()).map_or(chars.len(), |p| p + i); + let n: String = chars[i..end].iter().collect(); + Some((Part::Absolute(n.parse().unwrap_or(u32::MAX)), end)) + } + _ => Some((Part::Relative(0), i)), + } +} + +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?' | '$') +} + +/// Rewrites the R1C1 references in a SYLK formula as A1 references for the cell at `at`: +/// `RC[-1]` → `B2`, `R1C1` → `$A$1`, `R[-2]C:R[-1]C` → `D2:D3`, a whole column `C4` → `$D:$D` and +/// a whole row `R3` → `$3:$3`. Text in quotes and quoted sheet names are left alone; a reference +/// off the sheet becomes `#REF!`. +pub(crate) fn r1c1_formula_to_a1(formula: &str, at: CellRef) -> String { + let chars: Vec = formula.chars().collect(); + let mut out = String::with_capacity(formula.len()); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c == '"' || c == '\'' { + // Copy the quoted run as it is; a doubled quote stays inside it. + let mut j = i + 1; + while j < chars.len() && !(chars[j] == c && chars.get(j + 1) != Some(&c)) { + j += if chars[j] == c { 2 } else { 1 }; + } + let stop = (j + 1).min(chars.len()); + out.extend(&chars[i..stop]); + i = stop; + continue; + } + let starts_word = i == 0 || !is_word_char(chars[i - 1]); + if starts_word + && matches!(c, 'R' | 'r' | 'C' | 'c') + && let Some((text, next)) = reference_at(&chars, i, at) + { + out.push_str(&text); + i = next; + continue; + } + // Copy the rest of a word as it is, so `ROUND` or `Sheet2` isn't read as a reference. + if is_word_char(c) { + let end = (i..chars.len()).find(|&j| !is_word_char(chars[j])).unwrap_or(chars.len()); + out.extend(&chars[i..end]); + i = end; + } else { + out.push(c); + i += 1; + } + } + out +} + +/// The A1 text of the R1C1 reference starting at `i`, and the index after it, or `None` when the +/// word there isn't a reference (a function or a name). +fn reference_at(chars: &[char], i: usize, at: CellRef) -> Option<(String, usize)> { + let mut j = i; + let mut row = None; + let mut col = None; + if matches!(chars.get(j), Some('R' | 'r')) { + let (part, next) = parse_part(chars, j + 1)?; + row = Some(part); + j = next; + } + if matches!(chars.get(j), Some('C' | 'c')) { + let (part, next) = parse_part(chars, j + 1)?; + col = Some(part); + j = next; + } + if row.is_none() && col.is_none() { + return None; + } + // A reference ends the word: `ROUND(`, `RATE` or `R2D2` are names. + if chars.get(j).is_some_and(|&c| is_word_char(c) || c == '(' || c == '[' || c == '!') { + return None; + } + let row_text = |p: Part| p.resolve(at.row, MAX_ROWS).map(|(r, abs)| format!("{}{}", if abs { "$" } else { "" }, r + 1)); + let col_text = |p: Part| p.resolve(at.col, MAX_COLS).map(|(c, abs)| format!("{}{}", if abs { "$" } else { "" }, col_to_letters(c))); + // One side of a range (`R2:R4`, `C1:C3`) is written alone; a lone row or column is the whole of it. + let prev = chars[..i].iter().rev().find(|c| !c.is_whitespace()); + let in_range = prev == Some(&':') || chars[j..].iter().find(|c| !c.is_whitespace()) == Some(&':'); + let text = match (row, col) { + (Some(r), Some(c)) => row_text(r).zip(col_text(c)).map(|(r, c)| format!("{c}{r}")), + (Some(r), None) => row_text(r).map(|r| if in_range { r } else { format!("{r}:{r}") }), + (None, Some(c)) => col_text(c).map(|c| if in_range { c } else { format!("{c}:{c}") }), + (None, None) => None, + }; + Some((text.unwrap_or_else(|| "#REF!".into()), j)) +} diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index 9051c5c09..9e2adea32 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -604,3 +604,81 @@ fn opening_google_sheets_export_unwraps_only_formulas_gridcraft_calculates() { assert_eq!(text("A2").as_deref(), Some("IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")")); assert_eq!(opened.sheets[0].cells.get(CellRef::parse("A2").unwrap()).map(|c| c.value.clone()), Some(Value::text("salut"))); } + +/// A small SYLK file written the way spreadsheets export one: a header, number formats, column +/// widths, cells that carry on from the last row or column, formulas in R1C1 and a shared formula. +const SYLK: &str = "ID;PTEST;N;E\r\nP;PGeneral\r\nP;P0.00\r\nP;Pyyyy\\-mm\\-dd\r\nF;W1 1 18\r\nF;W2 3 9\r\nB;Y5;X7\r\n\ +C;Y1;X1;K\"Item\"\r\nC;X2;K\"Qty\"\r\nC;X4;K\"Total\"\r\n\ +C;Y2;X1;K\"Bolts;; small\"\r\nC;X2;K12\r\nF;P1;F$2G;X3\r\nC;X3;K0.35\r\nC;X4;K4.2;ERC[-2]*RC[-1]\r\n\ +C;X5;K52;ER2C2+R[1]C[-3]\r\nC;X6;KTRUE\r\nC;X7;K8.4;ERC[-3]*2\r\n\ +C;Y3;X1;K\"Nuts \"\"hex\"\"\"\r\nC;X2;K40\r\nC;X3;K0.12\r\nC;X4;K4.8;ERC[-2]*RC[-1]\r\nC;X5;K\"big\";EIF(R4C4>5,\"big\",\"small\")\r\nC;X7;K9.6;S;R2;C7\r\n\ +C;Y4;X1;K\"Sum\"\r\nC;X4;K9;ESUM(R[-2]C:R[-1]C)\r\nC;X5;K#DIV/0!;E1/0\r\nC;X7;K18;ESUM(C4)\r\n\ +F;P2;FG0G;Y5;X2\r\nC;Y5;X2;K45000\r\nC;X3;K\"R2C3\";E\"R2C3\"\r\nE\r\n"; + +#[test] +fn sylk_files_open_with_values_formulas_formats_and_widths() { + let (wb, warnings) = open_bytes("Parts list.slk", SYLK.as_bytes()).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.name, "Parts list", "the sheet is named after the file"); + let at = |a: &str| CellRef::parse(a).unwrap(); + let formula = |a: &str| sh.cell(at(a)).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()); + let text = |s: &str| gridcraft_core::Value::Text(s.into()); + assert_eq!(sh.value(at("A1")), text("Item")); + assert_eq!(sh.value(at("A2")), text("Bolts; small"), ";; is a semicolon"); + assert_eq!(sh.value(at("A3")), text("Nuts \"\"hex\"\""), "text is kept between the outer quotes as written"); + assert_eq!(sh.value(at("B2")), gridcraft_core::Value::Number(12.0)); + assert_eq!(sh.value(at("F2")), gridcraft_core::Value::Bool(true)); + assert_eq!(sh.value(at("E4")), gridcraft_core::Value::Error(gridcraft_core::CellError::Div0)); + assert_eq!(sh.value(at("D4")), gridcraft_core::Value::Number(9.0), "a formula keeps the value the file cached"); + assert_eq!(formula("D2").as_deref(), Some("B2*C2")); + assert_eq!(formula("E2").as_deref(), Some("$B$2+B3")); + assert_eq!(formula("E3").as_deref(), Some("IF($D$4>5,\"big\",\"small\")")); + assert_eq!(formula("D4").as_deref(), Some("SUM(D2:D3)")); + assert_eq!(formula("G3").as_deref(), Some("D3*2"), "a shared formula is the other cell's, moved"); + assert_eq!(formula("G4").as_deref(), Some("SUM($D:$D)"), "C4 alone is the whole column"); + assert_eq!(formula("C5").as_deref(), Some("\"R2C3\""), "text in quotes isn't a reference"); + // `F;P1;X3` formats the cell on the current row only. + let fmt = |a: &str| wb.styles.get(sh.style_id(at(a))).num_fmt.0.to_string(); + assert_eq!(fmt("C2"), "0.00"); + assert_eq!(fmt("C3"), "General"); + assert_eq!(fmt("B5"), "yyyy\\-mm\\-dd"); + // Widths are in characters, like the Column Width dialog's. + let chars = |c: u32| gridcraft_model::points_to_chars(f64::from(sh.col_width(c))); + assert_eq!((chars(0), chars(1), chars(2)), (18.0, 9.0, 9.0)); + assert_eq!(sh.cols.get(&3).and_then(|i| i.size), None, "column D keeps the default width"); +} + +#[test] +fn sylk_r1c1_formulas_become_a1() { + let b2 = CellRef::parse("B2").unwrap(); + for (r1c1, a1) in [ + ("RC", "B2"), + ("R[1]C[-1]", "A3"), + ("R1C1:R2C2", "$A$1:$B$2"), + ("R3", "$3:$3"), + ("R1:R[2]", "$1:4"), + ("C[1]", "C:C"), + ("SUM(C3:C4)", "SUM($C:$D)"), + ("ROUND(RC[1],2)+RATE(1,2,3)", "ROUND(C2,2)+RATE(1,2,3)"), + ("'R1C1 sheet'!R1C1", "'R1C1 sheet'!$A$1"), + ("Sheet2!RC", "Sheet2!B2"), + ("R2D2+rc", "R2D2+B2"), + ("R[-5]C", "#REF!"), + ("\"RC\"&R1C1", "\"RC\"&$A$1"), + ] { + assert_eq!(sylk::r1c1_formula_to_a1(r1c1, b2), a1, "{r1c1}"); + } +} + +#[test] +fn sylk_rejects_other_files_and_survives_damage() { + assert!(open_bytes("not.slk", b"A,B\n1,2\n").is_err(), "a SYLK file starts with ID"); + // Positions off the sheet, broken numbers, a missing end and a format past the list. + let (wb, warnings) = + open_bytes("damaged.slk", b"ID;P\nC;Y0;X1;K1\nC;Y2;X99999;K2\nC;Y3;X1;K1e999\nF;P7;Y3;X1\nF;W5 2 9\nC;Y4;X1;K5;E(((\n").unwrap(); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.cells.iter().count(), 2, "{:?}", sh.cells.iter().collect::>()); + assert_eq!(sh.value(CellRef::parse("A3").unwrap()), gridcraft_core::Value::Text("1e999".into())); + assert_eq!(warnings.len(), 1, "skipped records are reported: {warnings:?}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c4bc72bae..f117806a3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -106,7 +106,8 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real | `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection; per-column day/month date-order detection for `/` and `-` dates) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`); `file.exportCsv {values: true}` writes raw values instead. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8; per-column day/month date-order detection for `/` and `-` dates) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | | `.prn` formatted text (space-delimited) | R/W | no | no | | -| `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy | +| `.slk` SYLK | R/W | **yes, partial** (#1481) | no (Save As `.xlsx`) | Values (numbers, text, booleans, errors), formulas converted from R1C1 to A1 as Excel does (relative and absolute references, ranges, whole rows and columns, shared formulas) with their cached results, number formats (`P`/`F;P`), column widths (`F;W`); the sheet is named after the file. Not read: fonts and cell styles, row heights, names, comments. Non-UTF-8 files are read as Latin-1 | engine `io/tests.rs` (`sylk_*`) | +| `.dif` Data Interchange Format | R/W | no | no | Small, legacy | | `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes (one sheet as a table; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported. A sheet with pictures or charts is written with the sheet's own column widths and row heights, a picture as a `data:` URI `` and a chart as inline SVG, each placed at its anchor; shapes, text boxes and comments are not exported | | `.mht` / `.mhtml` single-file web page | R/W (Windows) | no | no | | | `.json` | via Power Query | GridCraft's own workbook JSON (debug) and JSON import | GridCraft JSON | Not Power Query's JSON shaping; notes and hyperlinks break export (#126) | @@ -148,13 +149,14 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Legacy `.xls` (BIFF8): exact theme colours (XFExt), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 | | Encrypted workbooks: save with password (#11) | 4–6 | | XLSB and ODS: formulas and formatting on import; then write | 10–15 | -| CSV/text: encoding choice, `.prn`, `.dif`, `.slk`, XML Spreadsheet 2003 | 4–7 | +| CSV/text: encoding choice, `.prn`, `.dif`, XML Spreadsheet 2003 | 4–7 | | PDF: embed subsetted system fonts so every script exports | (counted in [localization](localization-parity.md) script work) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | SYLK (`.slk`) import: values, R1C1 formulas as A1 (shared formulas too) with cached results, number formats and column widths; import only (#1481) | | 2026-11-02 | minor | Python cells: `.xlsx` reads and writes `_xlfn._xlws.PY(i,0 or 1)` with the code in the Python part (and reads the string-literal form) with cached values and spills, other formats adopt PY formulas, GridCraft JSON keeps the whole cell, and opening limits code size, result size and spill ranges | | 2026-11-02 | trivial | An equation without Office Math takes its stored source only while its text, paragraphs included, is what the writer wrote for it; a paragraph split that changes no character reads back as a text box with the split text | | 2026-11-02 | trivial | Each equation box carries the marker of its own body in its description, the Office Math box and the plain-text fallback alike, and the reader takes the stored source only while the body it reads matches the marker: the text of an equation without Office Math that another application edited reads back as a text box with the edited text instead of the stale source | diff --git a/docs/gaps.md b/docs/gaps.md index de725fb4c..8879e967d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -157,7 +157,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- [ui-parity.md](ui-parity.md#accessibility) ### 19. XLSB/ODS beyond values; minor formats (FF) -- XLSB and ODS import values only, no write; no `.prn`, `.dif`, `.slk`, XML Spreadsheet 2003; CSV +- XLSB and ODS import values only, no write; no `.prn`, `.dif`, XML Spreadsheet 2003 (`.slk` imports values, formulas, number formats and widths); CSV writes raw instead of displayed values; templates saved with the workbook content type. **Estimate:** 15–23 h. From e56a722bb4751089d34b3b750802f75f0211ea5b Mon Sep 17 00:00:00 2001 From: adifiedio Date: Tue, 3 Nov 2026 14:25:14 +0530 Subject: [PATCH 42/80] Clipboard: refuse oversized pasted pictures before copying their pixels; document paste as desktop-only (#699) --- crates/ui-egui/src/app/clipboard.rs | 19 +++++++++++++++---- crates/ui-egui/tests/it/clipboard.rs | 9 +++++++++ docs/cell-pictures.md | 3 ++- docs/ui-parity.md | 2 +- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index 3ef59a8af..4b7cc74d9 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -74,8 +74,13 @@ impl SheetApp { /// Cmd/Ctrl+V with a picture and no text on the clipboard (egui's `Event::PasteImage`). pub fn paste_color_image(&mut self, image: &egui::ColorImage) { let [w, h] = image.size; + // A side too large for u32 is too large to paste; check before copying the pixels. + let (w, h) = (u32::try_from(w).unwrap_or(u32::MAX), u32::try_from(h).unwrap_or(u32::MAX)); + if let Err(e) = check_picture_size(w, h) { + return self.alert(e); + } let rgba = image.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect(); - match u32::try_from(w).ok().zip(u32::try_from(h).ok()).and_then(|(w, h)| image::RgbaImage::from_raw(w, h, rgba)) { + match image::RgbaImage::from_raw(w, h, rgba) { Some(image) => self.paste_picture(image), None => self.alert("The picture on the clipboard can't be read.".into()), } @@ -104,15 +109,21 @@ impl SheetApp { const MAX_PICTURE_SIDE: u32 = 16_384; const MAX_PICTURE_PIXELS: u64 = 64 * 1024 * 1024; -/// PNG bytes of a clipboard picture, refusing sizes that would exhaust memory. -fn encode_png(image: &image::RgbaImage) -> Result, String> { - let (w, h) = image.dimensions(); +/// Refuse empty clipboard pictures and sizes that would exhaust memory. +fn check_picture_size(w: u32, h: u32) -> Result<(), String> { if w == 0 || h == 0 { return Err("The picture on the clipboard is empty.".into()); } if w > MAX_PICTURE_SIDE || h > MAX_PICTURE_SIDE || u64::from(w) * u64::from(h) > MAX_PICTURE_PIXELS { return Err("The picture on the clipboard is too large to paste.".into()); } + Ok(()) +} + +/// PNG bytes of a clipboard picture, refusing sizes that would exhaust memory. +fn encode_png(image: &image::RgbaImage) -> Result, String> { + let (w, h) = image.dimensions(); + check_picture_size(w, h)?; let mut png = Vec::new(); image .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) diff --git a/crates/ui-egui/tests/it/clipboard.rs b/crates/ui-egui/tests/it/clipboard.rs index ee52dc162..38c6ea259 100644 --- a/crates/ui-egui/tests/it/clipboard.rs +++ b/crates/ui-egui/tests/it/clipboard.rs @@ -522,6 +522,15 @@ fn a_malformed_clipboard_picture_is_refused_without_inserting() { h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(egui::ColorImage::new([0, 0], Vec::new())))); h.run_steps(2); assert!(pictures(&h).is_empty()); + + // Wider than the 16384-pixel limit: refused before its pixels are copied. + dismiss(&mut h); + let wide = egui::ColorImage::new([20_000, 1], vec![egui::Color32::WHITE; 20_000]); + h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(wide))); + h.run_steps(2); + assert!(pictures(&h).is_empty()); + let message = format!("{:?}", h.state().message); + assert!(message.contains("too large"), "{message}"); } #[test] diff --git a/docs/cell-pictures.md b/docs/cell-pictures.md index 1257476c5..0803128a4 100644 --- a/docs/cell-pictures.md +++ b/docs/cell-pictures.md @@ -19,7 +19,8 @@ The command API accepts the same placement: This initial feature handles static pictures. A picture on the operating system's clipboard (a screenshot, an image copied in a browser) pastes with Cmd/Ctrl+V or Paste as a floating picture -at the active cell, as in Excel. Pasting it into a cell, converting between floating and cell +at the active cell, as in Excel (desktop builds only: the web build's paste receives clipboard +text alone, so a picture with no text pastes nothing there). Pasting it into a cell, converting between floating and cell pictures, and the `IMAGE()` function remain separate work. Formula references and arithmetic do not carry pictures: the calculation engine uses a `#VALUE!` fallback, including when a picture is referenced from another cell. Text-only exports also use that fallback. The picture cell still counts as occupied and diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 76ecc39e0..ae2bf9db6 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -308,7 +308,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md | | 2026-10-12 | minor | Page Layout view: left/right/top/bottom margins are dragged on the rulers (#792) | | 2026-10-12 | minor | Alt+Down opens the active filter header's existing popup, with validation priority and mirrored anchoring on right-to-left sheets (part of #441) | -| 2026-11-03 | minor | A picture on the system clipboard (a screenshot, an image copied in a browser or an image editor) pastes with Cmd/Ctrl+V, the ribbon's Paste and the Paste menu items as a floating picture whose top-left corner is the active cell, as Excel does; text on the clipboard still wins. Oversized or malformed pictures are refused with a message (#699, #3018, #3182) | +| 2026-11-03 | minor | A picture on the system clipboard (a screenshot, an image copied in a browser or an image editor) pastes with Cmd/Ctrl+V, the ribbon's Paste and the Paste menu items as a floating picture whose top-left corner is the active cell, as Excel does; text on the clipboard still wins. Desktop only: the web build's paste receives clipboard text alone. Oversized or malformed pictures are refused with a message (#699, #3018, #3182) | | 2026-10-12 | minor | Paste from the ribbon (button and options menu), the cell and header context menus and the macOS menu bar's Edit menu pastes what is on the system clipboard, as Cmd/Ctrl+V does, instead of only GridCraft's own copy (the web build, which can't read the clipboard on request, still pastes GridCraft's copy). Copy and Cut by keytip (H,C / H,X) publish to the system clipboard like the ribbon buttons. After a control-channel or MCP copy, which leaves the system clipboard alone, Paste pastes that copy until other text is copied. Once a cut has been pasted, Paste (ribbon, menus or Cmd/Ctrl+V) doesn't paste the cut's clipboard text again over the moved cells (Excel pastes a cut once), and the Paste keytip (H,V,P) pastes the system clipboard like the button. Regression test for the Paste button click freeze in 0.5.0 (#273, fixed by #272); the Paste button gets an accessible "Paste" name for screen readers | | 2026-10-11 | minor | Ribbon: scroll arrows on a tab wider than the window, Formulas › AutoSum as a split button with its function menu, View › Show in two columns so Headings is no longer cut off | | 2026-10-11 | minor | Theme tokens for icon, reference, on-accent and sheet-control colours; WCAG 2 contrast test over both themes (dark danger red and selected header text adjusted) | From 6fcb94f7e3873be4136cb38e219f99f03bd654ac Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 03:03:58 -0600 Subject: [PATCH 43/80] #941: Alt+I, S opens the Symbol gallery instead of re-setting the active cell Addresses CodeRabbit review on #3250, "Open a symbol picker instead of re-parsing the cell": the legacy key ran insert.symbol with no symbol, which set the active cell to its own text again (re-reading it: text could turn into a number, a Python cell lost its code). It now routes to the ribbon's Symbol control: a control key tip whose control is on another tab shows that tab first and hands the request to the next frame, when the control is drawn, and the Symbol button opens its gallery on its key tip. Nothing is inserted until a symbol is picked. Test: legacy_keytips::alt_i_s_opens_the_symbol_gallery_and_leaves_the_cell_alone (keytips regenerated with cargo xtask keytips). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app.rs | 5 +-- crates/ui-egui/src/app/keys.rs | 13 +++++++- .../src/keytips/keytip_table/generated.rs | 2 +- crates/ui-egui/src/keytips/keytips.json | 2 +- crates/ui-egui/src/lib.rs | 5 +++ crates/ui-egui/src/ribbon/insert.rs | 4 ++- crates/ui-egui/tests/it/keytips_generated.rs | 3 +- crates/ui-egui/tests/it/legacy_keytips.rs | 33 +++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 9 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/tests/it/legacy_keytips.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 9d190083e..762a8c704 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -107,8 +107,9 @@ impl SheetApp { pub fn logic(&mut self, ctx: &egui::Context) { self.ensure_text_width(ctx); // A keytip control request lives one frame: the ribbon consumes it during `ui`, and - // anything that never reached a control (collapsed ribbon, tab switch) is dropped. - self.ui.keytip_invoke = None; + // anything that never reached a control (collapsed ribbon, tab switch) is dropped. One + // made with a switch to the control's tab waits for the frame that draws that tab. + self.ui.keytip_invoke = self.ui.keytip_invoke_next.take(); // Floating pictures' textures go with their workbook, even when no sheet is painted. let open: Vec = self.session.documents().iter().map(|d| d.uid).collect(); floating_pictures::forget_closed(ctx, &open); diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index 3490bd01c..b490d8557 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -59,7 +59,18 @@ impl SheetApp { // Route to the ribbon control that owns this keytip: it opens its dropdown or // runs its click handler with the right params (the engine command alone would // be missing them). - keytips::Action::Control(id) => self.ui.keytip_invoke = Some(id), + // + // A control on another tab (a legacy menu key such as Alt+I, S for the Symbol + // gallery) shows its tab first, so the control is drawn and takes the request. + keytips::Action::Control(id) => { + if let Some(tab) = gridcraft_engine::find_command(id).and_then(|c| c.menu.first()).filter(|t| **t != self.ui.ribbon_tab) { + self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); + self.ui.ribbon_collapsed = false; + self.ui.keytip_invoke_next = Some(id); + } else { + self.ui.keytip_invoke = Some(id); + } + } } } } diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index c3a343671..6c397f26c 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -378,7 +378,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("IW", Entry::Finish(Action::Command("home.insertSheet"))), // Excel: Insert > Worksheet; Legacy Excel 2003 menu path (#941). ("IF", Entry::Finish(Action::Dialog("insertFunction"))), // Excel: Insert > Function; Legacy Excel 2003 menu path (#941). ("IND", Entry::Finish(Action::Dialog("defineName"))), // Excel: Insert > Name > Define; Legacy Excel 2003 menu path (#941). - ("IS", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Insert > Symbol; Legacy Excel 2003 menu path (#941). + ("IS", Entry::Finish(Action::Control("insert.symbol"))), // Excel: Insert > Symbol; Legacy Excel 2003 menu path (#941). ("ED", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Edit > Delete; Legacy Excel 2003 menu path (#941). ("EM", Entry::Finish(Action::Dialog("moveSheet"))), // Excel: Edit > Move or Copy Sheet; Legacy Excel 2003 menu path (#941). ("EL", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Edit > Delete Sheet; Legacy Excel 2003 menu path (#941). diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 5c96d1d9f..174d519eb 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -2626,7 +2626,7 @@ }, { "seq": "IS", - "kind": "command", + "kind": "control", "target": "insert.symbol", "excel": "Insert > Symbol", "note": "Legacy Excel 2003 menu path (#941)." diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 95f861f14..a0637d966 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -102,6 +102,10 @@ pub struct UiState { /// [`keytips::Action::Control`], consumed (and cleared) by the control's render site. #[serde(skip)] pub keytip_invoke: Option<&'static str>, + /// A keytip control request for the next frame: its tab was switched to this frame, so its + /// control is drawn only then. + #[serde(skip)] + pub keytip_invoke_next: Option<&'static str>, /// Interface language. English by default (so tests and headless renders never depend on the /// host's locale); the desktop app starts from [`i18n::Language::system`] when `ui.json` has /// no usable language (see [`i18n::saved_language`]). An unknown saved value reads as English @@ -143,6 +147,7 @@ impl Default for UiState { autosave: false, recent: vec![], keytip_invoke: None, + keytip_invoke_next: None, language: i18n::Language::En, interface_scale: 100, } diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs index 595b74657..c347c896a 100644 --- a/crates/ui-egui/src/ribbon/insert.rs +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -213,7 +213,9 @@ pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) { }); let sym = big_button(ui, Icon::Symbol, "Symbol", "Symbol", true); control_keytip(app, ui, &sym, "insert.symbol"); - egui::Popup::menu(&sym).show(|ui| { + // Opened by its key tip too (Alt+I, S): the gallery picks the symbol, nothing is inserted before. + let forced = invoke(app, "insert.symbol"); + egui::Popup::menu(&sym).open_memory(popup_command(forced, sym.clicked())).show(|ui| { ui.set_width(260.0); ui.horizontal_wrapped(|ui| { for ch in [ diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 976e1fdbf..600a36319 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -2800,7 +2800,8 @@ fn keytip_ind_() { fn keytip_is_() { let mut h = harness(blank()); sequence(&mut h, "IS", false); - assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol"), "IS must journal insert.symbol"); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "IS must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); } #[test] diff --git a/crates/ui-egui/tests/it/legacy_keytips.rs b/crates/ui-egui/tests/it/legacy_keytips.rs new file mode 100644 index 000000000..fa6ca37ed --- /dev/null +++ b/crates/ui-egui/tests/it/legacy_keytips.rs @@ -0,0 +1,33 @@ +//! Excel 2003 menu access keys that open a ribbon control rather than run a command (#941). + +use egui::os::OperatingSystem; +use egui::{Event, Key, Modifiers}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +fn press(h: &mut egui_kittest::Harness<'static, SheetApp>, key: Key) { + let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + h.input_mut().events.extend([ev(true), Event::Text(key.name().to_ascii_lowercase()), ev(false)]); + h.run_steps(2); +} + +#[test] +fn alt_i_s_opens_the_symbol_gallery_and_leaves_the_cell_alone() { + // Insert > Symbol picks a symbol; running insert.symbol with no symbol set the active cell + // to its own text again, re-reading it (a Python cell lost its code, text could turn into a + // number). From the Home tab the keys show the Insert tab's gallery instead. + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "'007"})).unwrap(); + let mut h = crate::common::os_harness(s, OperatingSystem::Nix); + h.run_steps(4); + for key in [Key::F10, Key::I, Key::S] { + press(&mut h, key); + } + h.run_steps(4); + assert!(!h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol"), "nothing is inserted until a symbol is picked"); + assert_eq!(h.state().ui.ribbon_tab, "Insert"); + assert!(h.query_by_label("€").is_some(), "the Symbol gallery is open"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index f1240dcd6..eedef2148 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -63,6 +63,7 @@ mod interaction; mod interface_scale; mod keyboard_shortcuts; mod keytips_generated; +mod legacy_keytips; mod macro_editor; mod macros_ui; mod menu_hover; From 3c72d5f7aa09bbbf91042a46057991b3378952c1 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 03:05:18 -0600 Subject: [PATCH 44/80] #3224: Find and Replace within the selected range edit.find and edit.replace take within: selection, which searches only the selected cells; Find Next then moves the active cell through them and keeps the range selected, as Excel does. The Find and Replace dialog uses it whenever more than one cell is selected (Excel searches a selected range; one selected cell still means the whole sheet). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 4 +-- crates/engine/src/cmd/edit/find.rs | 13 +++++++-- crates/engine/src/cmd/edit/tests.rs | 28 ++++++++++++++++++++ crates/ui-egui/src/dialogs/find.rs | 11 +++++++- crates/ui-egui/tests/it/find_dialog_focus.rs | 24 +++++++++++++++++ docs/ui-parity.md | 1 + 6 files changed, 76 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2aa9d8fe3..a863fcc74 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -126,13 +126,13 @@ pub fn specs() -> Vec { fill_series ), cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Ctrl+E"), "{range?}", has_doc, flash_fill), - cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook}", has_doc, find), + cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook|selection} (selection: only the selected cells; Find Next keeps them selected and moves the active cell)", has_doc, find), cmd!( "edit.replace", "Replace…", ["Home", "Editing", "Find & Select"], Some("Ctrl+H"), - "{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)", + "{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook|selection} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)", has_doc, replace ), diff --git a/crates/engine/src/cmd/edit/find.rs b/crates/engine/src/cmd/edit/find.rs index 0d4656320..ddb9ecaf7 100644 --- a/crates/engine/src/cmd/edit/find.rs +++ b/crates/engine/src/cmd/edit/find.rs @@ -62,6 +62,8 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result { let by_cols = bool_param(p, "byColumns").unwrap_or(false); let all = bool_param(p, "all").unwrap_or(false); let workbook = str_param(p, "within") == Some("workbook"); + // `selection`: only the selected cells of the active sheet, as Excel searches a selected range. + let in_selection = str_param(p, "within") == Some("selection"); s.last_find = Some(p.clone()); let d = s.doc()?; let sheets: Vec = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] }; @@ -71,6 +73,7 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result { let mut cells: Vec<(CellRef, String)> = sh .cells .iter() + .filter(|(c, _)| !in_selection || d.selection.contains(*c)) .filter_map(|(c, cell)| { let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) { crate::display::cell_text(&d.wb, sh, c) @@ -100,7 +103,12 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result { let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) }; let dd = s.doc_mut()?; dd.wb_switch_sheet(si); - dd.selection = crate::Selection::at(c); + if in_selection { + // The range stays selected and the active cell moves through it, as in Excel. + dd.selection.active = c; + } else { + dd.selection = crate::Selection::at(c); + } Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()})) } @@ -114,6 +122,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result { let whole = bool_param(p, "wholeCell").unwrap_or(false); let all = bool_param(p, "all").unwrap_or(true); let workbook = str_param(p, "within") == Some("workbook"); + let selection = (str_param(p, "within") == Some("selection")).then(|| s.doc().map(|d| d.selection.clone())).transpose()?; let active = s.doc()?.selection.active; let mut skipped = 0usize; let n = edit(s, |cx| { @@ -123,7 +132,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result { let cells: Vec<(CellRef, String)> = cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default(); for (c, text) in cells { - if !all && c != active { + if (!all && c != active) || selection.as_ref().is_some_and(|sel| !sel.contains(c)) { continue; } if !matches(&text, &what, case, whole) { diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 3f3a2fb6d..10ee22bbe 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -371,3 +371,31 @@ fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() { let sh = s.doc().unwrap().wb.active().unwrap(); assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text)); } + +/// `within: selection` searches only the selected cells, as Excel does with a range selected +/// (#3224): Find All lists them, Find Next moves the active cell through them and keeps the range +/// selected, and Replace changes only them. +#[test] +fn find_and_replace_within_the_selection() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1:B4", "values": [["pear", "pear"], ["plum", "pear"], ["pear", "fig"], ["pear", "pear"]]})) + .unwrap(); + s.execute("selection.set", json!({"range": "B1:B4"})).unwrap(); + let all = s.execute("edit.find", json!({"what": "pear", "all": true, "within": "selection"})).unwrap(); + let cells: Vec<&str> = all["results"].as_array().unwrap().iter().filter_map(|r| r["cell"].as_str()).collect(); + assert_eq!(cells, ["B1", "B2", "B4"], "only column B"); + assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B2"); + assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B4"); + assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B1", "wraps inside the range"); + let sel = &s.doc().unwrap().selection; + assert_eq!((sel.a1(), sel.active.a1()), ("B1:B4".to_string(), "B1".to_string()), "the range stays selected"); + assert!(s.execute("edit.find", json!({"what": "plum", "within": "selection"})).is_err(), "A2 is outside the range"); + assert_eq!(s.execute("edit.replace", json!({"what": "pear", "with": "kiwi", "within": "selection"})).unwrap()["replaced"], 3); + let sh = s.doc().unwrap().wb.active().unwrap(); + let at = |a: &str| sh.value(CellRef::parse(a).unwrap()); + assert_eq!((at("A1"), at("B1"), at("B4"), at("A4")), (Value::text("pear"), Value::text("kiwi"), Value::text("kiwi"), Value::text("pear"))); + // The sheet scope is unchanged. + let all = s.execute("edit.find", json!({"what": "pear", "all": true})).unwrap(); + assert_eq!(all["count"], 3, "A1, A3 and A4 on the whole sheet"); +} diff --git a/crates/ui-egui/src/dialogs/find.rs b/crates/ui-egui/src/dialogs/find.rs index c1401d554..fe7f8ad6c 100644 --- a/crates/ui-egui/src/dialogs/find.rs +++ b/crates/ui-egui/src/dialogs/find.rs @@ -40,7 +40,16 @@ pub(super) fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { d.values.insert("matchCase".into(), json!(case)); d.values.insert("wholeCell".into(), json!(whole)); d.values.insert("within".into(), json!(if wb { "workbook" } else { "sheet" })); - let base = json!({"what": what, "matchCase": case, "wholeCell": whole, "within": if wb { "workbook" } else { "sheet" }}); + // As in Excel, a selected range (more than one cell) limits the search on the sheet to it. + let range_selected = app.session.active().is_some_and(|doc| !doc.selection.is_single_cell()); + let within = if wb { + "workbook" + } else if range_selected { + "selection" + } else { + "sheet" + }; + let base = json!({"what": what, "matchCase": case, "wholeCell": whole, "within": within}); ui.horizontal(|ui| { if d.tab == "Replace" { if ui.button("Replace All").clicked() { diff --git a/crates/ui-egui/tests/it/find_dialog_focus.rs b/crates/ui-egui/tests/it/find_dialog_focus.rs index a774a431b..a66bb7acb 100644 --- a/crates/ui-egui/tests/it/find_dialog_focus.rs +++ b/crates/ui-egui/tests/it/find_dialog_focus.rs @@ -34,3 +34,27 @@ fn typing_right_after_ctrl_f_fills_find_what() { fn typing_right_after_ctrl_h_fills_find_what() { assert_eq!(type_after_shortcut(Key::H), Some(json!("abc"))); } + +/// With a range selected, Find All in the dialog lists only the matches inside it, as in Excel +/// (#3224); with one cell selected it searches the whole sheet. +#[test] +fn find_all_with_a_range_selected_searches_only_the_range() { + use egui_kittest::kittest::Queryable; + for (range, expected) in [("B1:B3", 2), ("C5", 4)] { + let mut h = harness(); + let app = h.state_mut(); + app.session.execute("range.setValues", json!({"range": "A1:B3", "values": [["pear", "pear"], ["fig", "fig"], ["pear", "pear"]]})).unwrap(); + app.session.execute("selection.set", json!({"range": range})).unwrap(); + h.run_steps(2); + h.input_mut().events.push(Event::Key { key: Key::F, physical_key: Some(Key::F), pressed: true, repeat: false, modifiers: CTRL }); + h.input_mut().events.push(Event::Key { key: Key::F, physical_key: Some(Key::F), pressed: false, repeat: false, modifiers: CTRL }); + h.run_steps(6); + h.input_mut().events.push(Event::Text("pear".into())); + h.run_steps(4); + h.get_by_label("Find All").click(); + h.run_steps(4); + let d = h.state().dialog.as_ref().expect("find dialog is open"); + let count = d.result.as_ref().map(|r| r["count"].clone()); + assert_eq!(count, Some(json!(expected)), "{range} selected"); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..95da1794c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Find and Replace with a range selected searches only that range, as in Excel: Find Next moves the active cell through it and keeps it selected, Find All lists its matches, Replace and Replace All change only them; one selected cell still searches the sheet. `edit.find` / `edit.replace` take `within: selection` (#3224) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From 9ce599d978d8bebc2361e9ca59d89704ec326d39 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 03:25:52 -0600 Subject: [PATCH 45/80] #941: a control key tip expands a collapsed ribbon before it opens the control Addresses CodeRabbit review on #3250, "Expand the ribbon for a same-tab control request": with the Insert tab chosen but the ribbon collapsed, Alt+I, S went to the same-tab branch and the Symbol gallery was never drawn. A collapsed ribbon is now expanded like a tab switch, and the request waits for the frame that draws the control. Test: legacy_keytips::alt_i_s_expands_a_collapsed_ribbon_on_the_insert_tab. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/keys.rs | 8 ++++++-- crates/ui-egui/tests/it/legacy_keytips.rs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index b490d8557..1facc5a4f 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -63,8 +63,12 @@ impl SheetApp { // A control on another tab (a legacy menu key such as Alt+I, S for the Symbol // gallery) shows its tab first, so the control is drawn and takes the request. keytips::Action::Control(id) => { - if let Some(tab) = gridcraft_engine::find_command(id).and_then(|c| c.menu.first()).filter(|t| **t != self.ui.ribbon_tab) { - self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); + // A collapsed ribbon draws no controls either: it is expanded the same way. + let other_tab = gridcraft_engine::find_command(id).and_then(|c| c.menu.first()).filter(|t| **t != self.ui.ribbon_tab); + if other_tab.is_some() || self.ui.ribbon_collapsed { + if let Some(tab) = other_tab { + self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); + } self.ui.ribbon_collapsed = false; self.ui.keytip_invoke_next = Some(id); } else { diff --git a/crates/ui-egui/tests/it/legacy_keytips.rs b/crates/ui-egui/tests/it/legacy_keytips.rs index fa6ca37ed..c2490f2bf 100644 --- a/crates/ui-egui/tests/it/legacy_keytips.rs +++ b/crates/ui-egui/tests/it/legacy_keytips.rs @@ -31,3 +31,22 @@ fn alt_i_s_opens_the_symbol_gallery_and_leaves_the_cell_alone() { assert_eq!(h.state().ui.ribbon_tab, "Insert"); assert!(h.query_by_label("€").is_some(), "the Symbol gallery is open"); } + +#[test] +fn alt_i_s_expands_a_collapsed_ribbon_on_the_insert_tab() { + // With the Insert tab already chosen but the ribbon collapsed, the gallery's control isn't + // drawn: the keys expand the ribbon first. + let mut s = Session::new(); + s.new_workbook(); + let mut h = crate::common::os_harness(s, OperatingSystem::Nix); + h.state_mut().run("ui.ribbonTab", json!({"tab": "Insert"})).unwrap(); + h.state_mut().ui.ribbon_collapsed = true; + h.run_steps(4); + for key in [Key::F10, Key::I, Key::S] { + press(&mut h, key); + } + h.run_steps(4); + assert!(!h.state().ui.ribbon_collapsed); + assert!(h.query_by_label("€").is_some(), "the Symbol gallery is open"); + assert!(!h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol")); +} From d964c1cb661fb2a2ca38672d13d2259825c47cfa Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 03:39:43 -0600 Subject: [PATCH 46/80] #941: a waiting key tip request stays with the window that made it Addresses CodeRabbit review on #3250, "Keep deferred requests with their window": the request waiting for the next frame was shared across windows, so the root window, drawn first, could take a second window's Alt+I, S. It is now parked with each window's interface state and moved into the one-frame request when that window's ui begins. Test: legacy_keytips::a_waiting_key_tip_request_stays_with_its_window. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app.rs | 8 ++++++-- crates/ui-egui/src/window_ui.rs | 4 ++++ crates/ui-egui/tests/it/legacy_keytips.rs | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 762a8c704..94b6b01e7 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -108,8 +108,9 @@ impl SheetApp { self.ensure_text_width(ctx); // A keytip control request lives one frame: the ribbon consumes it during `ui`, and // anything that never reached a control (collapsed ribbon, tab switch) is dropped. One - // made with a switch to the control's tab waits for the frame that draws that tab. - self.ui.keytip_invoke = self.ui.keytip_invoke_next.take(); + // made with a switch to the control's tab waits, with its window, for the next `ui` of + // that window. + self.ui.keytip_invoke = None; // Floating pictures' textures go with their workbook, even when no sheet is painted. let open: Vec = self.session.documents().iter().map(|d| d.uid).collect(); floating_pictures::forget_closed(ctx, &open); @@ -195,6 +196,9 @@ impl SheetApp { let t0 = now_ms(); // A script may have switched windows since the last frame. self.follow_active_window(); + // This window's key tip request that waited for this frame (its tab or ribbon was just + // shown); any other window's request was dropped before it. + self.ui.keytip_invoke = self.ui.keytip_invoke_next.take(); text_box::before_ui(self, &ctx); // Another window came forward without going through a command (which settles drafts // first): the draft goes into its own window instead of floating over this one. If its diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index cef736ecc..93f2c699a 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -31,6 +31,8 @@ pub struct WindowUi { ribbon_tab: String, /// The status bar's Ready/Enter/Edit, which follows the window's cell draft. mode: gridcraft_engine::Mode, + /// A key tip's control request waiting for the frame that draws this window's ribbon. + keytip_invoke_next: Option<&'static str>, } impl WindowUi { @@ -70,6 +72,7 @@ impl SheetApp { open_switch_windows, ribbon_tab, mode, + keytip_invoke_next, } = w; std::mem::swap(&mut self.editor, editor); std::mem::swap(&mut self.text_box_editor, text_box_editor); @@ -86,6 +89,7 @@ impl SheetApp { std::mem::swap(&mut self.open_switch_windows, open_switch_windows); std::mem::swap(&mut self.ui.ribbon_tab, ribbon_tab); std::mem::swap(&mut self.session.mode, mode); + std::mem::swap(&mut self.ui.keytip_invoke_next, keytip_invoke_next); } /// The window whose interface state is in the per-window fields. diff --git a/crates/ui-egui/tests/it/legacy_keytips.rs b/crates/ui-egui/tests/it/legacy_keytips.rs index c2490f2bf..57deea0aa 100644 --- a/crates/ui-egui/tests/it/legacy_keytips.rs +++ b/crates/ui-egui/tests/it/legacy_keytips.rs @@ -50,3 +50,22 @@ fn alt_i_s_expands_a_collapsed_ribbon_on_the_insert_tab() { assert!(h.query_by_label("€").is_some(), "the Symbol gallery is open"); assert!(!h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol")); } + +#[test] +fn a_waiting_key_tip_request_stays_with_its_window() { + // A request waiting for the frame that draws its control belongs to the window whose keys + // made it: another window drawn first must not take it. + let mut s = Session::new(); + s.new_workbook(); + let mut h = crate::common::os_harness(s, OperatingSystem::Nix); + h.run_steps(2); + let first = h.state().session.active_window().unwrap(); + h.state_mut().run("view.newWindow", json!({})).unwrap(); + let second = h.state().session.active_window().unwrap(); + h.state_mut().show_window(second); + h.state_mut().ui.keytip_invoke_next = Some("insert.symbol"); + h.state_mut().show_window(first); + assert_eq!(h.state().ui.keytip_invoke_next, None, "the first window doesn't see the second window's request"); + h.state_mut().show_window(second); + assert_eq!(h.state().ui.keytip_invoke_next, Some("insert.symbol")); +} From 2adfd5571fd5d4999f1a68080dd70ae3dc34cd14 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 03:58:23 -0600 Subject: [PATCH 47/80] #1786: ink is translucent by its width in points, while drawn and once committed Addresses CodeRabbit review on #3229, "Use the same translucency threshold as committed ink": the preview judged the pen by its width in points, the committed stroke by its width on screen, so at 50% a 12 pt highlighter turned opaque once committed and at 200% a 6 pt pen turned translucent. Both now use chartview::ink_color, which goes by the stroke's width in points, so the two agree and neither changes with the zoom. Test: ink_render::ink_translucency_follows_the_pen_not_the_zoom. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/chartview.rs | 11 +++++-- crates/ui-egui/src/grid/interact.rs | 2 +- crates/ui-egui/tests/it/ink_render.rs | 45 +++++++++++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 4 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/it/ink_render.rs diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index 5c51d087b..40e3f0aa4 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -316,11 +316,12 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo p.add(PathShape::convex_polygon(vec![pos2(r.center().x, r.top()), r.right_bottom(), r.left_bottom()], fill, Stroke::new(1.0, line))); } ShapeKind::Ink => { - let w = gridcraft_engine::cmd::draw::ink_width(sp) * geo.z; + let pt = gridcraft_engine::cmd::draw::ink_width(sp); + let w = pt * geo.z; let pts: Vec = gridcraft_engine::cmd::draw::ink_points(sp).iter().map(|q| pos2(r.left() + q[0] * geo.z, r.top() + q[1] * geo.z)).collect(); if pts.len() >= 2 { - let col = if w > 8.0 { line.gamma_multiply(0.45) } else { line }; + let col = ink_color(line, pt); p.add(PathShape::line(pts, PathStroke::new(w, col))); } label = false; @@ -556,3 +557,9 @@ pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Re } None } + +/// The colour ink is painted in: a highlighter (a stroke over 8 pt wide) is translucent, by its +/// width in points, so the live pen stroke and the committed ink agree at every zoom. +pub(crate) fn ink_color(line: Color32, width_pt: f32) -> Color32 { + if width_pt > 8.0 { line.gamma_multiply(0.45) } else { line } +} diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index a94f08e6d..f7442fca4 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -257,7 +257,7 @@ pub(crate) fn pen_ink(color: &str, width: f32, theme: &gridcraft_engine::model:: let line = gridcraft_engine::model::Color::from_hex(color).unwrap_or(pen_blue); let width = if width <= 0.0 { 2.0 } else { width }.clamp(0.5, 40.0).round().max(1.0); let color = line.resolve(theme).map(crate::theme::color32).unwrap_or(Color32::from_rgb(0x1F, 0x5F, 0xC9)); - (if width > 8.0 { color.gamma_multiply(0.45) } else { color }, width) + (crate::chartview::ink_color(color, width), width) } fn handle_header_autofit(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool { diff --git a/crates/ui-egui/tests/it/ink_render.rs b/crates/ui-egui/tests/it/ink_render.rs new file mode 100644 index 000000000..60ff42a4e --- /dev/null +++ b/crates/ui-egui/tests/it/ink_render.rs @@ -0,0 +1,45 @@ +//! Committed ink is painted as the pen previews it, at any zoom. + +use egui::Shape; +use egui::epaint::ColorMode; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +/// The colour of the ink stroke drawn from (40, 40) to (200, 40) sheet points, `width` points +/// wide: the two-point horizontal path whose width is `width` at its own scale (160 pt long). +fn painted_ink(h: &egui_kittest::Harness<'static, SheetApp>, width: f32) -> Option { + h.output().shapes.iter().find_map(|s| match &s.shape { + Shape::Path(p) if p.points.len() == 2 && (p.points[0].y - p.points[1].y).abs() < 0.01 => { + let z = (p.points[1].x - p.points[0].x).abs() / 160.0; + match p.stroke.color { + ColorMode::Solid(c) if z > 0.0 && (p.stroke.width - width * z).abs() < 0.01 => Some(c), + _ => None, + } + } + _ => None, + }) +} + +#[test] +fn ink_translucency_follows_the_pen_not_the_zoom() { + // A highlighter (over 8 pt) is translucent and a pen isn't, whatever the zoom: the committed + // stroke used its width on screen, so at 50% a 12 pt highlighter turned opaque and at 200% a + // 6 pt pen turned translucent, unlike the pen's preview. + for (width, zoom, translucent) in [(12.0_f32, 50, true), (6.0, 200, false), (12.0, 100, true), (6.0, 100, false)] { + let mut s = Session::new(); + s.new_workbook(); + s.execute("view.zoom", json!({"percent": zoom})).unwrap(); + s.execute("draw.stroke", json!({"points": [[40, 40], [200, 40]], "color": "#FFFF00", "width": width})).unwrap(); + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + crate::common::app(s), + ); + h.run_steps(4); + let c = painted_ink(&h, width).unwrap_or_else(|| panic!("the {width} pt stroke is painted at {zoom}%")); + assert_eq!(c.a() < 255, translucent, "{width} pt at {zoom}%: {c:?}"); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index f1240dcd6..4608f843c 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -58,6 +58,7 @@ mod hyperlink_ctrl_click; mod hyperlink_dialog; mod in_cell_editor_size; mod in_cell_editor_wrap; +mod ink_render; mod insert_copied_cells; mod interaction; mod interface_scale; From 68e87f0a6863c21c4669cc2bf3143def1150459a Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 04:01:25 -0600 Subject: [PATCH 48/80] #941: a hidden window drops its waiting key tip request Addresses CodeRabbit review on #3250, "Clear deferred key-tip requests when parking a hidden window": a hidden window isn't drawn, so a request it parked would fire whenever it was shown again (the Symbol gallery opening long after the keys). Parking a hidden window now drops it; the rest of its interface state is kept. Test: legacy_keytips::a_hidden_window_drops_its_waiting_key_tip_request. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/window_ui.rs | 7 ++++++- crates/ui-egui/tests/it/legacy_keytips.rs | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index 93f2c699a..e3f07715b 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -112,7 +112,12 @@ impl SheetApp { }; let mut outgoing = WindowUi::fresh(&self.grid, &self.ui.ribbon_tab); self.swap_window_ui(&mut outgoing); - if self.session.window(previous).is_some() { + if let Some(window) = self.session.window(previous) { + // A hidden window isn't drawn: a key tip request waiting for its next frame would + // fire whenever it is shown again, so it goes. + if window.hidden { + outgoing.keytip_invoke_next = None; + } self.parked.insert(previous, outgoing); } if let Some(mut incoming) = active.and_then(|w| self.parked.remove(&w)) { diff --git a/crates/ui-egui/tests/it/legacy_keytips.rs b/crates/ui-egui/tests/it/legacy_keytips.rs index 57deea0aa..c75a6a087 100644 --- a/crates/ui-egui/tests/it/legacy_keytips.rs +++ b/crates/ui-egui/tests/it/legacy_keytips.rs @@ -69,3 +69,23 @@ fn a_waiting_key_tip_request_stays_with_its_window() { h.state_mut().show_window(second); assert_eq!(h.state().ui.keytip_invoke_next, Some("insert.symbol")); } + +#[test] +fn a_hidden_window_drops_its_waiting_key_tip_request() { + // A hidden window isn't drawn, so its waiting request would fire when it was shown again, + // long after the keys: hiding the window drops it. + let mut s = Session::new(); + s.new_workbook(); + let mut h = crate::common::os_harness(s, OperatingSystem::Nix); + h.run_steps(2); + let first = h.state().session.active_window().unwrap(); + h.state_mut().run("view.newWindow", json!({})).unwrap(); + let second = h.state().session.active_window().unwrap(); + h.state_mut().show_window(second); + h.state_mut().ui.keytip_invoke_next = Some("insert.symbol"); + h.state_mut().run("view.hideWindow", json!({"window": second})).unwrap(); + h.state_mut().show_window(first); + h.state_mut().run("view.unhideWindow", json!({"window": second})).unwrap(); + h.state_mut().show_window(second); + assert_eq!(h.state().ui.keytip_invoke_next, None); +} From 533c0dadafe4eefe246d0a3e0355bbd688698b39 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 04:19:49 -0600 Subject: [PATCH 49/80] #2225: a field that only looks quoted keeps the tabs and line breaks in it Addresses CodeRabbit review on #3231, "Reparse consumed separators when a field is not quoted": parse_tsv read "ab"x as one quoted field, then fell back to its raw text without the separator it had consumed, so the cells "a, b"x and c pasted back as two. The new copy (which no longer quotes a cell holding only a quote) makes that text. A field whose closing quote doesn't end it, or that never closes, is now read again as unquoted text, so its tabs and line breaks separate cells. The text read again is bounded by the input's length, so hostile input stays linear. Test: a_field_that_is_not_really_quoted_keeps_its_separators. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit/clipboard.rs | 44 +++++++++++++++++++++---- crates/engine/src/cmd/edit/tests.rs | 22 +++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index 1490b082c..0cd814ba0 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -226,24 +226,54 @@ pub(crate) fn parse_tsv(text: &str) -> Vec> { let mut row = Vec::new(); let mut field = String::new(); let mut chars = text.chars().peekable(); + // Text read again as unquoted, ahead of the rest of the input: a field that opened with a + // quote but turned out not to be quoted. + let mut replay: std::collections::VecDeque = std::collections::VecDeque::new(); + // Text is replayed at most as much as the input is long; past that, quotes are text, so + // hostile input full of fields that only look quoted reads in linear time. + let mut replay_budget = text.len(); let mut quoted = false; let mut at_start = true; // A quoted field's text as it stands in the input, for a field that turns out not to be one. let mut raw = String::new(); - while let Some(c) = chars.next() { + loop { + let c = match replay.pop_front().or_else(|| chars.next()) { + Some(c) => c, + // A quote that never closes: the field is its text as written. + None if quoted => { + quoted = false; + field.clear(); + field.push('"'); + replay_budget = replay_budget.saturating_sub(raw.len()); + replay.extend(raw.chars().skip(1)); + raw.clear(); + continue; + } + None => break, + }; if quoted { raw.push(c); if c == '"' { - if chars.peek() == Some(&'"') { + let next = replay.front().copied().or_else(|| chars.peek().copied()); + if next == Some('"') { field.push('"'); raw.push('"'); - chars.next(); + if replay.pop_front().is_none() { + chars.next(); + } } else { quoted = false; // A field is quoted only when its closing quote ends it, as Excel reads it: - // `"hi" there` is that text, quotes included. - if !matches!(chars.peek(), None | Some('\t' | '\n' | '\r')) { - field = std::mem::take(&mut raw); + // `"hi" there` is that text, quotes included, and a tab or line break inside + // it still ends the cell, as if the leading quote weren't there. + if !matches!(next, None | Some('\t' | '\n' | '\r')) { + field.clear(); + field.push('"'); + replay_budget = replay_budget.saturating_sub(raw.len()); + let rest: Vec = std::mem::take(&mut raw).chars().skip(1).collect(); + for ch in rest.into_iter().rev() { + replay.push_front(ch); + } } } } else { @@ -252,7 +282,7 @@ pub(crate) fn parse_tsv(text: &str) -> Vec> { continue; } match c { - '"' if at_start => { + '"' if at_start && replay_budget > 0 => { quoted = true; at_start = false; raw = String::from('"'); diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 5034646cd..020592c56 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -390,3 +390,25 @@ fn copy_quotes_only_cells_with_tabs_or_line_breaks() { let cells: Vec<&str> = rows.iter().map(|r| r[0].as_str()).collect(); assert_eq!(cells, ["say \"hi\"", "plain", "\"hi\" there", "x \"q\"\ny", "tab\tinside"]); } + +#[test] +fn a_field_that_is_not_really_quoted_keeps_its_separators() { + // A field opening with a quote is quoted only if a closing quote ends it; when it doesn't (or + // never closes), the text is read as written, and the tabs and line breaks inside it still + // separate cells, as they would have without the leading quote. + assert_eq!(parse_tsv("\"a\tb\"x\tc"), vec![vec!["\"a", "b\"x", "c"]]); + assert_eq!(parse_tsv("\"a\tb\nc"), vec![vec!["\"a", "b"], vec!["c"]]); + assert_eq!(parse_tsv("\"a\tb\tc\n"), vec![vec!["\"a", "b", "c"]]); + // Cells copied without quotes (they hold no tab or line break) read back as the same cells. + let mut s = Session::new(); + s.new_workbook(); + for (cell, input) in [("A1", "'\"a"), ("B1", "b\"x"), ("C1", "c")] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + let text = s.execute("edit.copy", json!({"range": "A1:C1"})).unwrap()["text"].as_str().unwrap().to_string(); + assert_eq!(parse_tsv(&text), vec![vec!["\"a", "b\"x", "c"]], "{text:?}"); + // Many fields that only look quoted: each is replayed at most once (the replay budget is the + // input's length), and every one is still its own cell. + let hostile = "\"x\t".repeat(50_000); + assert_eq!(parse_tsv(&hostile).first().map(Vec::len), Some(50_001)); +} From 2bdec6c0a11e9d9ef8cfe91ed6618aafc2be9486 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 04:44:40 -0600 Subject: [PATCH 50/80] #941: a window hidden while parked drops its key tip request; parity row names the badge-less keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit review on #3250: - "Clear parked keytip requests when their window becomes hidden": a window hidden from another window (View › Hide while it isn't in front) kept the request it had parked, and showing it again opened the control. follow_active_window now drops the waiting request of every parked window that is hidden. - "Correct the no-badge description in the parity table": only Alt+I, D, V and T (no ribbon tab) paint no badges. Test: legacy_keytips::a_window_hidden_from_another_window_drops_its_parked_request. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/window_ui.rs | 8 ++++++++ crates/ui-egui/tests/it/legacy_keytips.rs | 21 +++++++++++++++++++++ docs/ui-parity.md | 2 +- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index e3f07715b..d68b7e3d8 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -102,6 +102,14 @@ impl SheetApp { /// in (a window never seen before starts fresh). A closed window's state is dropped. pub fn follow_active_window(&mut self) { let active = self.session.active_window(); + // A window hidden while parked (from another window's View › Hide) isn't drawn either: + // its waiting key tip request goes, as it does for a window hidden as it is left. + let session = &self.session; + for (id, parked) in &mut self.parked { + if session.window(*id).is_some_and(|w| w.hidden) { + parked.keytip_invoke_next = None; + } + } if active == self.ui_window { return; } diff --git a/crates/ui-egui/tests/it/legacy_keytips.rs b/crates/ui-egui/tests/it/legacy_keytips.rs index c75a6a087..885c70f8f 100644 --- a/crates/ui-egui/tests/it/legacy_keytips.rs +++ b/crates/ui-egui/tests/it/legacy_keytips.rs @@ -89,3 +89,24 @@ fn a_hidden_window_drops_its_waiting_key_tip_request() { h.state_mut().show_window(second); assert_eq!(h.state().ui.keytip_invoke_next, None); } + +#[test] +fn a_window_hidden_from_another_window_drops_its_parked_request() { + // Hidden while another window is in front (its request already parked), the window still + // loses the request, so showing it again doesn't open the control. + let mut s = Session::new(); + s.new_workbook(); + let mut h = crate::common::os_harness(s, OperatingSystem::Nix); + h.run_steps(2); + let first = h.state().session.active_window().unwrap(); + h.state_mut().run("view.newWindow", json!({})).unwrap(); + let second = h.state().session.active_window().unwrap(); + h.state_mut().show_window(second); + h.state_mut().ui.keytip_invoke_next = Some("insert.symbol"); + h.state_mut().enter_window(first); + h.state_mut().run("view.hideWindow", json!({"window": second})).unwrap(); + h.state_mut().show_window(first); + h.state_mut().run("view.unhideWindow", json!({"window": second})).unwrap(); + h.state_mut().show_window(second); + assert_eq!(h.state().ui.keytip_invoke_next, None); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3d51e0e65..e9d98725c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -21,7 +21,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Formulas › Function Library | Insert Function, AutoSum, Recently Used and a menu per function category | yes (10-24): a big Insert Function button and three columns of labelled small buttons, the layout Excel uses when the window is not wide enough for a big button per category; each category's book icon has its own colour; a category menu shows each function's description on hover and ends with Insert Function…, which opens the dialog on that category; More Functions cascades into Statistical, Engineering, Cube, Information, Compatibility and Web; AutoSum is a split button whose menu (Sum, Average, Count Numbers, Max, Min, More Functions…) Home › Editing shares; Recently Used has the MR key tip | the wide layout with a big button per category is not offered; a function counts as recently used once its formula is entered, not when Insert Function's OK is pressed | | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | -| Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus the Excel 2003 menu keys (Alt+I, E, O, D, V, T and Alt+F, U, such as Alt+I, R or Alt+D, S), which paint no badges, as in Excel (#941); other tabs and controls not yet (#33, #49) | 3–5 h | +| Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus the Excel 2003 menu keys (Alt+I, E, O, D, V, T and Alt+F, U, such as Alt+I, R or Alt+D, S); Alt+I, D, V and T, which have no ribbon tab, paint no badges, as in Excel (#941); other tabs and controls not yet (#33, #49) | 3–5 h | | Formula bar with Name Box, expand, function hints | yes | yes; grows with its content from one to five lines (Alt+Enter, wrapped text, or viewing a multi-line cell), then scrolls with the caret in view, the Name Box and buttons staying on the first line; the chevron pins it at five; the in-cell editor shows every line | done; no drag-to-resize handle | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | Ctrl-drag (Cmd or Option on macOS) drops a copy (#971); active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Formula bar with Name Box, expand, function hints | yes | yes | done; dynamic-array spill children show the source formula muted and read-only, using current spill ownership | From fe6ab7c5c06d5d552faf93b38c4518b4b6d15756 Mon Sep 17 00:00:00 2001 From: Hector Viov Date: Tue, 3 Nov 2026 05:02:44 -0600 Subject: [PATCH 51/80] #3101: clear the tab bar's cached rectangles when a control is hidden Addresses review comment 4453674677: after a frame drew both controls, turning off the horizontal scroll bar or the sheet tabs left app.grid.hscroll / app.grid.tab_split holding the old rectangles. sheet_tabs now clears both before any visibility return. Test: tab_split::hiding_the_controls_clears_their_cached_rectangles toggles both options on one live harness. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tabs.rs | 3 +++ crates/ui-egui/tests/it/tab_split.rs | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 6147b2e14..ea11615d0 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -100,6 +100,9 @@ fn tab_split_handle(app: &mut SheetApp, ui: &mut Ui, split: Rect, bar: (f32, f32 /// Draws the worksheet tab strip: navigation, scroll/reveal, rename/drag/move with optional copy, context menu, new-sheet button, split handle and grid scrollbar. pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + // Only the controls drawn this frame have a rectangle: a hidden one keeps no stale geometry. + app.grid.hscroll = None; + app.grid.tab_split = None; // File › Options › Advanced › Display options for this workbook (#3101): with neither the // sheet tabs nor the horizontal scroll bar, the bar isn't there at all. let view = app.session.active().map(|d| d.wb.view).unwrap_or_default(); diff --git a/crates/ui-egui/tests/it/tab_split.rs b/crates/ui-egui/tests/it/tab_split.rs index 7189358ac..c2dabc2fe 100644 --- a/crates/ui-egui/tests/it/tab_split.rs +++ b/crates/ui-egui/tests/it/tab_split.rs @@ -197,3 +197,24 @@ fn display_options_hide_the_sheet_tabs_and_the_horizontal_scroll_bar() { h.run_steps(5); assert!(!shows_tab_name(&h) && h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no bar at all"); } + +#[test] +fn hiding_the_controls_clears_their_cached_rectangles() { + // Turned off on a live window, the controls drop the rectangles of the frame that drew them. + let mut h = harness(900.0, 1); + assert!(h.state().grid.tab_split.is_some() && h.state().grid.hscroll.is_some(), "both controls drawn first"); + + h.state_mut().run("view.showSheetTabs", json!({"on": false})).unwrap(); + h.run_steps(5); + assert!(h.state().grid.tab_split.is_none(), "no split handle once the tabs are hidden"); + assert!(h.state().grid.hscroll.is_some(), "the scroll bar stays"); + + h.state_mut().run("view.showSheetTabs", json!({"on": true})).unwrap(); + h.state_mut().run("view.showHorizontalScrollBar", json!({"on": false})).unwrap(); + h.run_steps(5); + assert!(h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no scroll bar and no split handle"); + + h.state_mut().run("view.showSheetTabs", json!({"on": false})).unwrap(); + h.run_steps(5); + assert!(h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no bar at all"); +} From 92cdc2a975b6e014da6ff25477232b500e3c1cf6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 06:45:15 -0500 Subject: [PATCH 52/80] xlsx: rename repeated table headers without retrying handed-out suffixes (#3213) table_columns restarted its suffix search at 2 for every duplicate header, lowercasing and hashing every candidate already in use, so a table of repeated headers was exported in quadratic time. The next suffix to try is now remembered per lowercased base. --- crates/xlsx/src/sheet_write/parts.rs | 57 +++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/crates/xlsx/src/sheet_write/parts.rs b/crates/xlsx/src/sheet_write/parts.rs index 2e40d0b26..040e2b9bd 100644 --- a/crates/xlsx/src/sheet_write/parts.rs +++ b/crates/xlsx/src/sheet_write/parts.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use super::*; /// Valid table name: letters, digits, `_` and `.`, starting with a letter, `_` or `\` @@ -21,16 +23,24 @@ pub(super) fn table_name(name: &str, used: &mut HashSet, n: u32) -> Stri pub(super) fn table_columns(t: &Table) -> Vec { let w = t.range.width() as usize; let mut used = HashSet::new(); + // The next suffix to try per lowercased base, so repeated headers don't retry every suffix + // already handed out. + let mut next: HashMap = HashMap::new(); (0..w) .map(|i| { let base = t.columns.get(i).map(|c| c.name.clone()).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("Column{}", i + 1)); - let mut name = base.clone(); - let mut k = 2; - while !used.insert(name.to_lowercase()) { - name = format!("{base}{k}"); - k += 1; + let lower = base.to_lowercase(); + if used.insert(lower.clone()) { + return base; + } + let k = next.entry(lower).or_insert(2); + loop { + let name = format!("{base}{k}"); + *k += 1; + if used.insert(name.to_lowercase()) { + return name; + } } - name }) .collect() } @@ -371,3 +381,38 @@ pub(super) fn vml_xml(sheet: &Sheet, n: u32) -> String { s.push_str(""); s } + +#[cfg(test)] +mod tests { + use gridcraft_model::TableColumn; + + use super::*; + + #[test] + fn repeated_headers_are_renamed_without_retrying_suffixes() { + let col = |n: &str| TableColumn { name: n.into(), totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None }; + let mut columns: Vec = (0..10_000).map(|_| col("Total")).collect(); + columns[1] = col("total3"); + let t = Table { + id: 1, + name: "T".into(), + range: RangeRef::parse("A1:NTP2").unwrap(), + header_row: true, + totals_row: false, + columns, + style: String::new(), + banded_rows: false, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: false, + filter: vec![], + table_type: None, + connection_id: None, + }; + let names = table_columns(&t); + assert_eq!(names.len(), 10_000); + assert_eq!(&names[..4], ["Total", "total3", "Total2", "Total4"]); + assert_eq!(names.iter().map(|n| n.to_lowercase()).collect::>().len(), 10_000); + } +} From f4f69beec237c6cf7c7ba3a6675e17ed6a2f2065 Mon Sep 17 00:00:00 2001 From: Daniele Mori Date: Tue, 3 Nov 2026 13:45:53 +0100 Subject: [PATCH 53/80] Point mode: dragging over row or column headers extends the reference A press on a header while a formula waits for a reference inserted C:C, but the drag that followed did nothing, so dragging from column C to E left C:C. The drag now follows the pointer, as it does over cells, in either direction and for rows too, keeping the sheet qualifier, the earlier arguments of a Ctrl press and the far end of a Shift press. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 4 + crates/ui-egui/src/grid/cursor.rs | 2 +- crates/ui-egui/src/grid/interact.rs | 62 ++++++++++++- .../ui-egui/tests/it/formula_point_headers.rs | 89 +++++++++++++++++++ docs/ui-parity.md | 5 +- 5 files changed, 155 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 5a6dd3978..5dd7246b9 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -101,6 +101,10 @@ pub enum Drag { Select, Rows(u32), Cols(u32), + /// Point mode over the row headers: the formula's reference runs from this row to the pointer. + PointRows(u32), + /// Point mode over the column headers: the formula's reference runs from this column to the pointer. + PointCols(u32), /// Reordering one contiguous whole-column selection by its header. MoveCols { source: RangeRef, diff --git a/crates/ui-egui/src/grid/cursor.rs b/crates/ui-egui/src/grid/cursor.rs index f93e59cb6..abcd7ed78 100644 --- a/crates/ui-egui/src/grid/cursor.rs +++ b/crates/ui-egui/src/grid/cursor.rs @@ -48,7 +48,7 @@ fn drag_cursor(drag: &Drag, rtl: bool) -> Option { match drag { Drag::None => None, Drag::Select => Some(CursorIcon::Cell), - Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default), + Drag::Rows(_) | Drag::Cols(_) | Drag::PointRows(_) | Drag::PointCols(_) => Some(CursorIcon::Default), Drag::Fill { .. } => Some(CursorIcon::Crosshair), Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { handle: None, .. } => Some(CursorIcon::Move), Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn), diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 5de25fd53..350aefd7a 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -307,6 +307,7 @@ fn handle_column_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, return; } if point_header(app, wb, sh, RangeRef::cols(c, c), mods.shift, mods.command) { + point_header_first_move(app, input); return; } // An armed Format Painter paints the columns dragged over instead of moving them. @@ -337,6 +338,7 @@ fn handle_row_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, p: } let r = geo.row_at(sh, p.y); if point_header(app, wb, sh, RangeRef::rows(r, r), mods.shift, mods.command) { + point_header_first_move(app, input); return; } let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) }; @@ -612,6 +614,10 @@ fn handle_dragging(app: &mut SheetApp, input: &InteractionInput<'_>) { app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()})); scroll_by(app, vec2(auto.x, 0.0)); } + drag @ (Drag::PointRows(_) | Drag::PointCols(_)) => { + point_header_to(app, geo, sh, wb, &drag, p); + scroll_by(app, if matches!(drag, Drag::PointRows(_)) { vec2(0.0, auto.y) } else { vec2(auto.x, 0.0) }); + } Drag::MoveCols { source, .. } => { let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)); let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5; @@ -688,6 +694,14 @@ fn handle_release(app: &mut SheetApp, input: &InteractionInput<'_>) { } // Whole rows or columns clicked or dragged over by their headers. Drag::Rows(_) | Drag::Cols(_) => format_painter_apply(app), + // The release may be the first frame to report the pointer's last position. + drag @ (Drag::PointRows(_) | Drag::PointCols(_)) => { + if resp.drag_stopped() + && let Some(p) = pos + { + point_header_to(app, geo, sh, wb, &drag, p); + } + } // Esc mid-drag lands here too (egui ends the drag on Esc): no menu, and the key is // spent on the drag so the grid's Esc doesn't also drop the copy. Drag::Fill { target: Some(t), menu: true } => { @@ -751,11 +765,13 @@ fn format_painter_apply(app: &mut SheetApp) { } } -/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting. +/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting, +/// and dragging on from there extends it. fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, extend: bool, add: bool) -> bool { let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false }; let edit_sheet = ed.sheet; let active_sheet = wb.active_sheet(); + let pressed = range; let range = if extend { ed.point .and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point)) @@ -776,9 +792,7 @@ fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, } else { range }; - let reference = range.a1(); - let reference = - if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference }; + let reference = header_reference(ed, wb, sh, range); if add { ed.append_ref(&reference); } else { @@ -787,9 +801,49 @@ fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, ed.point_anchor = None; ed.point_cell = Some(range.start); ed.request_focus = true; + // The drag keeps the end of the reference the press didn't land on. + app.grid.drag = if range.is_full_cols() { + Drag::PointCols(if range.start.col == pressed.start.col { range.end.col } else { range.start.col }) + } else { + Drag::PointRows(if range.start.row == pressed.start.row { range.end.row } else { range.start.row }) + }; true } +fn header_reference(ed: &EditState, wb: &Workbook, sh: &Sheet, range: RangeRef) -> String { + let reference = range.a1(); + if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference } +} + +/// The frame that starts a drag reports no `dragged()`, so its movement is applied here. +fn point_header_first_move(app: &mut SheetApp, input: &InteractionInput<'_>) { + if input.response.drag_started() + && let Some(p) = input.position + { + let drag = app.grid.drag.clone(); + point_header_to(app, input.geo, input.sheet, input.workbook, &drag, p); + } +} + +/// Point mode dragged over the headers: the pointed reference runs from the anchor to the pointer. +fn point_header_to(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, drag: &Drag, p: Pos2) { + let range = match *drag { + Drag::PointCols(anchor) => { + let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)); + RangeRef::cols(anchor.min(col), anchor.max(col)) + } + Drag::PointRows(anchor) => { + let row = geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)); + RangeRef::rows(anchor.min(row), anchor.max(row)) + } + _ => return, + }; + let Some(ed) = app.editor.as_mut().filter(|ed| ed.point_at_caret()) else { return }; + let reference = header_reference(ed, wb, sh, range); + ed.insert_ref(&reference); + ed.point_cell = Some(range.start); +} + fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) { let cell = CellRef::new( geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), diff --git a/crates/ui-egui/tests/it/formula_point_headers.rs b/crates/ui-egui/tests/it/formula_point_headers.rs index 61c4192fa..06d5b9308 100644 --- a/crates/ui-egui/tests/it/formula_point_headers.rs +++ b/crates/ui-egui/tests/it/formula_point_headers.rs @@ -173,3 +173,92 @@ fn shift_does_not_extend_a_row_reference_across_sheets() { [("6:6".into(), Some("Other sheet".into())),] ); } + +fn press_and_drag(h: &mut egui_kittest::Harness<'_, SheetApp>, from: Pos2, to: Pos2, modifiers: Modifiers) { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut() + .events + .extend([Event::PointerMoved(from), Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }]); + h.step(); + drag_to(h, to); +} + +fn drag_to(h: &mut egui_kittest::Harness<'_, SheetApp>, to: Pos2) { + h.input_mut().events.push(Event::PointerMoved(to)); + h.step(); +} + +fn release(h: &mut egui_kittest::Harness<'_, SheetApp>, at: Pos2) { + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); +} + +fn text<'a>(h: &'a egui_kittest::Harness<'_, SheetApp>) -> &'a str { + h.state().editor.as_ref().map_or("", |ed| ed.text.as_str()) +} + +#[test] +fn dragging_over_column_headers_extends_the_reference() { + let mut h = harness(); + let (c, e, a, d) = (header_center(&h, false, 2), header_center(&h, false, 4), header_center(&h, false, 0), header_center(&h, false, 3)); + press_and_drag(&mut h, c, e, Modifiers::NONE); + assert_eq!(text(&h), "=SUM(C:E"); + // Back past the press: the reference follows the pointer instead of keeping E. + drag_to(&mut h, a); + assert_eq!(text(&h), "=SUM(A:C"); + // The release position counts even without a movement frame before it. + release(&mut h, d); + assert_eq!(text(&h), "=SUM(C:D"); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "F8"); +} + +#[test] +fn dragging_over_row_headers_extends_the_reference() { + let mut h = harness(); + let (r3, r6, r2) = (header_center(&h, true, 2), header_center(&h, true, 5), header_center(&h, true, 1)); + press_and_drag(&mut h, r3, r6, Modifiers::NONE); + assert_eq!(text(&h), "=SUM(3:6"); + release(&mut h, r2); + assert_eq!(text(&h), "=SUM(2:3"); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "F8"); +} + +#[test] +fn header_drag_on_another_sheet_keeps_the_qualifier() { + let mut h = harness(); + h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap(); + h.run_steps(2); + let (b, d) = (header_center(&h, false, 1), header_center(&h, false, 3)); + press_and_drag(&mut h, b, d, Modifiers::NONE); + assert_eq!(text(&h), "=SUM('Other sheet'!B:D"); +} + +#[test] +fn header_drag_with_command_keeps_the_earlier_argument() { + let mut h = harness(); + let (a, e, f) = (header_center(&h, false, 0), header_center(&h, false, 4), header_center(&h, false, 5)); + click(&mut h, a); + press_and_drag(&mut h, e, f, Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }); + assert_eq!(text(&h), "=SUM(A:A,E:F"); +} + +#[test] +fn header_drag_with_shift_keeps_the_far_end_of_the_reference() { + let mut h = harness(); + let (c, e, d) = (header_center(&h, false, 2), header_center(&h, false, 4), header_center(&h, false, 3)); + click(&mut h, c); + press_and_drag(&mut h, e, d, Modifiers { shift: true, ..Modifiers::NONE }); + assert_eq!(text(&h), "=SUM(C:D"); +} + +#[test] +fn clicks_on_headers_replace_the_reference_after_a_drag_too() { + let mut h = harness(); + let (c, e, g) = (header_center(&h, false, 2), header_center(&h, false, 4), header_center(&h, false, 6)); + press_and_drag(&mut h, c, e, Modifiers::NONE); + release(&mut h, e); + assert_eq!(text(&h), "=SUM(C:E"); + click(&mut h, g); + assert_eq!(text(&h), "=SUM(G:G"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..b15cd3fbf 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (dragging over row or column headers in point mode extends the reference; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -57,7 +57,7 @@ this logic, with one undo step. | Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series; month patterns use the workbook's 1900 or 1904 date system); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, and from a date Fill Days, Fill Weekdays, Fill Months and Fill Years, then Series…; a left-drag leaves an Auto Fill Options button with the same fill choices (gone once the workbook changes or the selection moves); Linear/Growth Trend and Flash Fill missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | -| Point mode (click/drag/arrow references), reference colouring, F4 | done; Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. | +| Point mode (click/drag/arrow references), reference colouring, F4 | done; dragging over row or column headers points at whole rows or columns (`A:C`, `2:5`); Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. | | Number-format colors (`[Green]`, `[Color1]`–`[Color56]`) | Rendered in grid and PDF from shared RGB mappings (#1452) | | Point mode (click/drag/arrow references), reference colouring, F4 | done | | AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) | @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Point mode: dragging over row or column headers extends the whole-row or whole-column reference, as it does over cells | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From 38a7067ca26aaf884e54bb79cae90b708b974e18 Mon Sep 17 00:00:00 2001 From: Daniele Mori Date: Tue, 3 Nov 2026 13:59:41 +0100 Subject: [PATCH 54/80] Options: highlight the active cell's row, as a global preference On a wide table the eye loses its place following a row from the active cell. File > Options > General gets "Highlight the active cell's row", off by default: a tint across the whole row of the active cell, over the fills and under the text, in Normal and Page Layout views. It is a preference for every sheet and workbook, so nothing goes into the file, the workbook does not become dirty and there is no undo step. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/commands.rs | 4 + crates/ui-egui/src/backstage/options.rs | 4 + crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/grid.rs | 2 + crates/ui-egui/src/grid/active_row.rs | 28 ++++ crates/ui-egui/src/grid/quadrant.rs | 7 + crates/ui-egui/src/grid/show.rs | 14 +- crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/paper_view.rs | 14 +- crates/ui-egui/src/theme.rs | 3 + .../ui-egui/tests/it/active_row_highlight.rs | 124 ++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/ui-parity.md | 4 +- 14 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/grid/active_row.rs create mode 100644 crates/ui-egui/tests/it/active_row_highlight.rs diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 86a4a6cc5..db657f567 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -191,6 +191,10 @@ impl SheetApp { self.ui.autocomplete = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.autocomplete); Ok(json!({"on": self.ui.autocomplete})) } + "options.highlightActiveRow" => { + self.ui.highlight_active_row = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.highlight_active_row); + Ok(json!({"on": self.ui.highlight_active_row})) + } "view.interfaceScale" => { // `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped. let percent = match p.get("percent") { diff --git a/crates/ui-egui/src/backstage/options.rs b/crates/ui-egui/src/backstage/options.rs index 6b9f70eb7..8d62ae41b 100644 --- a/crates/ui-egui/src/backstage/options.rs +++ b/crates/ui-egui/src/backstage/options.rs @@ -14,6 +14,10 @@ pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) { if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() { app.run_or_alert("view.formulaBar", json!({"on": formula_bar})); } + let mut highlight_row = app.ui.highlight_active_row; + if ui.checkbox(&mut highlight_row, lang.tr("Highlight the active cell's row")).changed() { + app.run_or_alert("options.highlightActiveRow", json!({"on": highlight_row})); + } ui.add_space(18.0); section(ui, lang, "Options|Editing"); let mut autocomplete = app.ui.autocomplete; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2d46b21b8..4cf1a77df 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -269,6 +269,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("view.theme", "Display Theme", ""), ("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"), ("options.autoComplete", "Enable AutoComplete for cell values", "{on?: bool} (toggles without it) → {on}"), + ("options.highlightActiveRow", "Highlight the active cell's row", "{on?: bool} (toggles without it) → {on}"), ("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"), ("ui.dialog", "Open Dialog", ""), ("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|account|close}"), diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 5a6dd3978..1b92ed0c0 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -22,6 +22,8 @@ type PagesCache = ((u64, u64, usize), Vec<(RangeRef, u32)>); /// Cached Show Details cells, keyed by (doc uid, revision, sheet, pivot). type DetailsCache = ((u64, u64, usize, usize), Option>); +mod active_row; +pub use active_row::active_row_band; mod cell_paint; mod cursor; mod editor; diff --git a/crates/ui-egui/src/grid/active_row.rs b/crates/ui-egui/src/grid/active_row.rs new file mode 100644 index 000000000..89c33dce2 --- /dev/null +++ b/crates/ui-egui/src/grid/active_row.rs @@ -0,0 +1,28 @@ +//! "Highlight the active cell's row" (File ▸ Options ▸ General): a tint across the whole row of +//! the active cell, so the eye keeps its place in a wide table. + +use super::geo::Geo; +use super::quadrant::QuadrantPaint; +use super::{Rect, Sheet, pos2}; + +/// The tinted band of `row` inside one quadrant (`rows` x `cols`): none when the row is outside +/// the quadrant or hidden. +pub fn active_row_band(geo: &Geo, sh: &Sheet, row: u32, rows: (u32, u32), cols: (u32, u32)) -> Option { + let height = sh.row_height(row) * geo.z; + if row < rows.0 || row > rows.1 || height <= 0.0 { + return None; + } + let top = geo.y(sh, row); + let right = geo.x(sh, cols.1) + sh.col_width(cols.1) * geo.z; + Some(Rect::from_min_max(pos2(geo.x(sh, cols.0), top), pos2(right, top + height))) +} + +/// Paints the band of one quadrant, when the preference is on. With `within`, only the part of it +/// inside that area (text running past its cell has just cleared it). +pub(super) fn paint_active_row(q: &QuadrantPaint<'_>, within: Option) { + let Some(band) = q.active_row.and_then(|row| active_row_band(q.geo, q.sheet, row, q.rows, q.cols)) else { return }; + let part = within.map_or(band, |area| band.intersect(area)); + if part.is_positive() { + q.painter.rect_filled(part, 0.0, q.tokens.active_row); + } +} diff --git a/crates/ui-egui/src/grid/quadrant.rs b/crates/ui-egui/src/grid/quadrant.rs index 554bdcfdd..84155bf26 100644 --- a/crates/ui-egui/src/grid/quadrant.rs +++ b/crates/ui-egui/src/grid/quadrant.rs @@ -1,3 +1,4 @@ +use super::active_row::paint_active_row; use super::cell_paint::{ Look, MergedArea, cell_text_y, center_across_end, col32, display_text, look, merged_edges, paint_border, paint_cf_icon, painted_halign, rotated_text_pos, shrink_font, wrapped_cell_text_y, @@ -102,6 +103,8 @@ pub(crate) struct QuadrantPaint<'a> { pub(crate) cols: (u32, u32), pub(crate) cf: &'a mut CfCache, pub(crate) tokens: &'a Tokens, + /// The row to tint, when "Highlight the active cell's row" is on. + pub(crate) active_row: Option, } /// Paint a worksheet quadrant, including backgrounds, borders, and text. @@ -118,6 +121,8 @@ pub(crate) fn paint_quadrant(mut quadrant: QuadrantPaint<'_>) { let cells = visible_cells(&quadrant); let looks = paint_backgrounds(&mut quadrant, &cells, &merges); let outside = outside_text_cells(&mut quadrant, &merges); + // Over the fills and under the text and borders. + paint_active_row(&quadrant, None); paint_cells(&mut quadrant, &looks, &outside, &merges); paint_borders(&mut quadrant, &looks, &merges); paint_sparklines(&quadrant, range); @@ -478,9 +483,11 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option 0.0 { p.rect_filled(extra_l, 0.0, t.grid_bg); + paint_active_row(q, Some(extra_l)); } if extra_r.width() > 0.0 { p.rect_filled(extra_r, 0.0, t.grid_bg); + paint_active_row(q, Some(extra_r)); } } } diff --git a/crates/ui-egui/src/grid/show.rs b/crates/ui-egui/src/grid/show.rs index f1416c2ec..bcf128c17 100644 --- a/crates/ui-egui/src/grid/show.rs +++ b/crates/ui-egui/src/grid/show.rs @@ -404,7 +404,19 @@ fn paint_panes(input: PanesInput<'_>, cf: &mut CfCache) -> Vec { continue; } let p = painter.with_clip_rect(*clip); - paint_quadrant(QuadrantPaint { painter: &p, geo, workbook: wb, sheet_index: si, sheet: sh, rows: *rows, cols: *cols, cf, tokens: t }); + let active_row = app.ui.highlight_active_row.then_some(sel.active.row); + paint_quadrant(QuadrantPaint { + painter: &p, + geo, + workbook: wb, + sheet_index: si, + sheet: sh, + rows: *rows, + cols: *cols, + cf, + tokens: t, + active_row, + }); paint_selection(SelectionPaintInput { painter: &p, geo, sheet: sh, selection: sel, outline, tokens: t, app, rows: *rows, cols: *cols }); } // Freeze lines. diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index f72a4cdf1..a0ca6bd16 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -410,6 +410,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|Save" => "Salvataggio", "Options|Editing" => "Modifica", "Show formula bar" => "Mostra barra della formula", + "Highlight the active cell's row" => "Evidenzia la riga della cella attiva", "Enable AutoComplete for cell values" => "Abilita Completamento automatico per i valori delle celle", "Save .xlsx workbooks automatically after every change" => "Salva automaticamente le cartelle di lavoro .xlsx dopo ogni modifica", "Workbook calculation" => "Calcolo della cartella di lavoro", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 95f861f14..78a949d0c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -94,6 +94,9 @@ pub struct UiState { /// Enable AutoComplete for cell values (File ▸ Options ▸ Editing): typed text is completed /// from the one matching entry in the same column. pub autocomplete: bool, + /// Tint the active cell's whole row (File ▸ Options ▸ General), on every sheet and workbook, so + /// the eye keeps its place across a wide table. A preference: nothing goes into the file. + pub highlight_active_row: bool, /// AutoSave (the title-bar switch): save saved-to-file .xlsx workbooks shortly after every /// change. A preference like the rest of this struct — it stays on across restarts (#1901). pub autosave: bool, @@ -140,6 +143,7 @@ impl Default for UiState { status_bar: true, animations: true, autocomplete: true, + highlight_active_row: false, autosave: false, recent: vec![], keytip_invoke: None, diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index f444b6ac2..114d1e8ca 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -162,7 +162,19 @@ pub(crate) fn paint_pages(input: PagePaintInput<'_>, cf: &mut CfCache) -> Vec egui_kittest::Harness<'static, SheetApp> { + let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + crate::common::app(session), + ); + h.run_steps(4); + h +} + +fn book(active: &str) -> Session { + let mut session = Session::new(); + session.new_workbook(); + session.execute("selection.set", json!({"range": active})).unwrap(); + session +} + +/// The rectangles painted with the highlight's own colour. +fn tinted(h: &egui_kittest::Harness<'_, SheetApp>) -> Vec { + let tint = Tokens::get(&h.ctx).active_row; + h.output() + .shapes + .iter() + .filter_map(|clipped| match &clipped.shape { + Shape::Rect(r) if r.fill == tint => Some(r.rect), + _ => None, + }) + .collect() +} + +#[test] +fn it_is_off_by_default_and_paints_nothing() { + assert!(!UiState::default().highlight_active_row); + let h = harness(book("C5")); + assert!(tinted(&h).is_empty()); +} + +#[test] +fn turned_on_it_tints_the_active_row_across_the_columns() { + let mut h = harness(book("C5")); + assert_eq!(h.state_mut().run("options.highlightActiveRow", json!({"on": true})), Ok(json!({"on": true}))); + h.run_steps(2); + let bands = tinted(&h); + assert_eq!(bands.len(), 1, "{bands:?}"); + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + let cell = geo.cell_rect(sheet, CellRef::parse("C5").unwrap()); + assert_eq!((bands[0].top(), bands[0].bottom()), (cell.top(), cell.bottom())); + assert!(bands[0].left() <= geo.cells.left() + 1.0, "starts at the first column: {:?}", bands[0]); + assert!(bands[0].right() >= geo.cells.right() - 1.0, "reaches the last visible column: {:?}", bands[0]); +} + +#[test] +fn the_band_follows_the_active_cell_and_only_the_row() { + let mut h = harness(book("C5")); + h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap(); + h.state_mut().session.execute("selection.set", json!({"range": "E9"})).unwrap(); + h.run_steps(2); + let bands = tinted(&h); + assert_eq!(bands.len(), 1, "a row, and no column band: {bands:?}"); + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + assert_eq!(bands[0].top(), geo.cell_rect(sheet, CellRef::parse("E9").unwrap()).top()); +} + +#[test] +fn it_applies_to_every_sheet_and_workbook() { + let mut h = harness(book("C5")); + h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap(); + h.state_mut().session.execute("home.insertSheet", json!({"name": "Other"})).unwrap(); + h.run_steps(2); + assert_eq!(tinted(&h).len(), 1, "another sheet"); + h.state_mut().session.new_workbook(); + h.run_steps(2); + assert_eq!(tinted(&h).len(), 1, "another workbook"); +} + +#[test] +fn turning_it_off_clears_the_band_and_the_toggle_leaves_the_workbook_alone() { + let mut h = harness(book("C5")); + let dirty = h.state().session.active().unwrap().is_dirty(); + assert_eq!(h.state_mut().run("options.highlightActiveRow", json!({})), Ok(json!({"on": true})), "no parameters: it toggles"); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().is_dirty(), dirty); + assert_eq!(h.state_mut().run("options.highlightActiveRow", json!({})), Ok(json!({"on": false}))); + h.run_steps(2); + assert!(tinted(&h).is_empty()); + assert_eq!(h.state().session.active().unwrap().is_dirty(), dirty); +} + +#[test] +fn the_preference_survives_a_restart_and_older_settings_read_as_off() { + let state = UiState { highlight_active_row: true, ..UiState::default() }; + let saved = serde_json::to_string(&state).unwrap(); + assert!(serde_json::from_str::(&saved).unwrap().highlight_active_row); + assert!(!serde_json::from_str::("{}").unwrap().highlight_active_row); +} + +#[test] +fn a_hidden_row_or_a_row_outside_the_quadrant_has_no_band() { + let mut session = book("A1"); + session.execute("home.rowHeight", json!({"rows": "5:5", "height": 0})).unwrap(); + let h = harness(session); + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + assert_eq!(active_row_band(&geo, sheet, 4, (0, 20), (0, 5)), None, "hidden"); + assert_eq!(active_row_band(&geo, sheet, 30, (0, 20), (0, 5)), None, "outside"); + assert!(active_row_band(&geo, sheet, 6, (0, 20), (0, 5)).is_some()); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index f1240dcd6..df6d47842 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -7,6 +7,7 @@ mod common; mod accounting_fill; +mod active_row_highlight; mod advanced_filter; mod argument_names; mod autosave_persist; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..ca917a571 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Highlight the active cell's row, a global preference in File › Options › General; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -54,6 +54,7 @@ this logic, with one undo step. | Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) | | Cursor while dragging cells, headers, freeze handles and objects | stable for the active drag (#936); Draw tools keep their crosshair; header arrows and a custom cell cursor remain unimplemented | | Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet | +| Highlight the active cell's row (no Excel equivalent; Focus Cell tints the row and the column) | done (11-03): File › Options › General, off by default; a preference for every sheet and workbook, nothing saved in the file. Tints over the fills and under the text, in Normal and Page Layout views (`options.highlightActiveRow`) | | Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series; month patterns use the workbook's 1900 or 1904 date system); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, and from a date Fill Days, Fill Weekdays, Fill Months and Fill Years, then Series…; a left-drag leaves an Auto Fill Options button with the same fill choices (gone once the workbook changes or the selection moves); Linear/Growth Trend and Flash Fill missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | @@ -237,6 +238,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Highlight the active cell's row: a global preference that tints the whole row, off by default | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From 2caf435a38a2e2d62625c47b7ccd64befca2ea66 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 07:21:22 -0600 Subject: [PATCH 55/80] #2012: split a parsed sheet-qualified name at the last `!`, sheet as is Addresses review comments 4455115540 and 4455115557. The parser stores `'Sheet'!Name` as `Sheet!Name` with the sheet already unquoted: - Evaluator::name split it at the first `!`, so a series named `'a!b'!Title` showed #NAME?. - chart_name unquoted it again, trimming spaces, so a chart reference to `' Sales '!Data` was saved as `Sales!Data`. Both now split at the last `!` and look the sheet up as is. Tests: chart tests::a_series_named_by_a_name_on_a_sheet_with_a_bang_resolves, xlsx tests::chart_book_names::chart_names_keep_spaces_around_a_quoted_sheet_name. Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/eval.rs | 7 +++-- crates/chart/src/tests.rs | 36 +++++++++++++++++++++ crates/xlsx/src/chart.rs | 11 ++++--- crates/xlsx/src/tests/chart_book_names.rs | 38 +++++++++++++++++++++++ 4 files changed, 84 insertions(+), 8 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 4972623fc..7a957f3a4 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -359,9 +359,10 @@ impl<'h> Evaluator<'h> { return v.clone(); } let wb = self.host.workbook(); - // Sheet-qualified `Sheet1!Name`. - let (scope_sheet, bare) = match n.split_once('!') { - Some((s, b)) => match wb.sheet_index(&gridcraft_core::unquote_sheet(s)) { + // Sheet-qualified `Sheet1!Name`. The parser stores the sheet unquoted, so it is used as + // is (`' Sales '` keeps its spaces) and split at the last `!` (`'a!b'!Name`). + let (scope_sheet, bare) = match n.rsplit_once('!') { + Some((s, b)) => match wb.sheet_index(s) { Some(i) => (i, b), None => return err(CellError::Name), }, diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 5d5876af0..6e423802c 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -1294,3 +1294,39 @@ fn a_series_named_by_a_name_on_a_sheet_with_an_apostrophe_resolves() { let d = resolve(&wb, 0, &c); assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings")); } + +#[test] +fn a_series_named_by_a_name_on_a_sheet_with_a_bang_resolves() { + // `'a!b'!Title`: the sheet name holds a `!`, so the evaluator must split the parsed name at + // the last one, not the first. + let mut wb = Workbook::new(); + { + let s = wb.sheet_mut(0).unwrap(); + s.name = "a!b".into(); + s.set_value(CellRef::new(0, 0), Value::from("Takings")); + s.set_value(CellRef::new(1, 0), Value::Number(4.0)); + s.set_value(CellRef::new(2, 0), Value::Number(9.0)); + } + wb.names.push(gridcraft_model::DefinedName { + name: "Title".into(), + scope: Some(0), + formula: "'a!b'!$A$1".into(), + comment: String::new(), + hidden: false, + }); + let mut c = chart(ChartKind::ColumnClustered); + c.series = vec![Series { + name: Some("'a!b'!Title".into()), + categories: None, + values: "'a!b'!$A$2:$A$3".into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + quartiles: Default::default(), + smooth: false, + trendlines: Vec::new(), + }]; + let d = resolve(&wb, 0, &c); + assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings")); +} diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index c36420043..a408da5fa 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -366,13 +366,14 @@ fn is_ref(f: &str) -> bool { /// when it isn't a name, or names a sheet the workbook doesn't have. fn chart_name<'a>(wb: &'a Workbook, si: usize, f: &str) -> Option<(Option, String, Option<&'a DefinedName>)> { let Ok(Expr::Name(n)) = gridcraft_formula::parse(f.trim()) else { return None }; - // `'Bob''s'!Name`: the sheet is unquoted as everywhere else (#2012). - match gridcraft_core::split_sheet(&n) { - (Some(s), bare) => { - let i = wb.sheet_index(&s)?; + // The parser has already unquoted the sheet (`'Bob''s'!Name` → `Bob's!Name`, #2012): split + // at the last `!` (`'a!b'!Name`) and keep the name as is (`' Sales '` keeps its spaces). + match n.rsplit_once('!') { + Some((s, bare)) => { + let i = wb.sheet_index(s)?; Some((Some(i), bare.to_string(), wb.name(bare, i))) } - (None, _) => { + None => { let def = wb.name(&n, si); Some((None, n, def)) } diff --git a/crates/xlsx/src/tests/chart_book_names.rs b/crates/xlsx/src/tests/chart_book_names.rs index 14dc9d243..10edf45a4 100644 --- a/crates/xlsx/src/tests/chart_book_names.rs +++ b/crates/xlsx/src/tests/chart_book_names.rs @@ -139,3 +139,41 @@ fn chart_names_are_written_qualified() { let refs = |x: &str| x.split("").skip(1).filter_map(|p| p.split_once("").map(|(f, _)| f.to_string())).collect::>(); assert_eq!(refs(&again), refs(&xml)); } + +/// A quoted sheet name keeps its outer spaces: `' Sales '!Data` is not `Sales!Data`, so the +/// chart doesn't switch to the other sheet's name on save. +#[test] +fn chart_names_keep_spaces_around_a_quoted_sheet_name() { + let mut wb = Workbook::default(); + let mut s = Sheet::new("Sales"); + s.charts.push(Chart { + id: 1, + kind: ChartKind::BarStacked, + anchor: Anchor { cell: gridcraft_core::CellRef::new(0, 3), width: 480.0, height: 300.0, ..Anchor::default() }, + title: None, + series: vec![series("' Sales '!Label", "' Sales '!Data")], + legend: LegendPos::Bottom, + data_labels: false, + data_table: None, + gridlines: true, + style: 2, + x_title: None, + y_title: None, + source: None, + by_rows: false, + pivot: None, + }); + wb.sheets = vec![Arc::new(s), Arc::new(Sheet::new(" Sales "))]; + wb.names = vec![ + name("Items", None, "Sales!$A$2:$A$4"), + name("Data", Some(0), "Sales!$B$2:$B$4"), + name("Data", Some(1), "' Sales '!$B$2:$B$4"), + name("Label", Some(0), "Sales!$B$1"), + name("Label", Some(1), "' Sales '!$B$1"), + ]; + let bytes = write_xlsx(&wb).unwrap(); + let mut xml = String::new(); + zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap().by_name("xl/charts/chart1.xml").unwrap().read_to_string(&mut xml).unwrap(); + assert!(xml.contains("' Sales '!Data"), "{xml}"); + assert!(xml.contains("' Sales '!Label"), "{xml}"); +} From b3bce6cc815f39a6cadbc47a21a295762fa2b798 Mon Sep 17 00:00:00 2001 From: Daniele Mori Date: Tue, 3 Nov 2026 14:28:35 +0100 Subject: [PATCH 56/80] Point mode: Shift+arrow after a header drag moves the dragged end After a drag over C:E, point_cell held C and no anchor was set, so Shift+Right moved the wrong end and gave D:E. The drag now records the pointer's end as the moving one and the opposite corner as the anchor, so it gives C:F, and rows work the same way. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid/interact.rs | 12 ++++-- .../ui-egui/tests/it/formula_point_headers.rs | 40 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 350aefd7a..88c69eefb 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -827,21 +827,25 @@ fn point_header_first_move(app: &mut SheetApp, input: &InteractionInput<'_>) { /// Point mode dragged over the headers: the pointed reference runs from the anchor to the pointer. fn point_header_to(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, drag: &Drag, p: Pos2) { - let range = match *drag { + // The pointer's end moves with Shift+arrow next; the opposite corner keeps it a whole column or row. + let (range, pointer, opposite) = match *drag { Drag::PointCols(anchor) => { let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)); - RangeRef::cols(anchor.min(col), anchor.max(col)) + let range = RangeRef::cols(anchor.min(col), anchor.max(col)); + (range, CellRef::new(range.start.row, col), CellRef::new(range.end.row, anchor)) } Drag::PointRows(anchor) => { let row = geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)); - RangeRef::rows(anchor.min(row), anchor.max(row)) + let range = RangeRef::rows(anchor.min(row), anchor.max(row)); + (range, CellRef::new(row, range.start.col), CellRef::new(anchor, range.end.col)) } _ => return, }; let Some(ed) = app.editor.as_mut().filter(|ed| ed.point_at_caret()) else { return }; let reference = header_reference(ed, wb, sh, range); ed.insert_ref(&reference); - ed.point_cell = Some(range.start); + ed.point_cell = Some(pointer); + ed.point_anchor = Some((wb.active_sheet(), opposite)); } fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) { diff --git a/crates/ui-egui/tests/it/formula_point_headers.rs b/crates/ui-egui/tests/it/formula_point_headers.rs index 06d5b9308..97e60ae93 100644 --- a/crates/ui-egui/tests/it/formula_point_headers.rs +++ b/crates/ui-egui/tests/it/formula_point_headers.rs @@ -262,3 +262,43 @@ fn clicks_on_headers_replace_the_reference_after_a_drag_too() { click(&mut h, g); assert_eq!(text(&h), "=SUM(G:G"); } + +fn shift_arrow(h: &mut egui_kittest::Harness<'_, SheetApp>, key: egui::Key) { + let modifiers = Modifiers { shift: true, ..Modifiers::NONE }; + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +#[test] +fn shift_arrow_after_a_header_drag_moves_the_dragged_end() { + let mut h = harness(); + let (c, e) = (header_center(&h, false, 2), header_center(&h, false, 4)); + press_and_drag(&mut h, c, e, Modifiers::NONE); + release(&mut h, e); + assert_eq!(text(&h), "=SUM(C:E"); + shift_arrow(&mut h, egui::Key::ArrowRight); + assert_eq!(text(&h), "=SUM(C:F"); + + // Dragged leftwards, the dragged end is the first column. + let mut h = harness(); + let (e, c) = (header_center(&h, false, 4), header_center(&h, false, 2)); + press_and_drag(&mut h, e, c, Modifiers::NONE); + release(&mut h, c); + assert_eq!(text(&h), "=SUM(C:E"); + shift_arrow(&mut h, egui::Key::ArrowLeft); + assert_eq!(text(&h), "=SUM(B:E"); + + let mut h = harness(); + let (r1, r3) = (header_center(&h, true, 0), header_center(&h, true, 2)); + press_and_drag(&mut h, r1, r3, Modifiers::NONE); + release(&mut h, r3); + assert_eq!(text(&h), "=SUM(1:3"); + shift_arrow(&mut h, egui::Key::ArrowDown); + assert_eq!(text(&h), "=SUM(1:4"); +} From ee6b9fdceedb09d262cf837dd1042b7395bddd16 Mon Sep 17 00:00:00 2001 From: Daniele Mori Date: Tue, 3 Nov 2026 14:31:35 +0100 Subject: [PATCH 57/80] Options: keep the active row's band visible on a faded page In Page Layout view the "Click to add data" pages are faded after their cells are painted, which all but hid the band on them. The band goes on again over the fade. Tests also cover Page Layout view and frozen panes. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/paper_view.rs | 4 +++ .../ui-egui/tests/it/active_row_highlight.rs | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 114d1e8ca..9db376fbf 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -178,6 +178,10 @@ pub(crate) fn paint_pages(input: PagePaintInput<'_>, cf: &mut CfCache) -> Vec Date: Tue, 3 Nov 2026 14:48:29 +0100 Subject: [PATCH 58/80] Point mode: keep the sheet qualifier when pointing with the arrow keys point_move wrote a bare reference, so on another sheet Shift+arrow after a header drag turned 'Other sheet'!B:D into B:E, which reads as the formula's own sheet. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid/interact.rs | 1 + crates/ui-egui/tests/it/formula_point_headers.rs | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 88c69eefb..512042619 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -1096,6 +1096,7 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte to }; let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() }; + let text = if ed.sheet != d.wb.active_sheet() { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sheet.name)) } else { text }; let sheet = d.wb.active_sheet(); ed.insert_ref(&text); ed.point_cell = Some(to); diff --git a/crates/ui-egui/tests/it/formula_point_headers.rs b/crates/ui-egui/tests/it/formula_point_headers.rs index 97e60ae93..aeef72f7f 100644 --- a/crates/ui-egui/tests/it/formula_point_headers.rs +++ b/crates/ui-egui/tests/it/formula_point_headers.rs @@ -302,3 +302,19 @@ fn shift_arrow_after_a_header_drag_moves_the_dragged_end() { shift_arrow(&mut h, egui::Key::ArrowDown); assert_eq!(text(&h), "=SUM(1:4"); } + +#[test] +fn shift_arrow_pointing_on_another_sheet_keeps_the_qualifier() { + let mut h = harness(); + h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap(); + h.run_steps(2); + let (b, d) = (header_center(&h, false, 1), header_center(&h, false, 3)); + press_and_drag(&mut h, b, d, Modifiers::NONE); + release(&mut h, d); + assert_eq!(text(&h), "=SUM('Other sheet'!B:D"); + let app = h.state_mut(); + let mut ed = app.editor.take().unwrap(); + gridcraft_ui_egui::grid::point_move(app, &mut ed, 0, 1, true, false); + app.editor = Some(ed); + assert_eq!(text(&h), "=SUM('Other sheet'!B:E"); +} From 58e876a4d8fc7ccda8aff2c7c8cca31367f56684 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 07:52:14 -0600 Subject: [PATCH 59/80] #2120: Remove Hyperlink clears links on cells with nothing stored Addresses review comment 4455436875: `clear` clipped each range to the sheet's used range (stored cells) before removing links and notes, so a link from a file on an empty cell outside it was left in place, and nothing was removed on a sheet with no cells. Notes and links are now cleared over the whole range; cells and merges still only within the used range. Test: engine tests::clear_hyperlinks_removes_links_on_cells_with_nothing_stored. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 28 +++++++++++++--------------- crates/engine/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2aa9d8fe3..9a74a2d9d 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -245,20 +245,16 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { }) }; edit(s, |cx| { - for r in &ranges { - let r = match cx.wb.sheet(sheet).and_then(|sh| sh.used_range()) { - Some(u) => match r.intersection(&u) { - Some(x) => x, - None => continue, - }, - None => continue, + for &whole in &ranges { + // Cells are only stored inside the used range. Notes and links are kept apart from + // them, so they are cleared over the whole range: one from a file can sit on a cell + // with nothing stored in it. + let used = cx.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| whole.intersection(&u)); + let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, whole)).unwrap_or_default(); + let cells: Vec<(CellRef, Cell)> = match (cx.wb.sheet(sheet), used) { + (Some(sh), Some(r)) => sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect(), + _ => Vec::new(), }; - let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, r)).unwrap_or_default(); - let cells: Vec<(CellRef, Cell)> = cx - .wb - .sheet(sheet) - .map(|sh| sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect()) - .unwrap_or_default(); // Report every stored cell we touch, even beyond a million coordinates in a sparse // selection. Removed formula anchors must also reach calc so their spills disappear. cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c))); @@ -294,14 +290,16 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { _ => {} } } - let cleared = |c: CellRef| r.contains(c) && !skip.contains(&c.row); + let cleared = |c: CellRef| whole.contains(c) && !skip.contains(&c.row); if matches!(what, "all" | "comments") { sh.comments.retain(|c, _| !cleared(*c)); } if matches!(what, "all" | "hyperlinks") { sh.hyperlinks.retain(|c, _| !cleared(*c)); } - if matches!(what, "all" | "formats") { + if let Some(r) = used + && matches!(what, "all" | "formats") + { let mut unmerged = false; sh.merges.retain(|m| { let keep = !r.contains_range(m) || skip.range(m.start.row..=m.end.row).next().is_some(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5f92718ba..272abffe2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5457,3 +5457,23 @@ fn a_view_kept_through_redo_doesnt_make_the_saved_workbook_dirty() { assert_eq!(d.wb.active().unwrap().view, gridcraft_model::SheetViewMode::PageBreakPreview); assert!(!d.is_dirty(), "back at the saved edits, the view alone isn't a change"); } + +/// A link from a file can sit on a cell with nothing stored in it, past the used range (or on a +/// sheet with no cells at all). Clear Hyperlinks, and Remove Hyperlink in the cell menu, must +/// still remove it. +#[test] +fn clear_hyperlinks_removes_links_on_cells_with_nothing_stored() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap(); + let link = || gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true }; + let (a1, c5) = (CellRef::parse("A1").unwrap(), CellRef::parse("C5").unwrap()); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().hyperlinks.insert(c5, link()); + s.execute("edit.clearHyperlinks", json!({"range": "C5"})).unwrap(); + assert!(!s.doc().unwrap().wb.active().unwrap().hyperlinks.contains_key(&c5), "the link past the used range is removed"); + + // No cells at all. + s.execute("edit.clearAll", json!({"range": "A1"})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().hyperlinks.insert(a1, link()); + s.execute("edit.clearHyperlinks", json!({"range": "A1"})).unwrap(); + assert!(!s.doc().unwrap().wb.active().unwrap().hyperlinks.contains_key(&a1), "the link on an empty sheet is removed"); +} From dfb3b1224bd5710aa3d0dfcbb9af0a39233b2fca Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 08:08:07 -0600 Subject: [PATCH 60/80] #2120: clearing links and notes respects sheet protection Addresses review comment 4455670315: - Clear Hyperlinks and Clear Comments removed links and notes on locked cells of a protected sheet: protection compares stored cells only, so their removal went unseen (for stored cells too, and now for unstored ones since the whole range is cleared). `clear` now refuses a locked cell's note or link with locked_cell_error, which keeps Allow Edit Ranges and session-unlocked ranges. - A removed note or link on a cell with nothing stored wasn't added to the changed cells, so a recorded macro's footprint missed it. Tests: engine tests::clearing_links_and_notes_respects_sheet_protection, tests::clearing_links_and_notes_records_their_cells. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 32 ++++++++++++++++++++++- crates/engine/src/tests.rs | 48 +++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9a74a2d9d..0fd0a1c40 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -244,6 +244,25 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { }) }) }; + // Notes and links aren't cell contents, so the cell comparison that guards a protected sheet + // doesn't see them go: a locked cell's are refused here, stored cell or not. + if matches!(what, "all" | "comments" | "hyperlinks") { + let d = s.doc()?; + if let Some(sh) = d.wb.sheet(sheet).filter(|sh| sh.is_protected()) { + for &whole in &ranges { + let skip = filtered_rows(sh, whole); + let notes = matches!(what, "all" | "comments").then(|| sh.comments.keys()).into_iter().flatten(); + let links = matches!(what, "all" | "hyperlinks").then(|| sh.hyperlinks.keys()).into_iter().flatten(); + for &c in notes.chain(links).filter(|c| whole.contains(**c) && !skip.contains(&c.row)) { + if d.wb.styles.get(sh.style_id(c)).protection.locked + && let Some(m) = locked_cell_error(sh, c, &d.unlocked_ranges) + { + return Err(EngineError::Other(m)); + } + } + } + } + } edit(s, |cx| { for &whole in &ranges { // Cells are only stored inside the used range. Notes and links are kept apart from @@ -274,6 +293,18 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { } } } + let cleared = |c: CellRef| whole.contains(c) && !skip.contains(&c.row); + // A removed note or link changes its cell even with nothing stored there. + let metadata: Vec = cx + .wb + .sheet(sheet) + .map(|sh| { + let notes = matches!(what, "all" | "comments").then(|| sh.comments.keys()).into_iter().flatten(); + let links = matches!(what, "all" | "hyperlinks").then(|| sh.hyperlinks.keys()).into_iter().flatten(); + notes.chain(links).copied().filter(|c| cleared(*c)).collect() + }) + .unwrap_or_default(); + cx.changed.extend(metadata.into_iter().map(|c| (sheet, c))); let sh = cx.sheet_mut(sheet)?; for (c, mut cell) in cells { match what { @@ -290,7 +321,6 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { _ => {} } } - let cleared = |c: CellRef| whole.contains(c) && !skip.contains(&c.row); if matches!(what, "all" | "comments") { sh.comments.retain(|c, _| !cleared(*c)); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 272abffe2..7c03d8c72 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5477,3 +5477,51 @@ fn clear_hyperlinks_removes_links_on_cells_with_nothing_stored() { s.execute("edit.clearHyperlinks", json!({"range": "A1"})).unwrap(); assert!(!s.doc().unwrap().wb.active().unwrap().hyperlinks.contains_key(&a1), "the link on an empty sheet is removed"); } + +/// Notes and links aren't cell contents, so the cell comparison that guards a protected sheet +/// doesn't see them go: Clear Hyperlinks and Clear Comments refuse locked cells themselves, +/// stored or not. +#[test] +fn clearing_links_and_notes_respects_sheet_protection() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap(); + let link = || gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true }; + let (a1, c5) = (CellRef::parse("A1").unwrap(), CellRef::parse("C5").unwrap()); + { + let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + sh.hyperlinks.insert(a1, link()); + sh.hyperlinks.insert(c5, link()); + } + s.execute("review.newNote", json!({"cell": "C5", "text": "note", "author": "Example"})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + for (cmd, range) in [("edit.clearHyperlinks", "A1"), ("edit.clearHyperlinks", "C5"), ("edit.clearComments", "C5")] { + assert!(s.execute(cmd, json!({"range": range})).is_err(), "{cmd} on locked {range}"); + } + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert!(sh.hyperlinks.contains_key(&a1) && sh.hyperlinks.contains_key(&c5) && sh.comments.contains_key(&c5), "nothing removed"); + + s.execute("review.unprotectSheet", json!({})).unwrap(); + s.execute("edit.clearHyperlinks", json!({"range": "A1:C5"})).unwrap(); + s.execute("edit.clearComments", json!({"range": "C5"})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert!(sh.hyperlinks.is_empty() && sh.comments.is_empty()); +} + +/// A removed link or note is a change to its cell even when nothing is stored there, so a +/// recorded macro's footprint lists it. +#[test] +fn clearing_links_and_notes_records_their_cells() { + let mut s = s(); + let c5 = CellRef::parse("C5").unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb) + .sheet_mut(0) + .unwrap() + .hyperlinks + .insert(c5, gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true }); + s.execute("review.newNote", json!({"cell": "D7", "text": "note", "author": "Example"})).unwrap(); + s.doc_mut().unwrap().touched = Some(Vec::new()); + s.execute("edit.clearHyperlinks", json!({"range": "C5"})).unwrap(); + s.execute("edit.clearComments", json!({"range": "D7"})).unwrap(); + let touched: Vec = s.doc().unwrap().touched.clone().unwrap_or_default().into_iter().map(|(_, c)| c.a1()).collect(); + assert!(touched.contains(&"C5".to_string()) && touched.contains(&"D7".to_string()), "{touched:?}"); +} From 72a9c5b47d744eb3dd92aa0cbf7c10bfd62db8de Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 08:21:50 -0600 Subject: [PATCH 61/80] #2120: test that clearing notes and links keeps Allow Edit Ranges Addresses the follow-up in review 5753664551: the protection test had no range exception, so a regression that refused locked notes and links inside an Allow Edit Range would have passed. The new test clears a link in a range without a password, is refused a note in a password range, then clears both once that range is unlocked. Test: engine tests::clearing_links_and_notes_keeps_allow_edit_ranges. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7c03d8c72..e1b8bace8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5525,3 +5525,31 @@ fn clearing_links_and_notes_records_their_cells() { let touched: Vec = s.doc().unwrap().touched.clone().unwrap_or_default().into_iter().map(|(_, c)| c.a1()).collect(); assert!(touched.contains(&"C5".to_string()) && touched.contains(&"D7".to_string()), "{touched:?}"); } + +/// The locked-cell check for notes and links keeps Allow Edit Ranges: a range without a password +/// allows them at once, one with a password once it's unlocked in this session. +#[test] +fn clearing_links_and_notes_keeps_allow_edit_ranges() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap(); + let link = || gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true }; + let (a1, c5) = (CellRef::parse("A1").unwrap(), CellRef::parse("C5").unwrap()); + { + let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + sh.hyperlinks.insert(a1, link()); + sh.hyperlinks.insert(c5, link()); + } + s.execute("review.newNote", json!({"cell": "C5", "text": "note", "author": "Example"})).unwrap(); + // Ranges are added before protecting: a protected sheet refuses `add`. + s.execute("review.allowEditRanges", json!({"action": "add", "title": "Open", "range": "A1"})).unwrap(); + s.execute("review.allowEditRanges", json!({"action": "add", "title": "Kept", "range": "C5", "password": "example"})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + + s.execute("edit.clearHyperlinks", json!({"range": "A1"})).unwrap(); + assert!(s.execute("edit.clearComments", json!({"range": "C5"})).is_err(), "a password range stays locked until unlocked"); + s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Kept", "password": "example"})).unwrap(); + s.execute("edit.clearHyperlinks", json!({"range": "C5"})).unwrap(); + s.execute("edit.clearComments", json!({"range": "C5"})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert!(sh.hyperlinks.is_empty() && sh.comments.is_empty()); +} From 1a7fa68376a3f94632e958abe64360a478b4736f Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 08:52:05 -0600 Subject: [PATCH 62/80] #1246: an expanded formula bar leaves room for the worksheet Addresses review comment 4456174694: the drag/expand cap was half the window, so in a 400 pt window a ~195 pt bar left the grid about 1.6 pt under the status bar and sheet tabs. The cap is now also limited by the room left under the ribbon, keeping the status bar, sheet tabs, column headings and two rows below the bar. Test: ui-egui formula_bar_expand::an_expanded_formula_bar_leaves_room_for_the_worksheet_in_a_short_window. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/formula_bar.rs | 19 +++++++++++++------ crates/ui-egui/tests/it/formula_bar_expand.rs | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 9be205a4f..091869ab8 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -17,6 +17,9 @@ pub const MAX_LINES: usize = 5; pub const MAX_DRAG_LINES: usize = 100; /// How far the bar's bottom edge reaches up into the bar as a resize handle (its bottom margin). const HANDLE: f32 = 4.0; +/// What an expanded bar leaves below it: the status bar (26) and sheet tabs (32), and the +/// worksheet's column headings and two rows, so the grid never disappears in a short window. +const KEEP_BELOW: f32 = 26.0 + 32.0 + crate::grid::COL_HEADER_H + 2.0 * gridcraft_engine::model::DEFAULT_ROW_HEIGHT; /// The bar's height at one line. const ONE_LINE: f32 = 30.0; /// What the bar adds around the field's rows: the panel's margins (3 + 3), the field's (3 + 3) @@ -92,7 +95,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { // Height follows the content's line count (last frame's), 1 to MAX_LINES; expanded (the // chevron), it is the height the bottom edge was dragged to. It eases only when that target // changes. - let most = max_lines(ui.ctx().content_rect().height(), row_h); + let most = max_lines(ui.ctx().content_rect().height(), ui.available_rect_before_wrap().height(), row_h); let lines = if app.ui.formula_bar_expanded { app.ui.formula_bar_lines.clamp(1, most) } else { last.lines.clamp(1, MAX_LINES) }; let target = one_line(row_h) + (lines - 1) as f32 * row_h; let h = ui.ctx().animate_value_with_time(egui::Id::unique("formula_bar.height"), target, EASE_SECONDS); @@ -178,11 +181,15 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { ui.ctx().data_mut(|d| d.insert_temp(BarLayout::id(), layout)); } -/// The most lines the bar takes in a window `window_h` tall: half the window, as in Excel, and -/// never fewer than the chevron's [`MAX_LINES`]. -fn max_lines(window_h: f32, row_h: f32) -> usize { - let fit = ((window_h * 0.5 - one_line(row_h)) / row_h).floor(); - if fit.is_finite() && fit > 0.0 { (fit as usize + 1).clamp(MAX_LINES, MAX_DRAG_LINES) } else { MAX_LINES } +/// The most lines the bar takes in a window `window_h` tall with `room` left under the ribbon: +/// half the window, as in Excel (never fewer than the chevron's [`MAX_LINES`]), and no more than +/// leaves [`KEEP_BELOW`] for the status bar, sheet tabs and a few rows of the grid. +fn max_lines(window_h: f32, room: f32, row_h: f32) -> usize { + let lines_in = |h: f32| { + let fit = ((h - one_line(row_h)) / row_h).floor(); + if fit.is_finite() && fit > 0.0 { fit as usize + 1 } else { 1 } + }; + lines_in(window_h * 0.5).clamp(MAX_LINES, MAX_DRAG_LINES).min(lines_in(room - KEEP_BELOW)) } /// The lines a bar whose top is at `top` shows with its bottom edge dragged to `y`, in whole lines. diff --git a/crates/ui-egui/tests/it/formula_bar_expand.rs b/crates/ui-egui/tests/it/formula_bar_expand.rs index cabf7ee70..169107ac7 100644 --- a/crates/ui-egui/tests/it/formula_bar_expand.rs +++ b/crates/ui-egui/tests/it/formula_bar_expand.rs @@ -147,3 +147,17 @@ fn expand_formula_bar_with_lines_sets_the_expanded_height() { assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 0})), Ok(json!({"on": false, "lines": 8})), "zero lines is one line"); assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": u64::MAX})), Ok(json!({"on": true, "lines": 100})), "capped"); } + +#[test] +fn an_expanded_formula_bar_leaves_room_for_the_worksheet_in_a_short_window() { + // At 400 pt the title bar and ribbon leave about 236 pt: half the window as a formula bar + // would leave the status bar and sheet tabs no room for any of the grid. + let mut session = Session::new(); + session.new_workbook(); + let mut h = crate::common::harness_with(crate::common::builder().with_size(vec2(1200.0, 400.0)), crate::common::app(session), 4); + h.state_mut().run("view.expandFormulaBar", json!({"lines": 100})).unwrap(); + h.run_steps(60); + let cells = h.state().grid.cells_rect.expect("the grid is drawn"); + assert!(cells.height() >= 40.0, "a couple of rows stay visible under a {} pt bar: {cells:?}", bar(&h).height); + assert!(bar(&h).visible_lines > 1, "the bar is still expanded"); +} From 865a789c73d27b2662562c1319a86bb4dab3c492 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 3 Nov 2026 09:59:27 -0500 Subject: [PATCH 63/80] Review fixes for #3223, #3253, #3254, #3252, #3239, #3244 and #3268 - #3223: sniff the first 64 KiB for a web page and skip JSON and files over the text-import cap, so every CSV/TSV is no longer decoded in full before the size check. - #3253: SYLK relative offsets use checked arithmetic (no overflow on R[9223372036854775807]); at most 1024 column-width records are kept; regression test. - #3254: a single selected merged cell searches the whole sheet, as one selected cell does. - #3252: the formula bar's height cap is half the room below the ribbon, not half the window, so the worksheet stays visible in a short window (CodeRabbit Major). - #3239, #3244, #3252, #3268: es/de/cs/zh/ko/ru/pt entries for the new interface strings. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/io.rs | 15 ++++++++++++--- crates/engine/src/io/sylk.rs | 7 ++++++- crates/engine/src/io/tests.rs | 15 +++++++++++++++ crates/ui-egui/src/dialogs/find.rs | 7 ++++++- crates/ui-egui/src/formula_bar.rs | 6 +++--- crates/ui-egui/src/i18n/cs.rs | 10 ++++++++++ crates/ui-egui/src/i18n/de.rs | 10 ++++++++++ crates/ui-egui/src/i18n/es.rs | 10 ++++++++++ crates/ui-egui/src/i18n/ko.rs | 10 ++++++++++ crates/ui-egui/src/i18n/pt.rs | 10 ++++++++++ crates/ui-egui/src/i18n/ru.rs | 10 ++++++++++ crates/ui-egui/src/i18n/zh.rs | 10 ++++++++++ 12 files changed, 112 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 2234d4627..602c17ccd 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -359,16 +359,23 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String { ) } +/// How much of a text file is read to tell whether it is a web page. +const HTML_SNIFF_BYTES: usize = 64 << 10; + /// A web page that holds a table, whatever its extension: a web app's "Export to Excel" is often /// HTML saved as `.xls` (#3166). Excel opens its table as a sheet (after warning that the format /// and extension don't match): the cells read like typed input (or as text with `csv_as_text`), /// header cells (``) are bold and centred, and the sheet takes the name the page gives it /// (``) or the file's. `None` when the bytes aren't such a page. fn read_html_table(name: &str, kind: Option, bytes: &[u8], csv_as_text: bool) -> Option)>> { - // Decoded as a CSV is (BOM, UTF-8, else Windows-1252/1256), so a legacy page keeps its letters. - let text = gridcraft_xlsx::decode_text(bytes); + // Every other text file (CSV, TSV, JSON) passes through here: look at the start only, and decode + // the whole file once it is known to be a page within the size a text import takes. + if matches!(kind, Some(FileKind::Json)) || bytes.len() > gridcraft_xlsx::MAX_CSV_BYTES { + return None; + } + let start = gridcraft_xlsx::decode_text(bytes.get(..HTML_SNIFF_BYTES).unwrap_or(bytes)); // Comments an exporter writes before the page (``) don't make it something else. - let mut rest = text.trim_start_matches('\u{feff}').trim_start(); + let mut rest = start.trim_start_matches('\u{feff}').trim_start(); while let Some(after) = rest.strip_prefix("").and_then(|end| after.get(end + 3..)) { Some(more) => rest = more.trim_start(), @@ -379,6 +386,8 @@ fn read_html_table(name: &str, kind: Option, bytes: &[u8], csv_as_text if ![" Result<(Workbook, Vec)> { @@ -110,6 +112,9 @@ pub fn read(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { (position(first, MAX_COLS), position(last, MAX_COLS), width.parse::()) && first <= last && (0.0..=255.0).contains(&width) + // A file of width records can't make the import loop over every column + // again and again. + && widths.len() < MAX_WIDTH_RECORDS { widths.push((first, last, width)); } @@ -215,7 +220,7 @@ impl Part { fn resolve(self, base: u32, max: u32) -> Option<(u32, bool)> { match self { Part::Absolute(n) => (1..=max).contains(&n).then(|| (n - 1, true)), - Part::Relative(k) => u32::try_from(i64::from(base) + k).ok().filter(|v| *v < max).map(|v| (v, false)), + Part::Relative(k) => i64::from(base).checked_add(k).and_then(|v| u32::try_from(v).ok()).filter(|v| *v < max).map(|v| (v, false)), } } } diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index 0e93be9ee..ae5ea8832 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -746,3 +746,18 @@ fn sylk_rejects_other_files_and_survives_damage() { assert_eq!(sh.value(CellRef::parse("A3").unwrap()), gridcraft_core::Value::Text("1e999".into())); assert_eq!(warnings.len(), 1, "skipped records are reported: {warnings:?}"); } + +#[test] +fn sylk_survives_huge_relative_offsets_and_width_floods() { + // Relative offsets at the edge of i64 fall off the sheet instead of overflowing. + let text = b"ID;P\nC;Y1;X1;K0;ER[9223372036854775807]C\nC;Y2;X1;K0;ER[-9223372036854775808]C[-9223372036854775808]\nE\n"; + assert!(open_bytes("offsets.slk", text).is_ok()); + // Width records over every column, many times over: the import keeps a bounded number. + let mut flood = b"ID;P\n".to_vec(); + for _ in 0..5000 { + flood.extend_from_slice(b"F;W1 16384 12\n"); + } + flood.extend_from_slice(b"C;Y1;X1;K1\nE\n"); + let (wb, _) = open_bytes("widths.slk", &flood).unwrap(); + assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::Number(1.0)); +} diff --git a/crates/ui-egui/src/dialogs/find.rs b/crates/ui-egui/src/dialogs/find.rs index fe7f8ad6c..394cf5211 100644 --- a/crates/ui-egui/src/dialogs/find.rs +++ b/crates/ui-egui/src/dialogs/find.rs @@ -41,7 +41,12 @@ pub(super) fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { d.values.insert("wholeCell".into(), json!(whole)); d.values.insert("within".into(), json!(if wb { "workbook" } else { "sheet" })); // As in Excel, a selected range (more than one cell) limits the search on the sheet to it. - let range_selected = app.session.active().is_some_and(|doc| !doc.selection.is_single_cell()); + // One selected cell, merged or not, searches the sheet, as in Excel. + let range_selected = app.session.active().is_some_and(|doc| { + let sel = &doc.selection; + let one_merged = sel.ranges.len() == 1 && doc.wb.active().and_then(|sh| sh.merge_at(sel.current().start)).is_some_and(|m| m == sel.current()); + !sel.is_single_cell() && !one_merged + }); let within = if wb { "workbook" } else if range_selected { diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 9be205a4f..afdf1987b 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -92,7 +92,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { // Height follows the content's line count (last frame's), 1 to MAX_LINES; expanded (the // chevron), it is the height the bottom edge was dragged to. It eases only when that target // changes. - let most = max_lines(ui.ctx().content_rect().height(), row_h); + let most = max_lines(ui.available_rect_before_wrap().height(), row_h); let lines = if app.ui.formula_bar_expanded { app.ui.formula_bar_lines.clamp(1, most) } else { last.lines.clamp(1, MAX_LINES) }; let target = one_line(row_h) + (lines - 1) as f32 * row_h; let h = ui.ctx().animate_value_with_time(egui::Id::unique("formula_bar.height"), target, EASE_SECONDS); @@ -178,8 +178,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { ui.ctx().data_mut(|d| d.insert_temp(BarLayout::id(), layout)); } -/// The most lines the bar takes in a window `window_h` tall: half the window, as in Excel, and -/// never fewer than the chevron's [`MAX_LINES`]. +/// The most lines the bar takes when `window_h` is the room left below the ribbon: half of it, +/// so the worksheet keeps the rest, and never fewer than the chevron's [`MAX_LINES`]. fn max_lines(window_h: f32, row_h: f32) -> usize { let fit = ((window_h * 0.5 - one_line(row_h)) / row_h).floor(); if fit.is_finite() && fit > 0.0 { (fit as usize + 1).clamp(MAX_LINES, MAX_DRAG_LINES) } else { MAX_LINES } diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index c7938ff4d..cae5a12f8 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -335,6 +335,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "Obecné", "Options|Save" => "Uložit", "Show formula bar" => "Zobrazit řádek vzorců", + "Highlight the active cell's row" => "Zvýraznit řádek aktivní buňky", + "Display options for this workbook" => "Možnosti zobrazení pro tento sešit", + "Show horizontal scroll bar" => "Zobrazit vodorovný posuvník", + "Show vertical scroll bar" => "Zobrazit svislý posuvník", + "Show sheet tabs" => "Zobrazit karty listů", + "Edit Hyperlink…" => "Upravit hypertextový odkaz…", + "Edit Hyperlink" => "Upravit hypertextový odkaz", + "Open Hyperlink" => "Otevřít hypertextový odkaz", + "Remove Hyperlink" => "Odebrat hypertextový odkaz", + "Resize formula bar" => "Změnit velikost řádku vzorců", "Enable AutoComplete for cell values" => "Povolit automatické dokončování hodnot buněk", "Save .xlsx workbooks automatically after every change" => "Automaticky ukládat sešity .xlsx po každé změně", "Workbook calculation" => "Výpočet sešitu", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 97e6460e8..8720d7397 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -350,6 +350,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "Allgemein", "Options|Save" => "Speichern", "Show formula bar" => "Bearbeitungsleiste anzeigen", + "Highlight the active cell's row" => "Zeile der aktiven Zelle hervorheben", + "Display options for this workbook" => "Optionen für die Anzeige dieser Arbeitsmappe", + "Show horizontal scroll bar" => "Horizontale Bildlaufleiste anzeigen", + "Show vertical scroll bar" => "Vertikale Bildlaufleiste anzeigen", + "Show sheet tabs" => "Blattregisterkarten anzeigen", + "Edit Hyperlink…" => "Link bearbeiten…", + "Edit Hyperlink" => "Link bearbeiten", + "Open Hyperlink" => "Link öffnen", + "Remove Hyperlink" => "Link entfernen", + "Resize formula bar" => "Größe der Bearbeitungsleiste ändern", "Enable AutoComplete for cell values" => "AutoVervollständigen für Zellwerte aktivieren", "Save .xlsx workbooks automatically after every change" => ".xlsx-Arbeitsmappen nach jeder Änderung automatisch speichern", "Workbook calculation" => "Arbeitsmappenberechnung", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 1db58e0fc..92dc1b34e 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -337,6 +337,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "General", "Options|Save" => "Guardar", "Show formula bar" => "Mostrar barra de fórmulas", + "Highlight the active cell's row" => "Resaltar la fila de la celda activa", + "Display options for this workbook" => "Opciones de presentación para este libro", + "Show horizontal scroll bar" => "Mostrar barra de desplazamiento horizontal", + "Show vertical scroll bar" => "Mostrar barra de desplazamiento vertical", + "Show sheet tabs" => "Mostrar pestañas de hojas", + "Edit Hyperlink…" => "Modificar hipervínculo…", + "Edit Hyperlink" => "Modificar hipervínculo", + "Open Hyperlink" => "Abrir hipervínculo", + "Remove Hyperlink" => "Quitar hipervínculo", + "Resize formula bar" => "Cambiar el tamaño de la barra de fórmulas", "Enable AutoComplete for cell values" => "Habilitar Autocompletar para valores de celda", "Save .xlsx workbooks automatically after every change" => "Guardar automáticamente los libros .xlsx tras cada cambio", "Workbook calculation" => "Cálculo del libro", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index d725d97d1..8e6d0418e 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -426,6 +426,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "일반", "Options|Save" => "저장", "Show formula bar" => "수식 입력줄 표시", + "Highlight the active cell's row" => "활성 셀의 행 강조 표시", + "Display options for this workbook" => "이 통합 문서의 표시 옵션", + "Show horizontal scroll bar" => "가로 스크롤 막대 표시", + "Show vertical scroll bar" => "세로 스크롤 막대 표시", + "Show sheet tabs" => "시트 탭 표시", + "Edit Hyperlink…" => "하이퍼링크 편집…", + "Edit Hyperlink" => "하이퍼링크 편집", + "Open Hyperlink" => "하이퍼링크 열기", + "Remove Hyperlink" => "하이퍼링크 제거", + "Resize formula bar" => "수식 입력줄 크기 조정", "Enable AutoComplete for cell values" => "셀 값에 자동 완성 사용", "Save .xlsx workbooks automatically after every change" => "변경할 때마다 .xlsx 통합 문서를 자동으로 저장", "Workbook calculation" => "통합 문서 계산", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 6a126c055..b2d9b9384 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -416,6 +416,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "Geral", "Options|Save" => "Salvar", "Show formula bar" => "Mostrar barra de fórmulas", + "Highlight the active cell's row" => "Realçar a linha da célula ativa", + "Display options for this workbook" => "Opções de exibição para esta pasta de trabalho", + "Show horizontal scroll bar" => "Mostrar barra de rolagem horizontal", + "Show vertical scroll bar" => "Mostrar barra de rolagem vertical", + "Show sheet tabs" => "Mostrar guias de planilhas", + "Edit Hyperlink…" => "Editar hiperlink…", + "Edit Hyperlink" => "Editar hiperlink", + "Open Hyperlink" => "Abrir hiperlink", + "Remove Hyperlink" => "Remover hiperlink", + "Resize formula bar" => "Redimensionar a barra de fórmulas", "Enable AutoComplete for cell values" => "Habilitar AutoConclusão para valores de célula", "Save .xlsx workbooks automatically after every change" => "Salvar pastas de trabalho .xlsx automaticamente após cada alteração", "Workbook calculation" => "Cálculo da pasta de trabalho", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 351734919..49964ad79 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -426,6 +426,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "Общие", "Options|Save" => "Сохранение", "Show formula bar" => "Показывать строку формул", + "Highlight the active cell's row" => "Выделять строку активной ячейки", + "Display options for this workbook" => "Параметры отображения для этой книги", + "Show horizontal scroll bar" => "Показывать горизонтальную полосу прокрутки", + "Show vertical scroll bar" => "Показывать вертикальную полосу прокрутки", + "Show sheet tabs" => "Показывать ярлычки листов", + "Edit Hyperlink…" => "Изменить гиперссылку…", + "Edit Hyperlink" => "Изменить гиперссылку", + "Open Hyperlink" => "Открыть гиперссылку", + "Remove Hyperlink" => "Удалить гиперссылку", + "Resize formula bar" => "Изменить размер строки формул", "Enable AutoComplete for cell values" => "Включить автозавершение значений ячеек", "Save .xlsx workbooks automatically after every change" => "Автоматически сохранять книги .xlsx после каждого изменения", "Workbook calculation" => "Вычисления в книге", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 42f2c1c6a..550440364 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -418,6 +418,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "常规", "Options|Save" => "保存", "Show formula bar" => "显示编辑栏", + "Highlight the active cell's row" => "突出显示活动单元格所在行", + "Display options for this workbook" => "此工作簿的显示选项", + "Show horizontal scroll bar" => "显示水平滚动条", + "Show vertical scroll bar" => "显示垂直滚动条", + "Show sheet tabs" => "显示工作表标签", + "Edit Hyperlink…" => "编辑超链接…", + "Edit Hyperlink" => "编辑超链接", + "Open Hyperlink" => "打开超链接", + "Remove Hyperlink" => "删除超链接", + "Resize formula bar" => "调整编辑栏大小", "Enable AutoComplete for cell values" => "为单元格值启用记忆式键入", "Save .xlsx workbooks automatically after every change" => "每次更改后自动保存 .xlsx 工作簿", "Workbook calculation" => "工作簿计算", From df137b8ee8cd68503681f759f59b4e30b2e0b048 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 10:53:20 -0500 Subject: [PATCH 64/80] xlsx: repair repeated sheet names through a set of assigned names (#3211) Importing a workbook whose sheets share a name rescanned every sheet for every suffix tried, so a file of repeated names opened in cubic time. SheetNames keeps the assigned names and the next suffix per stem. --- crates/xlsx/src/ods.rs | 2 +- crates/xlsx/src/read.rs | 47 +++++++++++++++++++++++----- crates/xlsx/src/tests/sheet_names.rs | 14 +++++++++ 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/crates/xlsx/src/ods.rs b/crates/xlsx/src/ods.rs index 8415ce395..9b7f0743b 100644 --- a/crates/xlsx/src/ods.rs +++ b/crates/xlsx/src/ods.rs @@ -317,7 +317,7 @@ impl Import { return Err(IoError::TooLarge("ODS contains more than 1024 sheets".into())); } let original = attr(reader, e, TABLE, b"name")?.unwrap_or_default(); - let mut name = crate::read::unique_name(&self.wb, original.trim_matches('\''), self.wb.sheets.len()); + let mut name = crate::read::SheetNames::of(&self.wb).unique(original.trim_matches('\''), self.wb.sheets.len()); if self.wb.check_sheet_name(&name, None).is_err() { name = self.wb.next_sheet_name(); } diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 47aaeaa5d..32b87183f 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -1,5 +1,6 @@ //! Reading the workbook part and everything hanging off it. +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use gridcraft_core::{DateSystem, RangeRef}; @@ -306,6 +307,7 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) -> let mut file_to_model: Vec> = vec![]; let mut pivot_caches = crate::pivot::CachesIn::new(&wb_xml, &wb_rels); let sheet_els: Vec = wb_xml.child("sheets").map(|s| s.kids("sheet").cloned().collect()).unwrap_or_default(); + let mut names = SheetNames::default(); for (i, se) in sheet_els.iter().enumerate() { let name = se.attr("name").unwrap_or("").to_string(); let rel = se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)).cloned(); @@ -324,7 +326,7 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) -> file_to_model.push(None); continue; } - let name = unique_name(&wb, &name, i); + let name = names.unique(&name, i); let mut sheet = match crate::sheet_read::read_sheet(&mut cx, &rel.target, &name) { Ok(s) => s, Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)), @@ -457,15 +459,44 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) -> Ok((wb, cx.report)) } -pub(crate) fn unique_name(wb: &Workbook, name: &str, i: usize) -> String { - let mut base: String = name.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect(); - if base.trim().is_empty() { - base = format!("Sheet{}", i + 1); +/// The sheet names an import has assigned so far, for repairing repeated ones. +#[derive(Default)] +pub(crate) struct SheetNames { + /// Assigned names, ASCII-lowercased. + taken: HashSet, + /// For each lowercased stem, the next suffix to try: a file full of repeated names would + /// otherwise retry every occupied suffix for every sheet. + next: HashMap, +} + +impl SheetNames { + pub(crate) fn of(wb: &Workbook) -> Self { + SheetNames { taken: wb.sheets.iter().map(|s| s.name.to_ascii_lowercase()).collect(), next: HashMap::new() } } - if wb.sheet_index(&base).is_none() { - return base; + + /// A valid name for the i-th sheet, unique among the names assigned so far, which it joins. + pub(crate) fn unique(&mut self, name: &str, i: usize) -> String { + let mut base: String = name.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect(); + if base.trim().is_empty() { + base = format!("Sheet{}", i + 1); + } + let name = if self.taken.contains(&base.to_ascii_lowercase()) { + let stem: String = base.chars().take(25).collect(); + let next = self.next.entry(stem.to_ascii_lowercase()).or_insert(2); + let found = (*next..).find(|n| !self.taken.contains(&format!("{stem} ({n})").to_ascii_lowercase())); + match found { + Some(n) => { + *next = n + 1; + format!("{stem} ({n})") + } + None => base, + } + } else { + base + }; + self.taken.insert(name.to_ascii_lowercase()); + name } - (2..).map(|n| format!("{} ({n})", base.chars().take(25).collect::())).find(|n| wb.sheet_index(n).is_none()).unwrap_or(base) } /// Strips a sheet qualifier (`'My Sheet'!$A$1` → `$A$1`). diff --git a/crates/xlsx/src/tests/sheet_names.rs b/crates/xlsx/src/tests/sheet_names.rs index ed1e49e57..37a8b90f7 100644 --- a/crates/xlsx/src/tests/sheet_names.rs +++ b/crates/xlsx/src/tests/sheet_names.rs @@ -35,3 +35,17 @@ fn sheet_ids_survive_a_save() { let (again, _) = crate::read_xlsx(&write_xlsx(&read).unwrap()).unwrap(); assert_eq!(again.sheets.iter().map(|s| s.file_id).collect::>(), ids); } + +/// Repairing repeated names takes one set lookup per candidate, so thousands of sheets sharing +/// a name are renamed without rescanning the workbook for every suffix tried. +#[test] +fn repeated_sheet_names_are_repaired_without_rescans() { + let mut names = crate::read::SheetNames::default(); + assert_eq!(names.unique("Data", 0), "Data"); + assert_eq!(names.unique("data", 1), "data (2)"); + assert_eq!(names.unique("", 2), "Sheet3"); + assert_eq!(names.unique("Data (3)", 3), "Data (3)"); + for i in 4..100_000 { + assert_eq!(names.unique("Data", i), format!("Data ({i})")); + } +} From a30522928729068a56013c7a84950b31a79c6b11 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 16:11:47 -0500 Subject: [PATCH 65/80] PDF export: wrap text with running widths (#3208) wrap_lines split an over-long word by re-measuring and copying the rest of the word for every piece it cut, and only checked its line limit once the word was done, so one long word in a narrow text box cost word length x pieces. Each new word also re-measured the whole line built so far. Line and piece widths are now running sums of glyph widths (the same sum text_width makes), so every character is measured once, and the line limit is checked while a word is being split. The lines produced are unchanged; a test compares them with the old wrapper. --- crates/engine/src/cmd/print/tests.rs | 73 ++++++++++++++++++++++++++++ crates/engine/src/cmd/print/text.rs | 63 +++++++++++++++--------- 2 files changed, 112 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs index cd25606b9..26fca1937 100644 --- a/crates/engine/src/cmd/print/tests.rs +++ b/crates/engine/src/cmd/print/tests.rs @@ -695,6 +695,79 @@ fn bands_unit() { assert!(w.len() > 2); } +/// The wrapper `wrap_lines` replaced, re-measuring strings, as the reference for its lines. +fn wrap_lines_remeasuring(text: &str, font: Font, size: f32, width: f32) -> Vec { + let mut out = Vec::new(); + for para in text.split('\n') { + let mut line = String::new(); + for word in para.split(' ') { + let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; + if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width { + line = cand; + } else { + out.push(std::mem::take(&mut line)); + line = word.to_string(); + } + while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 { + let chars: Vec = line.chars().collect(); + let (mut lo, mut hi) = (1usize, chars.len()); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + let cut: String = chars[..mid].iter().collect(); + if gridcraft_pdf::text_width(font, size, &cut) <= width { + lo = mid; + } else { + hi = mid - 1; + } + } + if lo == chars.len() { + break; + } + out.push(chars[..lo].iter().collect()); + line = chars[lo..].iter().collect(); + } + if out.len() > 2000 { + return out; + } + } + out.push(line); + } + out +} + +#[test] +fn wrap_lines_matches_the_remeasuring_wrapper() { + let texts = [ + "the quick brown fox jumps", + "Supercalifragilistic", + "a b c\n\nverylongwordthatneedsbreaking short\nend ", + "Wide WWWWWWWWWW words, iiiiiiii narrow ones; mixed MMMMiiiiMMMM", + "€ ü ß — unicode Ünïcödé text", + "", + " ", + ]; + for text in texts { + for font in [Font::Helvetica, Font::HelveticaBold, Font::Courier] { + for (size, width) in [(10.0, 50.0), (10.0, 20.0), (11.0, 1.0), (8.0, 300.0), (12.0, 0.0)] { + assert_eq!(wrap_lines(text, font, size, width), wrap_lines_remeasuring(text, font, size, width), "{text:?} {font:?} {size} {width}"); + } + } + } +} + +#[test] +fn wrapping_a_long_word_in_a_narrow_box_stops_at_the_line_limit() { + // One character per line: the old wrapper re-measured and copied the rest of the word for + // each of the 100,000 lines before checking the limit. + let word = "w".repeat(100_000); + let lines = wrap_lines(&word, Font::Helvetica, 10.0, 1.0); + assert!(lines.len() <= 2002, "{}", lines.len()); + assert!(lines.iter().all(|l| l == "w")); + // A long paragraph in a very wide box is one line. + let para = vec!["word"; 50_000].join(" "); + assert_eq!(wrap_lines(¶, Font::Helvetica, 10.0, 1.0e9), vec![para.clone()]); +} + #[test] fn center_across_spans_following_empty_center_across_cells() { let mut styles = StyleTable::default(); diff --git a/crates/engine/src/cmd/print/text.rs b/crates/engine/src/cmd/print/text.rs index 6f71699ce..0523aa4ff 100644 --- a/crates/engine/src/cmd/print/text.rs +++ b/crates/engine/src/cmd/print/text.rs @@ -3,42 +3,57 @@ use super::{CellRef, Color, Font, HAlign, MAX_COLS, Page, Point, RangeRef, Sheet const PAD_PX: f32 = 2.0; +/// Most lines [`wrap_lines`] returns (it may return one more, the rest of a paragraph). +const MAX_WRAP_LINES: usize = 2000; + /// Word-wraps `text` to `width` points. +/// +/// Widths are kept as running sums of glyph widths (the sum `text_width` makes), so each +/// character is measured once: re-measuring the line for every word, or the rest of an +/// over-long word for every piece cut from it, made a long paragraph or word quadratic. pub(super) fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec { + let units_of = |s: &str| -> u64 { s.chars().map(|c| u64::from(font.char_width(c))).sum() }; + let fits = |units: u64| units as f32 * size / 1000.0 <= width; + let space = units_of(" "); let mut out = Vec::new(); for para in text.split('\n') { let mut line = String::new(); + let mut line_units: u64 = 0; for word in para.split(' ') { - let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width { - line = cand; + let word_units = units_of(word); + if line.is_empty() { + line.push_str(word); + line_units = word_units; + } else if fits(line_units + space + word_units) { + line.push(' '); + line.push_str(word); + line_units += space + word_units; } else { out.push(std::mem::take(&mut line)); - line = word.to_string(); + line.push_str(word); + line_units = word_units; } - // Break a single over-long word by characters. The cut that fits is found by binary - // search (each try measures one candidate prefix), so an over-long word costs - // O(n log n) measurements instead of re-measuring the whole cut per character for - // every emitted line (`a` × 100k pasted text would be cubic worst case). - while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 { - let chars: Vec = line.chars().collect(); - let (mut lo, mut hi) = (1usize, chars.len()); - while lo < hi { - let mid = lo + (hi - lo).div_ceil(2); - let cut: String = chars[..mid].iter().collect(); - if gridcraft_pdf::text_width(font, size, &cut) <= width { - lo = mid; - } else { - hi = mid - 1; + // Break a single over-long word by characters: each piece is the longest that fits + // (at least one character). + if !fits(line_units) && line.chars().nth(1).is_some() { + let mut piece = String::new(); + let mut units: u64 = 0; + for c in line.chars() { + let w = u64::from(font.char_width(c)); + if !piece.is_empty() && !fits(units + w) { + out.push(std::mem::take(&mut piece)); + units = 0; + if out.len() > MAX_WRAP_LINES { + return out; + } } + piece.push(c); + units += w; } - if lo == chars.len() { - break; - } - out.push(chars[..lo].iter().collect()); - line = chars[lo..].iter().collect(); + line = piece; + line_units = units; } - if out.len() > 2000 { + if out.len() > MAX_WRAP_LINES { return out; } } From 014f030a15d586ddc0ff5d775fa51991cabb8907 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 18:10:48 -0500 Subject: [PATCH 66/80] engine: PivotTable filters test each record against a set of selected labels (#3279) Every source record scanned the filter's whole selection, so a large selection multiplied by many records was slow even when nothing passed. Each filter's labels are lowercased into a set once per computation. --- crates/engine/src/cmd/pivot/tests.rs | 13 +++++++++++++ crates/engine/src/pivot/compute.rs | 11 ++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/pivot/tests.rs b/crates/engine/src/cmd/pivot/tests.rs index 308d30f4a..3748d0705 100644 --- a/crates/engine/src/cmd/pivot/tests.rs +++ b/crates/engine/src/cmd/pivot/tests.rs @@ -168,6 +168,19 @@ fn filters_and_report_filter_rows() { assert!(s.execute("pivot.filter", json!({"field": "Product", "selected": []})).is_err()); } +/// A selection of the most labels allowed (100,000) is looked up once per record, not scanned: the +/// report still filters case-insensitively. +#[test] +fn a_huge_filter_selection_is_matched_through_a_set() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap(); + let mut selected: Vec = (0..99_999).map(|i| format!("Region {i}")).collect(); + selected.push("EAST".into()); + s.execute("pivot.filter", json!({"field": "Region", "selected": selected})).unwrap(); + let gt = find_row(&s, "Grand Total").unwrap(); + assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(415.0)); +} + #[test] fn show_values_as() { let mut s = sales(); diff --git a/crates/engine/src/pivot/compute.rs b/crates/engine/src/pivot/compute.rs index 0ffa8cae5..90e81408d 100644 --- a/crates/engine/src/pivot/compute.rs +++ b/crates/engine/src/pivot/compute.rs @@ -77,6 +77,8 @@ struct AggregateInput<'a> { extra: &'a [usize], filters: &'a [(usize, &'a PivotFilter, PivotDateGroup, bool)], filter_fmts: &'a [String], + /// Each filter's selected labels, ASCII-lowercased, so a record's test is one probe. + filter_sets: &'a [Option>], row_fmts: &'a [String], col_fmts: &'a [String], empty: bool, @@ -99,10 +101,10 @@ fn aggregate_record( ri: usize, rec: &[Value], ) -> Result<(), String> { - for ((c, field, group, _), fmt) in input.filters.iter().zip(input.filter_fmts) { - if let Some(selected) = &field.selected { + for (((c, _, group, _), fmt), selected) in input.filters.iter().zip(input.filter_fmts).zip(input.filter_sets) { + if let Some(selected) = selected { let label = item_label(input.wb, rec.get(*c).unwrap_or(&Value::Empty), *group, fmt); - if !selected.iter().any(|value| value.eq_ignore_ascii_case(&label)) { + if !selected.contains(&label.to_ascii_lowercase()) { return Ok(()); } } @@ -475,6 +477,8 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro // ---- aggregate let filter_fmts: Vec = filters.iter().map(|f| fmt_of(f.0)).collect(); + let filter_sets: Vec>> = + filters.iter().map(|(_, field, _, _)| field.selected.as_ref().map(|s| s.iter().map(|v| v.to_ascii_lowercase()).collect())).collect(); let row_fmts: Vec = rows.iter().map(|(c, _)| fmt_of(*c)).collect(); let col_fmts: Vec = cols.iter().map(|(c, _)| fmt_of(*c)).collect(); let aggregate = aggregate_source( @@ -488,6 +492,7 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro extra: &extra, filters: &filters, filter_fmts: &filter_fmts, + filter_sets: &filter_sets, row_fmts: &row_fmts, col_fmts: &col_fmts, empty, From 74111e5a9335011e2fe029866512054b1d841cd6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:50:40 -0500 Subject: [PATCH 67/80] check: charge relationship parts to the package's decompression budget (#3185) * check: charge relationship parts to the package's decompression budget * address review: charge bytes read before a package part decompression error --- apps/gridcraft-cli/src/check/package.rs | 78 ++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/apps/gridcraft-cli/src/check/package.rs b/apps/gridcraft-cli/src/check/package.rs index 6b97fa495..c0def7037 100644 --- a/apps/gridcraft-cli/src/check/package.rs +++ b/apps/gridcraft-cli/src/check/package.rs @@ -6,6 +6,10 @@ const MAX_XML_PART: u64 = 64 * 1024 * 1024; /// Most zip entries looked at while validating a written package. const MAX_ENTRIES: usize = 200_000; +/// Decompressed bytes that comparing one package's relationship kinds may read in total: the +/// relationship parts themselves and the targets whose content is checked. +const RELATIONSHIP_BUDGET: u64 = 512 * 1024 * 1024; + /// Relationship kinds (lower case) whose absence after a save isn't a loss: the calculation chain is /// a cache Excel rebuilds, and GridCraft never writes it. pub(super) const REBUILT_KINDS: [&str; 1] = ["calcchain"]; @@ -117,6 +121,30 @@ fn read_zip_part(zip: &mut Zip<'_>, name: &str) -> Result, String> { Ok(buf) } +enum PartError { + /// Reading the part would spend more than the remaining budget. + OverBudget, + Unreadable, +} + +/// Reads a part like [`read_zip_part`], charging the decompressed bytes to `budget`. +fn read_zip_part_within(zip: &mut Zip<'_>, name: &str, budget: &mut u64) -> Result, PartError> { + let f = zip.by_name(name).map_err(|_| PartError::Unreadable)?; + // One byte past what is left shows that the part doesn't fit. + let limit = MAX_XML_PART.min(budget.saturating_add(1)); + let mut buf = Vec::new(); + // Bytes read before a decompression error still count against the budget. + let res = f.take(limit).read_to_end(&mut buf); + let read = buf.len() as u64; + if read > *budget { + *budget = 0; + return Err(PartError::OverBudget); + } + *budget -= read; + res.map_err(|_| PartError::Unreadable)?; + Ok(buf) +} + /// Whether the producer flagged its cached results as stale with ``: /// libraries that don't calculate write it, and store 0 or nothing as every formula's value. pub(super) fn full_calc_on_load(bytes: &[u8]) -> bool { @@ -232,19 +260,30 @@ pub fn validate_package(bytes: &[u8]) -> Result, String> { /// nothing are left out, as a save may rightly drop them: a link to a place in the workbook /// (`#Sheet1!A1`, written as a cell's `location`) and a target part with nothing in it. pub(super) fn relationship_kinds(bytes: &[u8]) -> Result, String> { + relationship_kinds_within(bytes, RELATIONSHIP_BUDGET) +} + +/// [`relationship_kinds`] reading at most `budget` decompressed bytes. `Err` once the relationship +/// parts alone need more: their kinds can't be listed completely. +fn relationship_kinds_within(bytes: &[u8], mut budget: u64) -> Result, String> { let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?; let names = zip_names(&mut zip); let by_key: HashMap = names.iter().map(|n| (part_key(n), n)).collect(); // Several relationships name the same part; each fresh check decompressed it again, so // repeated references to one highly compressible part multiplied the work. The verdict is // remembered per (part, named content) and the decompression the package can cause is - // budgeted as a whole (finding: repeated decompression). + // budgeted as a whole, the relationship parts included: many large, unreferenced rels parts + // would otherwise each spend a full per-part allowance. let mut verdicts: HashMap<(String, &'static str), bool> = HashMap::new(); - let mut budget: u64 = 512 * 1024 * 1024; let mut kinds = BTreeMap::new(); for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) { let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue }; - let Ok(els) = read_zip_part(&mut zip, n).and_then(|xml| xml_elements(&xml)) else { continue }; + let xml = match read_zip_part_within(&mut zip, n, &mut budget) { + Ok(xml) => xml, + Err(PartError::OverBudget) => return Err(format!("{n}: relationship check stopped at its decompression limit")), + Err(PartError::Unreadable) => continue, + }; + let Ok(els) = xml_elements(&xml) else { continue }; for (_, attrs) in els.iter().filter(|(el, _)| el == "Relationship") { let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) else { continue }; let target = attr(attrs, "Target").unwrap_or(""); @@ -329,3 +368,36 @@ fn holds_something_uncached(zip: &mut Zip<'_>, name: &str, marker: &'static str, buf.clear(); } } + +#[cfg(test)] +mod tests { + use std::io::Write as _; + + use super::*; + + /// A package with `count` relationship parts of `size` bytes each, none of them describing an + /// existing part. + fn many_rels(count: usize, size: usize) -> Vec { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + let head = r#""#; + let tail = ""; + let filler = " ".repeat(size.saturating_sub(head.len() + tail.len())); + for i in 0..count { + z.start_file(format!("xl/_rels/part{i}.xml.rels"), opts).unwrap(); + z.write_all(head.as_bytes()).unwrap(); + z.write_all(filler.as_bytes()).unwrap(); + z.write_all(tail.as_bytes()).unwrap(); + } + z.finish().unwrap().into_inner() + } + + #[test] + fn relationship_parts_count_against_the_decompression_budget() { + // Each part fits the per-part allowance, but together they need more than the budget. + let bytes = many_rels(8, 64 * 1024); + assert!(relationship_kinds_within(&bytes, 256 * 1024).is_err()); + let kinds = relationship_kinds_within(&bytes, 1024 * 1024).unwrap(); + assert_eq!(kinds.get("custom").map(String::as_str), Some("custom")); + } +} From c0ea297615c8a48f20797e7204541d1cf29dc8bc Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:51:11 -0500 Subject: [PATCH 68/80] calc: cap the text one & over arrays may build in all (#3189) * calc: cap the text one & over arrays may build in all * address review: concatenation over arrays stops at the byte budget without reserving the whole broadcast --- crates/calc/src/eval/array.rs | 80 ++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/crates/calc/src/eval/array.rs b/crates/calc/src/eval/array.rs index 67662c83c..39a6e0f53 100644 --- a/crates/calc/src/eval/array.rs +++ b/crates/calc/src/eval/array.rs @@ -86,6 +86,30 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value } } +/// [`zip2`] that stops at the first cell `f` refuses (`None`), before the rest is built: the +/// result grows as cells are made rather than being reserved for the whole broadcast up front. +fn try_zip2(a: &Value, b: &Value, mut f: impl FnMut(&Value, &Value) -> Option) -> Option { + Some(match (a, b) { + (Value::Array(x), Value::Array(y)) => { + let rows = broadcast_dim(x.rows, y.rows); + let cols = broadcast_dim(x.cols, y.cols); + if broadcast_len(rows, cols).is_none() { + return Some(Value::Error(CellError::Num)); + } + let mut data = Vec::new(); + for r in 0..rows { + for c in 0..cols { + data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c))?); + } + } + Value::from(Array { rows, cols, data }) + } + (Value::Array(x), s) => Value::from(Array { rows: x.rows, cols: x.cols, data: x.data.iter().map(|v| f(v, s)).collect::>()? }), + (s, Value::Array(y)) => Value::from(Array { rows: y.rows, cols: y.cols, data: y.data.iter().map(|v| f(s, v)).collect::>()? }), + (x, y) => f(x, y)?, + }) +} + /// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`]. /// Each operand passes its own size cap, but their broadcast product can be far larger /// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating. @@ -135,10 +159,36 @@ pub(super) fn single(v: Value) -> Option { } } +/// Text bytes that one `&` over arrays may produce in all. Each piece is capped on its own, but a +/// compact formula can broadcast a long text across millions of cells +/// (`SEQUENCE(4000,4000)&REPT("x",30000)` is 480 GB): past this the whole result is `#NUM!`, the +/// error for a result too large to hold, before the rest is built. +pub(super) const MAX_CONCAT_ARRAY_BYTES: usize = 512 * 1024 * 1024; + pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value { + if op == BinOp::Concat && (matches!(x, Value::Array(_)) || matches!(y, Value::Array(_))) { + return concat_arrays(x, y, sys, MAX_CONCAT_ARRAY_BYTES); + } zip2(x, y, |a, b| binary_scalar(op, a, b, sys)) } +/// `x & y` with at least one array operand, giving `#NUM!` once the texts built so far pass `budget` +/// bytes. +fn concat_arrays(x: &Value, y: &Value, sys: gridcraft_core::DateSystem, budget: usize) -> Value { + let mut used = 0usize; + try_zip2(x, y, |a, b| { + let v = binary_scalar(BinOp::Concat, a, b, sys); + if let Value::Text(t) = &v { + used = used.saturating_add(t.len()); + if used > budget { + return None; + } + } + Some(v) + }) + .unwrap_or(Value::Error(CellError::Num)) +} + fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value { if let Value::Error(e) = a { return Value::Error(*e); @@ -187,7 +237,35 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSyste #[cfg(test)] mod tests { - use super::{MAX_CELLS, broadcast_len}; + use super::{Array, BinOp, CellError, MAX_CELLS, Value, broadcast_len, concat_arrays, try_zip2}; + + /// The broadcast stops at the first refused cell: nothing after it is visited. + #[test] + fn a_refused_cell_stops_the_broadcast() { + let col = Value::from(Array::column((0..4_000).map(|i| Value::number(i as f64)).collect())); + let row = Value::from(Array { rows: 1, cols: 4_000, data: (0..4_000).map(|i| Value::number(i as f64)).collect() }); + let mut calls = 0; + let r = try_zip2(&col, &row, |a, _| { + calls += 1; + (calls <= 10).then(|| a.clone()) + }); + assert_eq!((r, calls), (None, 11)); + } + + #[test] + fn concatenating_arrays_stops_at_the_byte_budget() { + let sys = gridcraft_core::DateSystem::default(); + let col = Value::from(Array::column((0..100).map(|i| Value::number(i as f64)).collect())); + let long = Value::text("x".repeat(100)); + // 100 cells of 101 or 102 bytes each: about 10 KB in all. + let r = concat_arrays(&col, &long, sys, 20_000); + assert!(matches!(&r, Value::Array(a) if a.data.len() == 100), "{r:?}"); + assert_eq!(concat_arrays(&col, &long, sys, 5_000), Value::Error(CellError::Num)); + assert_eq!(concat_arrays(&long, &col, sys, 5_000), Value::Error(CellError::Num)); + // Through the operator, a small result is unaffected. + let r = super::binary_values(BinOp::Concat, &col, &Value::text("a"), sys); + assert!(matches!(&r, Value::Array(a) if a.data.first() == Some(&Value::text("0a"))), "{r:?}"); + } #[test] fn broadcast_len_caps_at_max_cells() { From db05400e67aed3831e5368a4991a12bfe3110be6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:51:29 -0500 Subject: [PATCH 69/80] calc: list each dependent once when a change spreads (#3190) * calc: list each dependent once when a change spreads * calc: check the dependency closure against a walk of every cell Random graphs of single-cell, narrow and wide range reads (some removed or unsettled), random seeds and changed areas: the closure with shared taken records and area lookups equals the one listing every dependent of every changed cell. --- crates/calc/src/recalc.rs | 192 +++++++++++++++++++++++++++++--- crates/calc/src/recalc/graph.rs | 81 ++++++++++++++ 2 files changed, 259 insertions(+), 14 deletions(-) diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 63ac533de..160e0d60d 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -24,7 +24,7 @@ mod pass_host; #[cfg(test)] use graph::Intervals; pub use graph::{FxHasher, Graph, Key}; -use graph::{HashMap, HashSet, Node}; +use graph::{HashMap, HashSet, Node, Taken}; use pass_host::{PassHost, spills_as_before}; /// Calculation state kept beside a workbook. @@ -292,17 +292,16 @@ impl Calc { } } } - // Spill areas of changed anchors also change. + // Spill areas of changed anchors also change. They stay rectangles: one seed per spilled + // cell listed the formulas reading the area once per cell. + let mut areas: Vec<(usize, RangeRef)> = cleared; for &k in changed { if let Some(r) = wb.sheet(k.0).and_then(|s| s.spill_ranges.get(&k.1)) { - seeds.extend(r.iter().map(|c| (k.0, c))); + areas.push((k.0, *r)); } } - for (si, r) in &cleared { - seeds.extend(r.iter().map(|c| (*si, c))); - } let t0 = prof_now(); - let dirty = self.dirty_closure(wb, &seeds); + let dirty = self.dirty_closure(wb, &seeds, &areas); let t1 = prof_now(); let n = dirty.len(); self.run(wb, dirty, false); @@ -311,16 +310,39 @@ impl Calc { } } - fn dirty_closure(&self, _wb: &Workbook, seeds: &[Key]) -> Vec { + /// The formulas to recalculate after `seeds` and every cell of `areas` changed: the seeds + /// that are formulas, and everything that reads them, directly or through other formulas. + fn dirty_closure(&self, wb: &Workbook, seeds: &[Key], areas: &[(usize, RangeRef)]) -> Vec { let mut seen: HashSet = HashSet::default(); let mut queue: VecDeque = VecDeque::new(); + // Each range-index entry is listed once in the whole walk: many changed cells under one + // broad reference (or many formulas inside one) would otherwise list every formula reading + // it once per cell, which squared the work. + let mut taken = Taken::default(); + let mut deps = Vec::new(); for k in seeds { if self.graph.nodes.contains_key(k) && seen.insert(*k) { queue.push_back(*k); } - let mut deps = Vec::new(); - self.graph.dependents(k.0, k.1, &mut deps); - for d in deps { + deps.clear(); + self.graph.dependents_untaken(k.0, k.1, &mut taken, &mut deps); + for d in deps.drain(..) { + if seen.insert(d) { + queue.push_back(d); + } + } + } + for &(si, r) in areas { + if let Some(sh) = wb.sheet(si) { + for (c, _) in sh.cells.iter_range(r) { + if self.graph.nodes.contains_key(&(si, c)) && seen.insert((si, c)) { + queue.push_back((si, c)); + } + } + } + deps.clear(); + self.graph.dependents_of_range(si, r, &mut deps); + for d in deps.drain(..) { if seen.insert(d) { queue.push_back(d); } @@ -331,10 +353,9 @@ impl Calc { queue.push_back(*k); } } - let mut deps = Vec::new(); while let Some(k) = queue.pop_front() { deps.clear(); - self.graph.dependents(k.0, k.1, &mut deps); + self.graph.dependents_untaken(k.0, k.1, &mut taken, &mut deps); for d in deps.drain(..) { if seen.insert(d) { queue.push_back(d); @@ -411,7 +432,7 @@ impl Calc { if spill_changes.is_empty() { break; } - dirty = self.dirty_closure(wb, &spill_changes); + dirty = self.dirty_closure(wb, &spill_changes, &[]); } // Loops among formulas the run did not evaluate stay listed while they are still formulas. // Iterative calculation reports none. @@ -691,6 +712,149 @@ pub(crate) mod tests { assert!(visited < 64, "visited {visited} nodes for no match"); } + #[test] + fn untaken_queries_list_each_interval_once_in_all() { + // 1,000 ranges over every row of 5,000: point queries at every row used to list all 1,000 + // each time (5 million matches); sharing `taken`, each range is listed once in all. + let n = 5_000u32; + let mut iv: Intervals = Intervals::default(); + for t in 0..1_000 { + iv.insert(0, n, t); + } + for r in 0..n { + iv.insert(r, r, 10_000 + r); + } + iv.settle(); + let mut taken = Vec::new(); + let mut listed = Vec::new(); + for row in 0..=n + 3 { + iv.query_untaken(row, row, &mut taken, &mut |t| { + listed.push(*t); + true + }); + } + listed.sort_unstable(); + let want: Vec = (0..1_000).chain(10_000..10_000 + n).collect(); + assert_eq!(listed, want); + // An entry the caller declines is offered again. + let mut taken = Vec::new(); + let mut offered = 0; + for _ in 0..3 { + iv.query_untaken(7, 7, &mut taken, &mut |t| { + if *t == 10_007 { + offered += 1; + } + *t != 10_007 + }); + } + assert_eq!(offered, 3); + } + + #[test] + fn dirty_closure_matches_a_walk_of_every_cell() { + // Random formulas over single cells, narrow ranges and wide (over 64 columns) ranges, some + // removed or left unsettled: the closure equals the one found by listing every dependent + // of every changed cell, area cells included, as before `taken` and areas existed. + use super::{HashSet, Node}; + use crate::eval::Area; + use std::collections::VecDeque; + let mut s = 0x2545_F491_4F6C_DD1D; + for case in 0..40 { + let mut wb = Workbook::new(); + let mut calc = Calc::new(); + let pick = |s: &mut u64, n: u64| (rng(s) % n) as u32; + let mut keys: Vec = Vec::new(); + for _ in 0..300 { + let k = (0, CellRef::new(pick(&mut s, 120), pick(&mut s, 150))); + let mut areas = Vec::new(); + for _ in 0..1 + pick(&mut s, 3) { + let start = CellRef::new(pick(&mut s, 120), pick(&mut s, 150)); + let end = match pick(&mut s, 3) { + 0 => start, + 1 => CellRef::new(start.row + pick(&mut s, 40), start.col + pick(&mut s, 5)), + _ => CellRef::new(start.row + pick(&mut s, 20), start.col + 65 + pick(&mut s, 30)), + }; + areas.push(Area { sheet: 0, range: RangeRef::new(start, end) }); + } + if let Some(sh) = wb.sheet_mut(0) { + sh.cells.set(k.1, Cell::formula(Formula::new("=1"))); + } + calc.graph.insert(k, Node { areas, dynamic: false }); + keys.push(k); + if rng(&mut s).is_multiple_of(7) { + calc.graph.settle(); + } + } + for _ in 0..30 { + let k = keys[(rng(&mut s) % keys.len() as u64) as usize]; + calc.graph.remove(k); + if let Some(sh) = wb.sheet_mut(0) { + sh.cells.remove(k.1); + } + } + if case % 2 == 0 { + calc.graph.settle(); + } + let seeds: Vec = (0..1 + pick(&mut s, 25)).map(|_| (0, CellRef::new(pick(&mut s, 140), pick(&mut s, 220)))).collect(); + let areas: Vec<(usize, RangeRef)> = (0..pick(&mut s, 3)) + .map(|_| { + let start = CellRef::new(pick(&mut s, 120), pick(&mut s, 150)); + (0, RangeRef::new(start, CellRef::new(start.row + pick(&mut s, 30), start.col + pick(&mut s, 80)))) + }) + .collect(); + let got: HashSet = calc.dirty_closure(&wb, &seeds, &areas).into_iter().collect(); + // The walk before: every area cell is a seed, and every cell lists all its dependents. + let mut all = seeds.clone(); + for (si, r) in &areas { + all.extend(r.iter().map(|c| (*si, c))); + } + let mut want: HashSet = HashSet::default(); + let mut queue: VecDeque = VecDeque::new(); + let mut deps = Vec::new(); + for k in &all { + if calc.graph.nodes.contains_key(k) && want.insert(*k) { + queue.push_back(*k); + } + deps.clear(); + calc.graph.dependents(k.0, k.1, &mut deps); + queue.extend(deps.iter().copied().filter(|d| want.insert(*d))); + } + while let Some(k) = queue.pop_front() { + deps.clear(); + calc.graph.dependents(k.0, k.1, &mut deps); + queue.extend(deps.iter().copied().filter(|d| want.insert(*d))); + } + assert_eq!(got, want, "case {case}"); + } + } + + #[test] + fn clearing_a_spill_recalculates_formulas_reading_it_once_each() { + // 300 formulas read the area an anchor spills into; clearing the anchor changes all of it + // and every one of them is recalculated (they were listed once per spilled cell). + let mut wb = Workbook::new(); + let mut calc = Calc::new(); + let set = |wb: &mut Workbook, calc: &mut Calc, at: CellRef, input: &str| { + if let Some(sh) = wb.sheet_mut(0) { + if input.is_empty() { + sh.cells.remove(at); + } else { + sh.cells.set(at, Cell::formula(Formula::new(input))); + } + } + calc.cells_changed(wb, &[(0, at)]); + }; + set(&mut wb, &mut calc, CellRef::new(0, 0), "=SEQUENCE(2000)"); + for r in 0..300 { + set(&mut wb, &mut calc, CellRef::new(r, 2), "=COUNT(A1:A2000)"); + } + assert_eq!(wb.sheet(0).map(|s| s.value(CellRef::new(299, 2))), Some(Value::Number(2000.0))); + set(&mut wb, &mut calc, CellRef::new(0, 0), ""); + for r in 0..300 { + assert_eq!(wb.sheet(0).map(|s| s.value(CellRef::new(r, 2))), Some(Value::Number(0.0)), "row {r}"); + } + } + /// `rows` rows of six numbers (E:J) and `=SUM(E{r}:J{r})` in K. fn sum_rows(rows: u32) -> Workbook { let mut wb = Workbook::new(); diff --git a/crates/calc/src/recalc/graph.rs b/crates/calc/src/recalc/graph.rs index ab034f8cc..f8a100eb3 100644 --- a/crates/calc/src/recalc/graph.rs +++ b/crates/calc/src/recalc/graph.rs @@ -151,6 +151,54 @@ impl Intervals { self.walk(1, 0, self.max_end.len() / 2, p, a, f) } + /// [`Intervals::query`] that skips entries an earlier call with the same `taken` already + /// took (and whole subtrees of them), so that a series of queries lists each interval once in + /// all. `f` answers whether it took the entry; one it declines is offered again later. + /// `taken` belongs to this index as it is now; start it empty. Pending insertions (a few at + /// most after [`Intervals::settle`]) are offered every time. + pub(super) fn query_untaken(&self, a: u32, b: u32, taken: &mut Vec, f: &mut dyn FnMut(&T) -> bool) { + for it in &self.pending { + if it.0 <= b && it.1 >= a { + f(&it.2); + } + } + let p = self.items.partition_point(|it| it.0 <= b); + if p == 0 { + return; + } + if taken.len() != self.max_end.len() { + *taken = vec![false; self.max_end.len()]; + } + self.walk_untaken(1, 0, self.max_end.len() / 2, p, a, taken, f); + } + + /// Returns whether everything under `node` has now been reported (or is dead). + #[allow(clippy::too_many_arguments)] + fn walk_untaken(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, taken: &mut [bool], f: &mut dyn FnMut(&T) -> bool) -> bool { + if taken.get(node).copied().unwrap_or(true) { + return true; + } + if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) { + return false; + } + let done = if hi - lo <= 1 { + match (self.items.get(lo), self.dead.get(lo)) { + (Some(it), Some(false)) if it.1 >= a => f(&it.2), + (Some(_), Some(true)) => true, + _ => false, + } + } else { + let mid = lo + (hi - lo) / 2; + let left = self.walk_untaken(2 * node, lo, mid, p, a, taken, f); + let right = self.walk_untaken(2 * node + 1, mid, hi, p, a, taken, f); + left && right + }; + if done && let Some(slot) = taken.get_mut(node) { + *slot = true; + } + done + } + fn walk(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, f: &mut dyn FnMut(&T)) -> usize { if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) { return 1; @@ -168,6 +216,13 @@ impl Intervals { } } +/// Range-index entries one dependency walk has already listed (see [`Graph::dependents_untaken`]). +#[derive(Default)] +pub(super) struct Taken { + cols: HashMap<(usize, u32), Vec>, + wide: HashMap>, +} + #[derive(Clone, Debug, Default)] pub struct Graph { pub(super) nodes: HashMap, @@ -302,6 +357,32 @@ impl Graph { } } + /// [`Graph::dependents`] leaving out the formulas whose range reads an earlier call with the + /// same `taken` already listed. A walk over many changed cells that a few broad ranges cover + /// then lists those formulas once, instead of once per cell (which squared the work). The + /// graph must not change while `taken` is in use. + pub(super) fn dependents_untaken(&self, sheet: usize, c: CellRef, taken: &mut Taken, out: &mut Vec) { + if let Some(v) = self.cell_deps.get(&(sheet, c)) { + out.extend(v.iter().copied()); + } + if let Some(v) = self.col_deps.get(&(sheet, c.col)) { + v.query_untaken(c.row, c.row, taken.cols.entry((sheet, c.col)).or_default(), &mut |k| { + out.push(*k); + true + }); + } + if let Some(v) = self.wide_deps.get(&sheet) { + // A wide entry is taken once it covers the cell's column too. + v.query_untaken(c.row, c.row, taken.wide.entry(sheet).or_default(), &mut |(c0, c1, k)| { + let hit = *c0 <= c.col && c.col <= *c1; + if hit { + out.push(*k); + } + hit + }); + } + } + /// Formulas that read any cell of `range` (a formula can be listed more than once). pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec) { if range.count() <= 4096 { From 065f05dbeccd98c6bca8f69fcdff9132d96fd732 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:51:58 -0500 Subject: [PATCH 70/80] Security: MODE counts through an index, FREQUENCY bins by binary search (#3198) * functions: MODE counts through an index, FREQUENCY bins by binary search MODE/MODE.SNGL/MODE.MULT searched the list of distinct numbers seen so far for every input, and FREQUENCY scanned the sorted bins for every data value, so both were quadratic in computed arrays. MODE now keys counts by value (-0 folded into 0, NaN never merged) and keeps first-occurrence order; FREQUENCY finds the first bin at or above each value with partition_point over the order it already sorts. Results are unchanged. * address review: FREQUENCY keeps NaN data in the overflow bin and NaN bins empty --- crates/functions/src/stat/descriptive.rs | 29 ++++++++++++++++++++---- crates/functions/src/stat/tests.rs | 20 ++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/functions/src/stat/descriptive.rs b/crates/functions/src/stat/descriptive.rs index f4e4b0160..c862e7605 100644 --- a/crates/functions/src/stat/descriptive.rs +++ b/crates/functions/src/stat/descriptive.rs @@ -50,12 +50,27 @@ pub(super) fn median(v: Vec) -> R { /// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count. fn modes(v: &[f64]) -> Vec { + // Index of each distinct number in `counts`, so counting is linear rather than a search of + // every distinct number so far. Keyed by bits with -0 folded into 0 (they compare equal); NaN + // never equals anything, so it is never merged. + let mut index: std::collections::HashMap = std::collections::HashMap::new(); let mut counts: Vec<(f64, usize)> = Vec::new(); for &x in v { - if let Some(e) = counts.iter_mut().find(|e| e.0 == x) { - e.1 += 1; - } else { + if x.is_nan() { counts.push((x, 1)); + continue; + } + let key = if x == 0.0 { 0 } else { x.to_bits() }; + match index.entry(key) { + std::collections::hash_map::Entry::Occupied(e) => { + if let Some(c) = counts.get_mut(*e.get()) { + c.1 += 1; + } + } + std::collections::hash_map::Entry::Vacant(e) => { + e.insert(counts.len()); + counts.push((x, 1)); + } } } let best = counts.iter().map(|e| e.1).max().unwrap_or(0); @@ -451,11 +466,15 @@ pub(super) fn frequency(a: &[Arg], _: C) -> R { if bins.is_empty() { return col_value(vec![data.len() as f64]); } - let mut order: Vec = (0..bins.len()).collect(); + // a NaN bin equals no value (and would break the ascending order searched below) + let mut order: Vec = (0..bins.len()).filter(|&i| bins.get(i).is_some_and(|b| !b.is_nan())).collect(); order.sort_by(|&i, &j| bins.get(i).copied().unwrap_or(0.0).total_cmp(&bins.get(j).copied().unwrap_or(0.0))); let mut counts = vec![0.0; bins.len() + 1]; for x in data { - let slot = order.iter().find(|&&i| x <= bins.get(i).copied().unwrap_or(f64::INFINITY)).copied().unwrap_or(bins.len()); + // `order` sorts the bins ascending, so the bins `x` exceeds come first: binary search for + // the first bin at or above `x` (the first of equal bins, as a linear scan would find). + let pos = if x.is_nan() { order.len() } else { order.partition_point(|&i| bins.get(i).copied().unwrap_or(f64::INFINITY) < x) }; + let slot = order.get(pos).copied().unwrap_or(bins.len()); if let Some(c) = counts.get_mut(slot) { *c += 1.0; } diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index ba59116cd..efadca8c3 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -617,3 +617,23 @@ fn aggregate_helper() { close(aggregate_values_k(9, &vals, &nv(2.0)), 9.0); is_err(aggregate_values_k(14, &vals, &nv(10.0)), CellError::Num); } + +/// Security: FREQUENCY finds each value's bin by binary search, and MODE counts distinct numbers +/// through an index, so neither is quadratic in its inputs. Results are as before: unsorted and +/// duplicate bins keep their positions (a value goes to the first of equal bins), and tied modes +/// come in first-occurrence order. +#[test] +fn frequency_and_mode_scale_with_their_inputs() { + let f = ev("FREQUENCY", vec![c(&[1.0, 2.0, 2.0, 3.0, 5.0, 9.0, -1.0]), c(&[3.0, 2.0, 2.0, 0.0])]); + assert_eq!(rows_of(&f), vec![vec![nv(1.0)], vec![nv(3.0)], vec![nv(0.0)], vec![nv(1.0)], vec![nv(2.0)]]); + // NaN data goes to the overflow bin, and a NaN bin takes nothing + let f = ev("FREQUENCY", vec![c(&[f64::NAN, 0.5, 2.0]), c(&[f64::NAN, 1.0])]); + assert_eq!(rows_of(&f), vec![vec![nv(0.0)], vec![nv(1.0)], vec![nv(2.0)]]); + let data: Vec = (0..200_000).map(|i| f64::from(i) + 1e6).collect(); + let bins: Vec = (0..200_000).map(f64::from).collect(); + let f = ev("FREQUENCY", vec![c(&data), c(&bins)]); + assert_eq!(rows_of(&f).last(), Some(&vec![nv(200_000.0)])); + let distinct = ev("MODE", vec![c(&data)]); + is_err(distinct, CellError::NA); + assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[3.0, 0.0, 1.0, -0.0, 3.0, 1.0])])), vec![vec![nv(3.0)], vec![nv(0.0)], vec![nv(1.0)]]); +} From 184c0ec84d640e70e803c3c537c8bf840606e860 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:52:28 -0500 Subject: [PATCH 71/80] ods: charge a cell's text when the cell is read, not when its row ends (#3257) The importer kept every cell of a row and only charged their text to the 64 MiB allowance when the row finished, so one row of long cells could hold up to 16,384 MiB before being refused. Text is now charged as each cell finishes; the row charges only its repeats. --- crates/xlsx/src/ods.rs | 11 ++++++++++- crates/xlsx/src/tests/ods_tests.rs | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/xlsx/src/ods.rs b/crates/xlsx/src/ods.rs index 9b7f0743b..b64930211 100644 --- a/crates/xlsx/src/ods.rs +++ b/crates/xlsx/src/ods.rs @@ -470,6 +470,14 @@ impl Import { None => return Err(IoError::TooLarge("ODS row exceeds worksheet width".into())), }; if kept { + // Charge the text a row keeps before the row is done, so a row of many long cells is + // refused as it grows; finish_row charges the row's repeats beyond the first. + if let Value::Text(t) = &cell.value { + self.text = self.text.saturating_add(u64::from(draft.repeat).saturating_mul(t.len() as u64)); + if self.text > MAX_TEXT { + return Err(IoError::TooLarge("ODS expands beyond the import limit (64 MiB of text)".into())); + } + } row.cells.push(RepeatedCell { col: row.col, repeat: draft.repeat, width, height, cell }); } row.col = end; @@ -537,7 +545,8 @@ impl Import { if !entry.cell.is_blank() { self.cells = self.cells.saturating_add(count); if let Value::Text(t) = &entry.cell.value { - self.text = self.text.saturating_add(count.saturating_mul(t.len() as u64)); + let repeats = count.saturating_sub(u64::from(entry.repeat)); + self.text = self.text.saturating_add(repeats.saturating_mul(t.len() as u64)); } } let merged = entry.width > 1 || entry.height > 1; diff --git a/crates/xlsx/src/tests/ods_tests.rs b/crates/xlsx/src/tests/ods_tests.rs index 47c258657..bac55efeb 100644 --- a/crates/xlsx/src/tests/ods_tests.rs +++ b/crates/xlsx/src/tests/ods_tests.rs @@ -200,6 +200,18 @@ fn ods_expansion_bounds_stop_small_repeat_bombs() { "x".repeat(1024) ); assert!(matches!(read_ods(&ods(&text)), Err(IoError::TooLarge(_)))); + // One row of cells each just under the 1 MiB cell limit, past 64 MiB in all: refused while + // the row is read, before it holds them all. The row's end is never reached. + let long = format!(r#""#, "x".repeat(1024 * 1024 - 1)); + let cells = ods(&format!(r#"{}"#, long.repeat(65))); + assert!(matches!(read_ods(&cells), Err(IoError::TooLarge(_)))); + // A row repeated once more than its cells' text allows is refused too, and one that fits reads. + let cell = format!(r#""#, "y".repeat(1024)); + let rows = |n: u32| { + ods(&format!(r#"{cell}"#)) + }; + assert!(read_ods(&rows(65_536)).is_ok()); + assert!(matches!(read_ods(&rows(65_537)), Err(IoError::TooLarge(_)))); } #[test] From a31178f5194ebb538af2728b8621c202d9a971ad Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:53:18 -0500 Subject: [PATCH 72/80] functions: NUMBERVALUE strips trailing percent signs in one pass (#3262) Each trailing % copied the rest of the text again, so a long run of them made one call quadratic. The signs are now counted and cut off in place. --- crates/functions/src/text.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 7f1ba8731..bf4d51d22 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -339,11 +339,11 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R { if body.is_empty() { return num_val(0.0); } - let mut pct = 0; - while let Some(b) = body.strip_suffix('%') { - body = b.to_string(); - pct += 1; - } + // Count the trailing percent signs and cut them off in place: copying the rest once per sign + // made a long run of them quadratic. + let kept = body.trim_end_matches('%').len(); + let pct = body.len() - kept; + body.truncate(kept); let mut seen_dec = false; let mut out = String::new(); for c in body.chars() { @@ -1084,6 +1084,15 @@ mod tests { use crate::util::testutil::*; + /// Trailing percent signs each divide by 100, and a long run of them is cut off in one pass. + #[test] + fn numbervalue_percent_signs() { + close(ev("NUMBERVALUE", vec![t("5%%")]), 0.0005); + close(ev("NUMBERVALUE", vec![t("1,5%"), t(","), t(".")]), 0.015); + let many = format!("1{}", "%".repeat(32_000)); + close(ev("NUMBERVALUE", vec![t(&many)]), 0.0); + } + #[test] fn bahttext() { let bt = |x: f64| ev("BAHTTEXT", vec![n(x)]); From 017c80572cb9592ba3f4228078cb1dd38a462d65 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:53:47 -0500 Subject: [PATCH 73/80] functions: match TEXTSPLIT/TEXTBEFORE/TEXTAFTER delimiters through an index, within a work limit (#3263) * functions: match TEXTSPLIT/TEXTBEFORE/TEXTAFTER delimiters through an index, within a work limit The delimiter matcher compared every delimiter, duplicates included, at every position of the text. Delimiters are now deduplicated and grouped by the character a match is anchored on, and a search that would compare more than 100 million characters returns #VALUE!. * functions: pass float literals in the delimiter test --- crates/functions/src/text.rs | 85 ++++++++++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 18 deletions(-) diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index bf4d51d22..24674a93c 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -1,5 +1,7 @@ //! Text functions. +use std::collections::HashMap; + use gridcraft_core::parse::parse_number_text_in; use gridcraft_core::{CellError, Value, number_to_text}; @@ -541,23 +543,52 @@ fn delimiters(v: &Value) -> R>> { as_array(v).iter().map(|d| d.to_text().map(|s| chars(&s))).collect() } +/// Most delimiter characters compared in one search: text length × delimiters is the formula's +/// to choose, so the search gives up with `#VALUE!` rather than run on. +const MAX_MATCH_WORK: u64 = 100_000_000; + /// Non-overlapping delimiter matches (start, end) in char positions, in ascending order. Scans left /// to right preferring the longest delimiter starting at each position, or (`from_end`) right to /// left preferring the longest delimiter ending at each position. Empty delimiters are ignored unless all -/// delimiters are empty, in which case every position matches with zero length. -fn find_matches(text: &[char], delims: &[Vec], insensitive: bool, from_end: bool) -> Vec<(usize, usize)> { +/// delimiters are empty, in which case every position matches with zero length. Repeated +/// delimiters are tried once, and only those whose first (or, from the end, last) character is the +/// one at hand; a search past [`MAX_MATCH_WORK`] is `#VALUE!`. +fn find_matches(text: &[char], delims: &[Vec], insensitive: bool, from_end: bool) -> R> { let t = if insensitive { fold(text) } else { text.to_vec() }; - let ds: Vec> = delims.iter().filter(|d| !d.is_empty()).map(|d| if insensitive { fold(d) } else { d.clone() }).collect(); + let mut ds: Vec> = delims.iter().filter(|d| !d.is_empty()).map(|d| if insensitive { fold(d) } else { d.clone() }).collect(); if ds.is_empty() { - return (0..=t.len()).map(|i| (i, i)).collect(); + return Ok((0..=t.len()).map(|i| (i, i)).collect()); } + ds.sort_unstable(); + ds.dedup(); + // Delimiters by the character a match is anchored on. + let mut by_char: HashMap> = HashMap::new(); + for d in &ds { + let anchor = if from_end { d.last() } else { d.first() }; + if let Some(&c) = anchor { + by_char.entry(c).or_default().push(d); + } + } + let mut work = 0u64; + let mut longest = |candidates: Option<&Vec<&[char]>>, fits: &dyn Fn(&[char]) -> bool| -> R> { + let mut best = None; + for d in candidates.into_iter().flatten() { + work = work.saturating_add(d.len() as u64); + if work > MAX_MATCH_WORK { + return Err(CellError::Value); + } + if fits(d) { + best = best.max(Some(d.len())); + } + } + Ok(best) + }; let mut out = Vec::new(); if from_end { let mut j = t.len(); while j > 0 { - let best = - ds.iter().filter(|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d.as_slice())).map(Vec::len).max(); - match best { + let at = t.get(j - 1).and_then(|c| by_char.get(c)); + match longest(at, &|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d))? { Some(l) => { out.push((j - l, j)); j -= l; @@ -566,12 +597,12 @@ fn find_matches(text: &[char], delims: &[Vec], insensitive: bool, from_end } } out.reverse(); - return out; + return Ok(out); } let mut i = 0; while i < t.len() { - let best = ds.iter().filter(|d| t.get(i..i + d.len()).is_some_and(|w| w == d.as_slice())).map(Vec::len).max(); - match best { + let at = t.get(i).and_then(|c| by_char.get(c)); + match longest(at, &|d| t.get(i..i + d.len()).is_some_and(|w| w == d))? { Some(l) => { out.push((i, i + l)); i += l; @@ -579,7 +610,7 @@ fn find_matches(text: &[char], delims: &[Vec], insensitive: bool, from_end None => i += 1, } } - out + Ok(out) } fn text_before_after(a: &[Arg], before: bool) -> R { @@ -591,7 +622,7 @@ fn text_before_after(a: &[Arg], before: bool) -> R { if !(0..=1).contains(&mode) || inst == 0 || inst.unsigned_abs() as usize > s.len().max(1) { return Err(CellError::Value); } - let mut m = find_matches(&s, &delims, mode == 1, inst < 0); + let mut m = find_matches(&s, &delims, mode == 1, inst < 0)?; if match_end { if inst > 0 { if m.last().is_none_or(|x| x.0 != s.len()) { @@ -620,18 +651,18 @@ fn textafter(a: &[Arg], _c: &mut dyn Ctx) -> R { text_before_after(a, false) } -fn split_by(s: &[char], delims: &[Vec], insensitive: bool) -> Vec> { +fn split_by(s: &[char], delims: &[Vec], insensitive: bool) -> R>> { if delims.iter().all(Vec::is_empty) { - return vec![s.to_vec()]; + return Ok(vec![s.to_vec()]); } let mut out = Vec::new(); let mut last = 0; - for (st, en) in find_matches(s, delims, insensitive, false) { + for (st, en) in find_matches(s, delims, insensitive, false)? { out.push(s[last..st].to_vec()); last = en; } out.push(s[last..].to_vec()); - out + Ok(out) } fn textsplit(a: &[Arg], _c: &mut dyn Ctx) -> R { @@ -648,12 +679,12 @@ fn textsplit(a: &[Arg], _c: &mut dyn Ctx) -> R { } let pad = if has(a, 5) { scalar(arg(a, 5)?) } else { Value::Error(CellError::NA) }; let mut rows: Vec> = Vec::new(); - for r in split_by(&s, &row_d, mode == 1) { + for r in split_by(&s, &row_d, mode == 1)? { if ignore && r.is_empty() { continue; } let cells: Vec = - split_by(&r, &col_d, mode == 1).into_iter().filter(|c| !(ignore && c.is_empty())).map(|c| c.into_iter().collect()).collect(); + split_by(&r, &col_d, mode == 1)?.into_iter().filter(|c| !(ignore && c.is_empty())).map(|c| c.into_iter().collect()).collect(); if ignore && cells.is_empty() { continue; } @@ -1375,6 +1406,24 @@ mod tests { is_text(ev("TEXTAFTER", vec![t("aaa"), t("aa"), n(1.0)]), "a"); } + /// Thousands of copies of one delimiter are tried once; delimiters that can't start where the + /// scan stands aren't compared; a search over a budget of comparisons is `#VALUE!`. + #[test] + fn delimiter_matching_stays_bounded() { + let text = "ab,".repeat(10_000); + let same: Vec<&str> = vec![","; 50_000]; + let v = ev("TEXTSPLIT", vec![t(&text), av(row_t(&same))]); + assert_eq!(rows_of(&v).first().map(Vec::len), Some(10_001)); + // Distinct long delimiters that start and end like every position: refused, not run. + let long: Vec = (0..5_000).map(|i| format!("a{i}{}", "a".repeat(1_000))).collect(); + let long: Vec<&str> = long.iter().map(String::as_str).collect(); + is_err(ev("TEXTSPLIT", vec![t(&"a".repeat(30_000)), av(row_t(&long))]), CellError::Value); + is_err(ev("TEXTBEFORE", vec![t(&"a".repeat(30_000)), av(row_t(&long)), n(-1.0)]), CellError::Value); + // Longest match wins at a position, from either end. + is_text(ev("TEXTBEFORE", vec![t("x--y-z"), av(row_t(&["-", "--"]))]), "x"); + is_text(ev("TEXTAFTER", vec![t("x--y-z"), av(row_t(&["-", "--"])), n(-1.0)]), "z"); + } + #[test] fn split() { let v = ev("TEXTSPLIT", vec![t("a,b;c,d"), t(","), t(";")]); From 4421bc679002ee8b2a74705c44d4485ee189682f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:54:17 -0500 Subject: [PATCH 74/80] engine: weekday fill series moves by whole weeks (#3272) edit.fillSeries with dateUnit "weekday" stepped one calendar day at a time, so a large step cost work proportional to its size for every cell. It now uses add_weekdays, which moves whole weeks arithmetically. --- crates/engine/src/fill.rs | 17 +++-------------- crates/engine/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index c63d7c983..efa09310a 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -575,20 +575,9 @@ pub fn series(cx: &mut Ctx, sheet: usize, options: SeriesOptions<'_>) -> Result< match (unit, d) { ("month", Some(_)) => (step as i64).checked_mul(i as i64).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v), ("year", Some(_)) => (step as i64).checked_mul(i as i64 * 12).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v), - ("weekday", Some(_)) => { - let mut n = v; - let mut left = step.abs() as i64; - while left > 0 { - n += step.signum(); - let Some(day) = datetime_from_serial(sys, n) else { - return Err(crate::EngineError::Other("The weekday series runs past the supported date range.".into())); - }; - if day.weekday != 0 && day.weekday != 6 { - left -= 1; - } - } - n - } + // Whole weeks at a time: a huge step costs no more than a small one. + ("weekday", Some(_)) => add_weekdays(sys, v, step as i64) + .ok_or_else(|| crate::EngineError::Other("The weekday series runs past the supported date range.".into()))?, _ => v + step, } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fe42b6977..dd7488f19 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3975,6 +3975,24 @@ fn fill_series_weekday_out_of_range_step_errors() { assert_eq!(v(&s, "A2"), Value::Empty); } +/// A step of a million weekdays moves whole weeks at a time instead of visiting every day, and +/// lands where counting day by day would. +#[test] +fn fill_series_weekday_large_step_is_arithmetic() { + let mut s = s(); + // 45005 is Monday 20 March 2023; five weekdays on from a Monday is the next Monday. + s.execute("cell.set", json!({"cell": "A1", "input": "45005"})).unwrap(); + s.execute("edit.fillSeries", json!({"range": "A1:A3", "direction": "columns", "type": "date", "dateUnit": "weekday", "step": 1_000_000})) + .unwrap(); + assert_eq!(v(&s, "A2"), Value::Number(45005.0 + 1_400_000.0)); + assert_eq!(v(&s, "A3"), Value::Number(45005.0 + 2_800_000.0)); + s.execute("cell.set", json!({"cell": "B1", "input": "45009"})).unwrap(); + s.execute("edit.fillSeries", json!({"range": "B1:B3", "direction": "columns", "type": "date", "dateUnit": "weekday", "step": -1})).unwrap(); + // Friday 24 March 2023 back one weekday at a time: Thursday, Wednesday. + assert_eq!(v(&s, "B2"), Value::Number(45008.0)); + assert_eq!(v(&s, "B3"), Value::Number(45007.0)); +} + #[test] fn fill_series_month_leap_year() { let s = series_date("2028-01-31", "A1:A3", "month"); From bebe7de877ac8a816072899c333e01ee66a8d4a6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:55:07 -0500 Subject: [PATCH 75/80] engine: cap header and footer sections at 64 KiB when fields expand (#3284) The 10,000-character header spec was bounded, but each &A, &F or &Z added the whole sheet name or path again, and a JSON workbook's sheet name has no length limit. A field that would take a section past 64 KiB is now left out. --- crates/engine/src/cmd/print/header_footer.rs | 25 ++++++++++++++------ crates/engine/src/cmd/print/tests.rs | 6 +++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/print/header_footer.rs b/crates/engine/src/cmd/print/header_footer.rs index 0f973d7fb..2dede3a2c 100644 --- a/crates/engine/src/cmd/print/header_footer.rs +++ b/crates/engine/src/cmd/print/header_footer.rs @@ -153,6 +153,17 @@ pub struct HfSection { pub size: Option, } +/// Most bytes one section's expanded text may hold. A code like `&A` adds the whole sheet name +/// (or file path) each time it appears, and a JSON workbook's names have no length limit. +const MAX_SECTION_BYTES: usize = 64 * 1024; + +/// Appends `s` unless the section would pass [`MAX_SECTION_BYTES`]; what doesn't fit is left out. +fn push_field(text: &mut String, s: &str) { + if text.len().saturating_add(s.len()) <= MAX_SECTION_BYTES { + text.push_str(s); + } +} + /// Expands a header/footer string into its left, centre and right sections. pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] { let mut out: [HfSection; 3] = Default::default(); @@ -187,14 +198,14 @@ pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] { i += 1 + digits.len(); } } - sec.text.push_str(&n.to_string()); + push_field(&mut sec.text, &n.to_string()); } - 'N' | 'n' => sec.text.push_str(&cx.pages.to_string()), - 'D' | 'd' => sec.text.push_str(&cx.date), - 'T' | 't' => sec.text.push_str(&cx.time), - 'A' | 'a' => sec.text.push_str(&cx.sheet), - 'F' | 'f' => sec.text.push_str(&cx.file), - 'Z' | 'z' => sec.text.push_str(&cx.path), + 'N' | 'n' => push_field(&mut sec.text, &cx.pages.to_string()), + 'D' | 'd' => push_field(&mut sec.text, &cx.date), + 'T' | 't' => push_field(&mut sec.text, &cx.time), + 'A' | 'a' => push_field(&mut sec.text, &cx.sheet), + 'F' | 'f' => push_field(&mut sec.text, &cx.file), + 'Z' | 'z' => push_field(&mut sec.text, &cx.path), '&' => sec.text.push('&'), 'B' | 'b' => sec.bold = !sec.bold, 'I' | 'i' => sec.italic = !sec.italic, diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs index 26fca1937..323060470 100644 --- a/crates/engine/src/cmd/print/tests.rs +++ b/crates/engine/src/cmd/print/tests.rs @@ -414,6 +414,12 @@ fn pdf_contains_text_and_header_codes_expand() { assert_eq!(r.text, "P4/9 9:00 AM"); assert_eq!(expand_header_footer("plain", &cx)[1].text, "plain"); assert_eq!(expand_header_footer("&", &cx)[1].text, ""); + // A megabyte sheet name repeated by 5,000 `&A` codes stops at the section's 64 KiB. + let long = HfContext { sheet: "S".repeat(1024 * 1024), ..cx }; + let [_, c, _] = expand_header_footer(&"&A".repeat(5_000), &long); + assert!(c.text.is_empty(), "the name alone is over the limit"); + let short = HfContext { sheet: "S".repeat(1000), ..Default::default() }; + assert_eq!(expand_header_footer(&"&A".repeat(5_000), &short)[1].text.len(), 65_000); } #[test] From cdd3a59dbc868cd4f8d3d3688fe21a485034482f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:55:35 -0500 Subject: [PATCH 76/80] Treemap rows keep running aggregates; General-fit width is clamped (#3290) --- crates/chart/src/render/kinds.rs | 17 +++++++++-------- crates/numfmt/src/decimal.rs | 5 +++++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/crates/chart/src/render/kinds.rs b/crates/chart/src/render/kinds.rs index 54ce6bec8..10286107b 100644 --- a/crates/chart/src/render/kinds.rs +++ b/crates/chart/src/render/kinds.rs @@ -203,12 +203,11 @@ pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inne // ---------------------------------------------------------------- treemap -fn worst(row: &[f64], side: f64) -> f64 { - let s: f64 = row.iter().sum(); +/// Worst aspect ratio of a row with the given sum, smallest and largest area. +fn worst(s: f64, mn: f64, mx: f64, side: f64) -> f64 { if s <= 0.0 || side <= 0.0 { return f64::INFINITY; } - let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v))); let s2 = s * s; let w2 = side * side; (w2 * mx / s2).max(s2 / (w2 * mn)) @@ -225,6 +224,8 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { let mut out = Vec::with_capacity(items.len()); let mut rem = r; let mut row: Vec<(usize, f64)> = Vec::new(); + // The row's sum, smallest and largest area, kept as items join so each step is O(1). + let (mut sum, mut mn, mut mx) = (0.0f64, f64::INFINITY, 0.0f64); let mut i = 0; let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| { let s: f64 = row.iter().map(|x| x.1).sum(); @@ -256,15 +257,14 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { while let Some(&(j, v)) = items.get(i) { let a = v * scale; let side = rem.w.min(rem.h) as f64; - let cur: Vec = row.iter().map(|x| x.1).collect(); - let mut with = cur.clone(); - with.push(a); - if row.is_empty() || worst(&with, side) <= worst(&cur, side) { + if row.is_empty() || worst(sum + a, mn.min(a), mx.max(a), side) <= worst(sum, mn, mx, side) { row.push((j, a)); + (sum, mn, mx) = (sum + a, mn.min(a), mx.max(a)); i += 1; } else { layout(&row, &mut rem, &mut out); row.clear(); + (sum, mn, mx) = (0.0, f64::INFINITY, 0.0); } } if !row.is_empty() { @@ -279,6 +279,7 @@ pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect(); items.sort_by(|a, b| b.1.total_cmp(&a.1)); let f = Fmt::new(&s.number_format); + let value_of: std::collections::HashMap = items.iter().copied().collect(); for (j, b) in squarify(&items, area) { let fill = point_color(data, j); ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5))); @@ -286,7 +287,7 @@ pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0); ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top)); if chart.data_labels && b.h > LABEL_SIZE * 3.2 { - let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0); + let v = value_of.get(&j).copied().unwrap_or(0.0); let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0); ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top)); } diff --git a/crates/numfmt/src/decimal.rs b/crates/numfmt/src/decimal.rs index 67071d4e0..b6c831f71 100644 --- a/crates/numfmt/src/decimal.rs +++ b/crates/numfmt/src/decimal.rs @@ -136,6 +136,9 @@ pub(crate) fn fixed(a: f64, decimals: usize) -> (String, String) { /// General format squeezed into `max_chars` characters. Excel shows fewer decimals, then /// scientific notation, then "####" when a column is narrow; `None` means the caller paints "####". pub fn format_general_fit(n: f64, max_chars: usize) -> Option { + // The widest fixed rendering of a double is about 330 characters; a larger request only + // pads zero decimals the trim below throws away. + let max_chars = max_chars.min(1024); if !n.is_finite() { return None; } @@ -214,6 +217,8 @@ mod tests { fn general_fit() { assert_eq!(format_general_fit(1e11, 11).as_deref(), Some("1E+11")); assert_eq!(format_general_fit(12345678901.0, 11).as_deref(), Some("12345678901")); + // A huge width neither allocates width-sized padding nor changes the text. + assert_eq!(format_general_fit(1.5, usize::MAX).as_deref(), Some("1.5")); assert_eq!(format_general_fit(123456789012.0, 11).as_deref(), Some("1.23457E+11")); assert_eq!(format_general_fit(0.1 + 0.2, 11).as_deref(), Some("0.3")); assert_eq!(format_general_fit(1.0 / 3.0, 11).as_deref(), Some("0.333333333")); From 22c5dfe0df2cea2e2fceb1189c75fcfa77f2b587 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:56:06 -0500 Subject: [PATCH 77/80] engine: protected sheets gate filter reapply, outline, line sizes and floating pictures; pivot destination resolved before the structure check; user-limited edit ranges stay locked (#3296) --- crates/engine/src/cmd.rs | 8 +++- crates/engine/src/cmd/data/filter.rs | 2 + crates/engine/src/cmd/data/grouping.rs | 17 ++++++++ crates/engine/src/cmd/format/sizes.rs | 11 +++++ crates/engine/src/cmd/insert.rs | 4 ++ crates/engine/src/cmd/pivot/create.rs | 7 +-- crates/engine/src/tests.rs | 59 ++++++++++++++++++++++++++ 7 files changed, 103 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 1148a6b6e..02f6dabca 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -503,10 +503,14 @@ pub(crate) fn refuse_unless_allowed(s: &Session, sheet: usize, what: &str, pick: pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option { let mut needs_password = None; for r in sh.edit_ranges.iter().filter(|r| r.contains(c)) { - if r.password.is_none() || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) { + // A range limited to Windows users or groups (a security descriptor) can't be checked + // here, so without a password it stays locked rather than opening to every editor. + if (r.password.is_none() && r.security_descriptor.is_none()) || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) { return None; } - needs_password.get_or_insert(r.title.as_str()); + if r.password.is_some() { + needs_password.get_or_insert(r.title.as_str()); + } } Some(match needs_password { Some(t) => format!("The range \"{t}\" needs its password before it can be changed (Review › Allow Edit Ranges)."), diff --git a/crates/engine/src/cmd/data/filter.rs b/crates/engine/src/cmd/data/filter.rs index 1b52e4537..437bd7887 100644 --- a/crates/engine/src/cmd/data/filter.rs +++ b/crates/engine/src/cmd/data/filter.rs @@ -270,6 +270,7 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result { let sheet = s.doc()?.wb.active_sheet(); + crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; if let Some(a) = sh.autofilter.as_mut() { @@ -283,6 +284,7 @@ pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result { pub(super) fn reapply(s: &mut Session, _: &Json) -> Result { let sheet = s.doc()?.wb.active_sheet(); + crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; edit(s, |cx| { apply_filter(&mut cx.wb, sheet); cx.structural = true; diff --git a/crates/engine/src/cmd/data/grouping.rs b/crates/engine/src/cmd/data/grouping.rs index d2e183ae1..03761b454 100644 --- a/crates/engine/src/cmd/data/grouping.rs +++ b/crates/engine/src/cmd/data/grouping.rs @@ -21,6 +21,15 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> { } } +/// Outline commands change line visibility and grouping: a protected sheet must allow formatting that axis. +fn refuse_unless_outline_allowed(s: &Session, sheet: usize, rows: bool) -> Result<()> { + if rows { + crate::cmd::refuse_unless_allowed(s, sheet, "Formatting rows", |p| p.format_rows) + } else { + crate::cmd::refuse_unless_allowed(s, sheet, "Formatting columns", |p| p.format_columns) + } +} + pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result { let si = target_sheet(s, p)?; // The selection belongs to the active sheet, so another sheet needs its lines. @@ -28,6 +37,7 @@ pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result { return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet")); } let (rows, a, b) = outline_lines(s, p)?; + refuse_unless_outline_allowed(s, si, rows)?; edit(s, |cx| { let sh = cx.sheet_mut(si)?; let before = outline::summary_before(sh, rows); @@ -58,6 +68,7 @@ pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result { /// + button, saved to XLSX); showing keeps inner groups that were collapsed on their own collapsed. pub(super) fn detail(s: &mut Session, p: &Json, hide: bool) -> Result { let (rows, a, b) = outline_lines(s, p)?; + refuse_unless_outline_allowed(s, s.doc()?.wb.active_sheet(), rows)?; let want = p.get("level").and_then(Json::as_u64).map(|l| l.min(outline::MAX_LEVEL as u64) as u8); edit(s, |cx| { let si = cx.sheet_index(); @@ -123,6 +134,12 @@ pub(super) fn show_levels(s: &mut Session, p: &Json) -> Result { msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(), }); } + let active = s.doc()?.wb.active_sheet(); + for (n, is_rows) in [(rows, true), (cols, false)] { + if n.is_some() { + refuse_unless_outline_allowed(s, active, is_rows)?; + } + } edit(s, |cx| { let si = cx.sheet_index(); let sh = cx.sheet_mut(si)?; diff --git a/crates/engine/src/cmd/format/sizes.rs b/crates/engine/src/cmd/format/sizes.rs index 3335d6100..c79327678 100644 --- a/crates/engine/src/cmd/format/sizes.rs +++ b/crates/engine/src/cmd/format/sizes.rs @@ -9,6 +9,17 @@ use super::{ /// sheet is `sheet`, else the prefix, else the active one. The selection belongs to the active /// sheet, so another sheet needs explicit lines. fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> { + let (sheet, lines) = resolve_lines(s, p, rows)?; + // Sizes and visibility are formatting of the line, which a protected sheet must allow. + if rows { + crate::cmd::refuse_unless_allowed(s, sheet, "Formatting rows", |p| p.format_rows)?; + } else { + crate::cmd::refuse_unless_allowed(s, sheet, "Formatting columns", |p| p.format_columns)?; + } + Ok((sheet, lines)) +} + +fn resolve_lines(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> { let key = if rows { "rows" } else { "cols" }; let mut sheet = target_sheet(s, p)?; if let Some(t) = str_param(p, key) { diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index b713061c2..56e066383 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1100,6 +1100,10 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result { .unwrap_or_else(|| if data.starts_with(&[0x89, b'P', b'N', b'G']) { "image/png".into() } else { "image/jpeg".into() }); let (w, h) = crate::io::image_size(&data).unwrap_or((320, 240)); let d = s.doc()?; + // Sheet protection locks drawing objects, as for a text box's text. + if d.wb.active().is_some_and(|sh| sh.is_protected()) { + return Err(EngineError::Other(PROTECTED.into())); + } let id = d.wb.next_object_id(); let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); let scale = (640.0 / w.max(1) as f32).min(1.0); diff --git a/crates/engine/src/cmd/pivot/create.rs b/crates/engine/src/cmd/pivot/create.rs index ce5f97257..0cec82b54 100644 --- a/crates/engine/src/cmd/pivot/create.rs +++ b/crates/engine/src/cmd/pivot/create.rs @@ -67,9 +67,6 @@ fn field_list(v: Option<&Json>) -> Vec { } pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result { - if s.doc()?.wb.protected_structure && matches!(str_param(p, "destination"), None | Some("new")) { - return Err(other("The workbook is protected and cannot be changed.")); - } let source = source_param(s, p)?; let active = s.doc()?.wb.active_sheet(); // Validate the source now (header names, size). @@ -98,6 +95,10 @@ pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result { let at = RangeRef::parse(body).map(|r| r.start).ok_or_else(|| bad("insert.pivotTable", format!("not a cell: `{dest}`")))?; Some((si, at)) }; + // Decided on the resolved destination: `New`, ` new ` and an empty one all make a sheet. + if target.is_none() && s.doc()?.wb.protected_structure { + return Err(other("The workbook is protected and cannot be changed.")); + } let replace = bool_param(p, "replace").unwrap_or(false); let rows = field_list(p.get("rows")); let columns = field_list(p.get("columns")); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dd7488f19..75e5eac96 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5590,3 +5590,62 @@ fn clearing_links_and_notes_keeps_allow_edit_ranges() { let sh = s.doc().unwrap().wb.active().unwrap().clone(); assert!(sh.hyperlinks.is_empty() && sh.comments.is_empty()); } + +#[test] +fn protected_sheet_refuses_layout_commands_its_permissions_do_not_allow() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["h"], [1], [2]]})).unwrap(); + s.execute("data.filter", json!({"range": "A1:A3"})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + for (id, params) in [ + ("home.rowHeight", json!({"rows": "1:1", "height": 40})), + ("home.columnWidth", json!({"cols": "A:A", "width": 80})), + ("data.group", json!({"rows": "2:3"})), + ("data.showLevels", json!({"rows": 1})), + ("data.clearFilter", json!({})), + ("data.reapply", json!({})), + ("insert.picture", json!({"base64": "iVBORw0KGgo="})), + ] { + let err = s.execute(id, params).expect_err(id).to_string(); + assert!(err.contains("protected"), "{id}: {err}"); + } + // With the permissions ticked the same layout commands run. + s.execute("review.unprotectSheet", json!({})).unwrap(); + s.execute("review.protectSheet", json!({"formatRows": true, "formatColumns": true, "autofilter": true})).unwrap(); + for (id, params) in [ + ("home.rowHeight", json!({"rows": "1:1", "height": 40})), + ("home.columnWidth", json!({"cols": "A:A", "width": 80})), + ("data.reapply", json!({})), + ] { + s.execute(id, params).unwrap_or_else(|e| panic!("{id}: {e}")); + } +} + +#[test] +fn an_edit_range_limited_to_windows_users_stays_locked() { + use gridcraft_model::EditRange; + let mut s = s(); + let a1 = CellRef::parse("A1").unwrap(); + let range = |sd: Option<&str>| EditRange { + title: "T".into(), + ranges: vec![gridcraft_core::RangeRef::cell(a1)], + password: None, + security_descriptor: sd.map(str::to_string), + }; + let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + sh.edit_ranges.push(range(None)); + assert!(crate::cmd::locked_cell_error(sh, a1, &[]).is_none(), "an open range"); + sh.edit_ranges = vec![range(Some("D:(A;;GA;;;WD)"))]; + assert!(crate::cmd::locked_cell_error(sh, a1, &[]).is_some(), "a user-limited range"); +} + +#[test] +fn pivot_new_sheet_destination_respects_workbook_structure_protection() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["N", 1], ["S", 2]]})).unwrap(); + s.execute("review.protectWorkbook", json!({"on": true})).unwrap(); + for dest in ["new", "NEW", " new ", ""] { + assert!(s.execute("insert.pivotTable", json!({"source": "A1:B3", "destination": dest})).is_err(), "destination {dest:?}"); + } + assert!(s.execute("insert.pivotTable", json!({"source": "A1:B3", "location": "New"})).is_err()); +} From f77ed98cdb47c39b592c6bbd2bb7d05a8585fc3f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:56:56 -0500 Subject: [PATCH 78/80] chart: a series without values takes no category-span entries (#3300) --- crates/chart/src/render/cartesian.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/chart/src/render/cartesian.rs b/crates/chart/src/render/cartesian.rs index 05105dbf4..7fae90a9f 100644 --- a/crates/chart/src/render/cartesian.rs +++ b/crates/chart/src/render/cartesian.rs @@ -96,8 +96,11 @@ fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Ve } else { None }; - let mut row = Vec::with_capacity(ncat); - for j in 0..ncat { + // A series without values has no spans: 255 empty series over 20,000 categories would + // otherwise fill ~5 million entries. + let n = if s.values.is_empty() { 0 } else { ncat }; + let mut row = Vec::with_capacity(n); + for j in 0..n { let v = s.values.get(j).copied().flatten(); let span = match (si.and_then(|p| stacks.get_mut(p)), fam) { (Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| { @@ -450,3 +453,18 @@ pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) } draw_trend_labels(ctx, plot, &trend_labels); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_series_get_no_span_entries() { + let empty = SeriesData::default(); + let full = SeriesData { values: vec![Some(1.0); 3], ..SeriesData::default() }; + let data = ChartData { series: vec![empty.clone(), full, empty], ..ChartData::default() }; + let fams = [Fam::Col; 3]; + let spans = series_spans(&data, &fams, &[false; 3], MAX_CATS); + assert_eq!(spans.iter().map(Vec::len).collect::>(), [0, MAX_CATS, 0]); + } +} From e5f6fa81974df8baa54a8345fa5daed5e419256a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:57:23 -0500 Subject: [PATCH 79/80] ui-egui: reference colours through a map; cap fill-character repeats per cell (#3311) --- crates/ui-egui/src/editor.rs | 11 +++-------- crates/ui-egui/src/grid/quadrant.rs | 6 +++++- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index d3cab75ea..71fd2a170 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -375,7 +375,7 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option, let offset = text.len() - body.len(); let Ok(toks) = gridcraft_engine::formula::lexer::tokenize(body) else { return vec![] }; let mut out: Vec<(usize, usize, RangeRef, Option, usize)> = Vec::new(); - let mut seen: Vec = Vec::new(); + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); let mut i = 0; while i < toks.len() { use gridcraft_engine::formula::lexer::Tok; @@ -407,13 +407,8 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option, continue; }; let key = body.get(start..end).unwrap_or("").replace('$', "").to_ascii_uppercase(); - let idx = match seen.iter().position(|k| *k == key) { - Some(k) => k, - None => { - seen.push(key); - seen.len() - 1 - } - }; + let next = seen.len(); + let idx = *seen.entry(key).or_insert(next); out.push((start + offset, end + offset, range, sheet, idx % REF_COLOR_COUNT)); continue; } diff --git a/crates/ui-egui/src/grid/quadrant.rs b/crates/ui-egui/src/grid/quadrant.rs index 0469c345f..7461c2204 100644 --- a/crates/ui-egui/src/grid/quadrant.rs +++ b/crates/ui-egui/src/grid/quadrant.rs @@ -278,6 +278,9 @@ fn paint_cell_decorations(q: &QuadrantPaint<'_>, c: CellRef, rect: Rect, lk: &Lo false } +/// Most repeats of a fill character laid out in one cell. +const MAX_FILL_REPEATS: usize = 8192; + fn paint_data_bar(p: &egui::Painter, rect: Rect, frac: f32, rgb: [u8; 3], gradient: bool) { let bar = Rect::from_min_size(pos2(rect.left() + 2.0, rect.top() + 2.0), vec2((rect.width() - 4.0) * frac, rect.height() - 4.0)); let col = theme::color32(rgb); @@ -432,7 +435,8 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option Date: Tue, 3 Nov 2026 22:57:52 -0500 Subject: [PATCH 80/80] formula, calc: LET/LAMBDA names looked up through a map; ragged array literals refused before reserving (#3312) --- crates/calc/src/eval.rs | 5 ++++ crates/formula/src/input.rs | 48 ++++++++++++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 7a957f3a4..7f10354da 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -325,6 +325,11 @@ impl<'h> Evaluator<'h> { Expr::Array(rows) => { let h = rows.len(); let w = rows.first().map_or(0, Vec::len); + // A ragged literal is an error either way; one wide row and many empty ones must + // not reserve their rows-times-width product first. + if rows.iter().any(|row| row.len() != w) { + return err(CellError::Value); + } let mut data = Vec::with_capacity(h * w); for row in rows { for el in row { diff --git a/crates/formula/src/input.rs b/crates/formula/src/input.rs index 1c8929739..6d95587a7 100644 --- a/crates/formula/src/input.rs +++ b/crates/formula/src/input.rs @@ -61,7 +61,7 @@ impl FormulaLocale { pub fn parse_input(src: &str, locale: FormulaLocale, known_name: impl Fn(&str) -> bool) -> Result { let mut expr = parser::parse_syntax(src, locale)?; if locale == FormulaLocale::Es { - normalize(&mut expr, &known_name, &mut Vec::new(), 0, false)?; + normalize(&mut expr, &known_name, &mut Bindings::default(), 0, false)?; } Ok(expr) } @@ -96,7 +96,7 @@ pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str) return Ok(crate::print(expr)); } let mut expr = expr.clone(); - normalize(&mut expr, &known_name, &mut Vec::new(), 0, true)?; + normalize(&mut expr, &known_name, &mut Bindings::default(), 0, true)?; let canonical = crate::print(&expr); let tokens = crate::lexer::tokenize(&canonical).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?; let mut out = String::new(); @@ -131,11 +131,47 @@ pub(crate) fn boolean_alias(name: &str) -> Option { } } -fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut Vec, depth: usize, display: bool) -> Result<(), ParseError> { +/// The LET/LAMBDA names in scope, with how many times each is bound, so a lookup is one probe +/// instead of a scan of every binding for each call. +#[derive(Default)] +struct Bindings { + stack: Vec, + counts: std::collections::HashMap, +} + +impl Bindings { + fn len(&self) -> usize { + self.stack.len() + } + + fn push(&mut self, name: &str) { + let key = crate::fold_name(name); + *self.counts.entry(key.clone()).or_default() += 1; + self.stack.push(key); + } + + fn truncate(&mut self, len: usize) { + while self.stack.len() > len { + let Some(key) = self.stack.pop() else { break }; + if let Some(n) = self.counts.get_mut(&key) { + *n -= 1; + if *n == 0 { + self.counts.remove(&key); + } + } + } + } + + fn contains(&self, name: &str) -> bool { + self.counts.contains_key(&crate::fold_name(name)) + } +} + +fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut Bindings, depth: usize, display: bool) -> Result<(), ParseError> { if depth > parser::MAX_DEPTH { return Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 }); } - let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| crate::same_name(n, name)) || known_name(name); + let bound = |name: &str, bindings: &Bindings| bindings.contains(name) || known_name(name); match expr { Expr::Bool(value) if display => { let alias = if *value { "VERDADERO" } else { "FALSO" }; @@ -156,7 +192,7 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut // The new name is not in scope in its own value expression. normalize(value, known_name, bindings, depth + 1, display)?; if let Expr::Name(name) = declaration { - bindings.push(name.clone()); + bindings.push(name); } } } @@ -169,7 +205,7 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut if let Some((body, params)) = args.split_last_mut() { for param in params { if let Expr::Name(name) = param { - bindings.push(name.clone()); + bindings.push(name); } } normalize(body, known_name, bindings, depth + 1, display)?;