From 5e5e131be7e4af8c9a3a4119e882bd341a3a7041 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 03:03:28 +0200 Subject: [PATCH 01/32] Grid: an armed Format Painter pastes onto the next cells clicked or dragged (fixes #1211) --- crates/ui-egui/src/grid.rs | 16 +++++- crates/ui-egui/src/ribbon.rs | 1 + crates/ui-egui/tests/format_painter.rs | 78 ++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/format_painter.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 97b787ef3..9a953c36e 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2258,10 +2258,22 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & // Release. if resp.drag_stopped() || (!pointer.any_down() && app.grid.drag != Drag::None && !resp.dragged()) { match std::mem::take(&mut app.grid.drag) { - Drag::Select if resp.drag_stopped() => { - if let Some(p) = pos { + Drag::Select => { + if resp.drag_stopped() + && let Some(p) = pos + { select_to_pointer(app, geo, sh, wb, p); } + // An armed Format Painter pastes onto the cells just clicked or dragged over + // (not while pointing a formula reference, which also drags a Select). + if app.session.format_painter.is_some() && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) { + let dest = app.session.active().map(|d| d.selection.current().a1()); + if let Some(dest) = dest + && let Err(e) = app.run("edit.formatPainter", json!({"apply": dest})) + { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } } Drag::Fill { target: Some(t) } => { let src = sel.current(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 9c6d89eeb..5fd93c117 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -569,6 +569,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { } let fp_on = app.session.format_painter.is_some(); let fp = toggle_button(ui, Icon::Brush, fp_on, "Format Painter (double-click to keep it on)"); + fp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), fp_on, "Format Painter")); control_keytip(app, ui, &fp, "edit.formatPainter"); if fp.double_clicked() { act(app, "edit.formatPainter", json!({"sticky": true})); diff --git a/crates/ui-egui/tests/format_painter.rs b/crates/ui-egui/tests/format_painter.rs new file mode 100644 index 000000000..1a9c5742c --- /dev/null +++ b/crates/ui-egui/tests/format_painter.rs @@ -0,0 +1,78 @@ +//! Format Painter through the real ribbon button and a click on the grid. + +use egui::accesskit::Toggled; +use egui::{Event, Modifiers, PointerButton, Pos2}; +use egui_kittest::kittest::{NodeT, Queryable}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_engine::model::Style; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["source"]]})).unwrap(); + session.execute("home.bold", json!({"range": "A1", "on": true})).unwrap(); + session.execute("home.fillColor", json!({"range": "A1", "color": "#FFFF00"})).unwrap(); + session.execute("selection.set", json!({"cell": "A1"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn style(h: &Harness, a1: &str) -> Style { + let doc = h.state().session.active().unwrap(); + let sheet = doc.wb.active().unwrap(); + doc.wb.styles.get(sheet.style_id(CellRef::parse(a1).unwrap())).clone() +} + +fn point(h: &Harness, a1: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll).cell_rect(sheet, CellRef::parse(a1).unwrap()).center() +} + +fn pressed(h: &Harness) -> bool { + h.get_by_label("Format Painter").accesskit_node().toggled() == Some(Toggled::True) +} + +fn click_cell(h: &mut Harness, a1: &str) { + let at = point(h, a1); + for event in [ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ] { + h.input_mut().events.push(event); + h.run_steps(1); + } + h.run_steps(2); +} + +#[test] +fn ribbon_button_picks_up_the_format_and_a_click_on_a_cell_pastes_it() { + let mut h = harness(); + let source = style(&h, "A1"); + assert!(source.font.bold); + assert_ne!(style(&h, "C3"), source); + + h.get_by_label("Format Painter").click(); + h.run_steps(2); + assert!(h.state().session.format_painter.is_some(), "the button must arm the painter"); + assert!(pressed(&h), "the armed button must show as pressed"); + + click_cell(&mut h, "C3"); + assert_eq!(style(&h, "C3"), source, "clicking C3 must paste A1's format"); + assert!(h.state().session.format_painter.is_none(), "a single-click painter disarms after one use"); + assert!(!pressed(&h)); +} From b5aacceb11fb760c41ad588160caadbfdc483a64 Mon Sep 17 00:00:00 2001 From: Richard Date: Wed, 14 Oct 2026 13:36:51 +1100 Subject: [PATCH 02/32] Sheet tabs: right-clicking a tab activates its sheet As in Excel, a right-click on an inactive tab now activates that sheet, so the tab menu's Protect/Unprotect Sheet and Select All Sheets act on the sheet that was clicked rather than the one that was active. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A2bJ3LxrZ6YnmppgrfwHwr --- crates/ui-egui/src/tabs.rs | 4 +++- crates/ui-egui/tests/sheet_tabs.rs | 32 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index b77524bb9..ddb083948 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -148,7 +148,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { } else { ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text }); } - if resp.clicked() { + // 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() { diff --git a/crates/ui-egui/tests/sheet_tabs.rs b/crates/ui-egui/tests/sheet_tabs.rs index 7b43cd12b..34d24c854 100644 --- a/crates/ui-egui/tests/sheet_tabs.rs +++ b/crates/ui-egui/tests/sheet_tabs.rs @@ -162,3 +162,35 @@ fn overflow_leaves_the_worksheet_scrollbar_reachable() { assert!(track.left() >= 0.0 && track.right() <= width + 0.5, "{width}: the scrollbar is offscreen: {track:?}"); } } + +#[test] +fn right_clicking_a_tab_activates_its_sheet_so_the_menu_acts_on_it() { + let mut h = harness(1200.0, 0); + h.state_mut().run("sheet.activate", json!({"sheet":3})).unwrap(); + h.state_mut().run("review.protectSheet", json!({})).unwrap(); + h.state_mut().run("sheet.activate", json!({"sheet":0})).unwrap(); + // Tall enough for the whole tab menu to be painted. + h.set_size(vec2(1200.0, 600.0)); + h.run_steps(20); + let (text, _) = tab(&h, "Tab03").unwrap(); + let at = text.center(); + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(20); + assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 3, "right-clicking Tab03 should activate it"); + // The menu now offers Unprotect for the protected sheet that was clicked, not Protect for the one that was active. + let texts: Vec = h + .output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::Text(t) => Some(t.galley.job.text.clone()), + _ => None, + }) + .collect(); + assert!(texts.iter().any(|t| t == "Unprotect Sheet…"), "menu should offer Unprotect Sheet…, painted: {texts:?}"); +} From 07f6fe0b0b386128e16d848479204e59ced85ec2 Mon Sep 17 00:00:00 2001 From: Richard Date: Wed, 14 Oct 2026 16:52:24 +1100 Subject: [PATCH 03/32] Headers: mark hidden columns and rows, split cursor and double-click to unhide (#1247) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A2bJ3LxrZ6YnmppgrfwHwr --- crates/ui-egui/src/grid.rs | 106 +++++++++++++++++++++++++++++++++++-- 1 file changed, 101 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 97b787ef3..f99bf0d5e 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1710,6 +1710,8 @@ pub(crate) fn paint_headers( let full_col = |c: u32| sel.ranges.iter().any(|x| x.is_full_cols() && c >= x.start.col && c <= x.end.col); let full_row = |rr: u32| sel.ranges.iter().any(|x| x.is_full_rows() && rr >= x.start.row && rr <= x.end.row); let _ = bounds; + // Hidden columns and rows leave a double divider where they sit, as in Excel. + let hidden_mark = Stroke::new(1.0, t.header_text.gamma_multiply(0.6)); let mut done_cols = std::collections::HashSet::new(); let mut done_rows = std::collections::HashSet::new(); for (rows, cols, clip) in quads { @@ -1730,6 +1732,12 @@ pub(crate) fn paint_headers( pc.line_segment([pos2(cr.left(), cr.bottom() - 1.0), pos2(cr.right(), cr.bottom() - 1.0)], Stroke::new(2.0, t.accent)); } pc.line_segment([pos2(cr.right() - 0.5, cr.top() + 3.0), pos2(cr.right() - 0.5, cr.bottom())], Stroke::new(1.0, t.header_line)); + let (before, after) = hidden_around(c, |i| sh.col_width(i)); + for x in [before.then_some(cr.left()), after.then_some(cr.right())].into_iter().flatten() { + for dx in [-1.5, 1.5] { + pc.line_segment([pos2(x + dx, cr.top() + 3.0), pos2(x + dx, cr.bottom())], hidden_mark); + } + } let color = if col_sel(c) { t.header_sel_text } else { t.header_text }; pc.text(cr.center(), Align2::CENTER_CENTER, col_to_letters(c), font.clone(), color); } @@ -1751,6 +1759,12 @@ pub(crate) fn paint_headers( pr.line_segment([pos2(cr.right() - 1.0, cr.top()), pos2(cr.right() - 1.0, cr.bottom())], Stroke::new(2.0, t.accent)); } pr.line_segment([pos2(cr.left() + 3.0, cr.bottom() - 0.5), pos2(cr.right(), cr.bottom() - 0.5)], Stroke::new(1.0, t.header_line)); + let (before, after) = hidden_around(rr, |i| sh.row_height(i)); + for y in [before.then_some(cr.top()), after.then_some(cr.bottom())].into_iter().flatten() { + for dy in [-1.5, 1.5] { + pr.line_segment([pos2(cr.left() + 3.0, y + dy), pos2(cr.right(), y + dy)], hidden_mark); + } + } let color = if row_sel(rr) { t.header_sel_text } else { t.header_text }; pr.text(cr.center(), Align2::CENTER_CENTER, (rr as u64 + 1).to_string(), font.clone(), color); } @@ -1769,6 +1783,12 @@ pub(crate) fn paint_headers( p.line_segment([pos2(geo.cells.left() - 0.5, r.top()), pos2(geo.cells.left() - 0.5, r.bottom())], Stroke::new(1.0, t.header_line)); } +/// Whether the line before / after visible line `i` is hidden (zero size), so its header edges +/// get the hidden marker. Line 0 has nothing before it. +fn hidden_around(i: u32, size: impl Fn(u32) -> f32) -> (bool, bool) { + (i > 0 && size(i - 1) <= 0.0, i < u32::MAX && size(i + 1) <= 0.0) +} + // ------------------------------------------------------------------ interaction /// How close (in points) a press must be to an object's bottom-right corner to resize it instead of moving it. @@ -1825,10 +1845,15 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let near_object_corner = app.grid.drag == Drag::None && near_corner; if near_object_corner || matches!(app.grid.drag, Drag::Object { resize: true, .. }) { ctx.set_cursor_icon(CursorIcon::ResizeNwSe); - } else if near_col_edge { - ctx.set_cursor_icon(CursorIcon::ResizeColumn); - } else if near_row_edge { - ctx.set_cursor_icon(CursorIcon::ResizeRow); + } else if near_col_edge || near_row_edge { + // Just past a hidden column or row, Excel shows a split cursor: dragging or + // double-clicking there unhides it. + let hidden = app.grid.drag == Drag::None + && if near_col_edge { hidden_col_edge(geo, sh, p.x).is_some() } else { hidden_row_edge(geo, sh, p.y).is_some() }; + match pointer.hover_pos() { + Some(at) if hidden => paint_split_cursor(&ctx, at, near_col_edge), + _ => ctx.set_cursor_icon(if near_col_edge { CursorIcon::ResizeColumn } else { CursorIcon::ResizeRow }), + } } else if on_handle(p) && app.editor.is_none() { ctx.set_cursor_icon(CursorIcon::Crosshair); if app.grid.drag == Drag::None && !pointer.any_down() { @@ -1944,7 +1969,16 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & if resp.double_clicked() && let Some(p) = pos { - let autofit = if in_col_header { + let unhide = if in_col_header { + hidden_col_edge(geo, sh, p.x).map(|col| ("home.unhideColumns", json!({"cols": RangeRef::cols(col, col).a1()}))) + } else if in_row_header { + hidden_row_edge(geo, sh, p.y).map(|row| ("home.unhideRows", json!({"rows": RangeRef::rows(row, row).a1()}))) + } else { + None + }; + let autofit = if unhide.is_some() { + unhide + } else if in_col_header { col_edge(geo, sh, p.x).map(|(col, _)| { let selected = sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col); ("home.autofitColumnWidth", if selected { json!({}) } else { json!({"cols": RangeRef::cols(col, col).a1()}) }) @@ -2494,6 +2528,36 @@ fn row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<(u32, f32)> { None } +/// The hidden column whose (zero-width) edge is at `x`: only just right of the boundary, as the +/// edge just left of it belongs to the visible column there. +fn hidden_col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option { + col_edge(geo, sh, x).map(|(c, _)| c).filter(|&c| sh.col_width(c) <= 0.0) +} + +fn hidden_row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option { + row_edge(geo, sh, y).map(|(r, _)| r).filter(|&r| sh.row_height(r) <= 0.0) +} + +/// A split cursor (two bars with arrows either side) painted in place of the OS cursor, which +/// has no such shape. Black on a white outline, like the system cursors. +fn paint_split_cursor(ctx: &egui::Context, at: Pos2, cols: bool) { + ctx.set_cursor_icon(CursorIcon::None); + let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("gridcraft.split_cursor"))); + // Drawn for columns (bars vertical); rows swap the axes. + let pt = |x: f32, y: f32| if cols { pos2(at.x + x, at.y + y) } else { pos2(at.x + y, at.y + x) }; + let arrow = |dir: f32| vec![pt(dir * 10.0, 0.0), pt(dir * 6.0, -4.0), pt(dir * 6.0, 4.0)]; + for (width, color) in [(3.0, Color32::WHITE), (1.0, Color32::BLACK)] { + let stroke = Stroke::new(width, color); + for bx in [-1.5, 1.5] { + p.line_segment([pt(bx, -8.0), pt(bx, 8.0)], stroke); + } + p.line_segment([pt(-7.0, 0.0), pt(7.0, 0.0)], stroke); + for dir in [-1.0, 1.0] { + p.add(egui::Shape::convex_polygon(arrow(dir), color, Stroke::new(width - 1.0, color))); + } + } +} + fn is_filter_button(sh: &Sheet, geo: &Geo, p: Pos2) -> bool { let c = geo.cell_at(sh, p); sh.has_filter_button(c) && filter_button_rect(geo, sh, c).contains(p) @@ -3577,6 +3641,38 @@ mod pane_tests { Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), scroll) } + #[test] + fn the_edge_just_past_a_hidden_line_unhides_it() { + let mut sh = Sheet::new("S"); + sh.cols.entry(11).or_default().hidden = true; // L + sh.rows.entry(26).or_default().hidden = true; // 27 + let geo = Geo::new(&sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(2000.0, 1200.0)), vec2(0.0, 0.0)); + let x = geo.x(&sh, 12); // between K and M + assert_eq!(hidden_col_edge(&geo, &sh, x + 2.0), Some(11)); + assert_eq!(hidden_col_edge(&geo, &sh, x - 2.0), None); // K's own edge + assert_eq!(hidden_col_edge(&geo, &sh, geo.x(&sh, 5)), None); + let y = geo.y(&sh, 27); // between 26 and 28 + assert_eq!(hidden_row_edge(&geo, &sh, y + 2.0), Some(26)); + assert_eq!(hidden_row_edge(&geo, &sh, y - 2.0), None); + } + + #[test] + fn headers_mark_the_edges_next_to_hidden_lines() { + let mut sh = Sheet::new("S"); + sh.cols.entry(0).or_default().hidden = true; // A + sh.cols.entry(11).or_default().hidden = true; // L + sh.rows.entry(26).or_default().hidden = true; // 27 + let col = |c| hidden_around(c, |i| sh.col_width(i)); + assert_eq!(col(1), (true, false)); // B, after hidden A + assert_eq!(col(10), (false, true)); // K + assert_eq!(col(12), (true, false)); // M + assert_eq!(col(5), (false, false)); + let row = |r| hidden_around(r, |i| sh.row_height(i)); + assert_eq!(row(25), (false, true)); + assert_eq!(row(27), (true, false)); + assert_eq!(row(0), (false, false)); + } + #[test] fn overlays_skip_cells_scrolled_under_the_frozen_columns() { let mut sh = Sheet::new("S"); From 3177fbe03f29caeb0e2dd93f493027f86ffb6053 Mon Sep 17 00:00:00 2001 From: Pranav Chandra Date: Wed, 14 Oct 2026 20:23:44 +0530 Subject: [PATCH 04/32] ui: show spill-cell formula as greyed out --- crates/engine/src/cmd/inspect.rs | 2 +- crates/model/src/sheet.rs | 6 ++ crates/ui-egui/src/formula_bar.rs | 164 +++++++++++++++++++++++++++--- crates/ui-egui/src/lib.rs | 5 + docs/ui-parity.md | 5 +- 5 files changed, 164 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index f92b48bd9..f373de489 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -130,7 +130,7 @@ fn cell_get(s: &mut Session, p: &Json) -> Result { "text": crate::display::cell_text(&d.wb, sh, c), "input": sh.input_text(c), "picture": sh.cell_pictures.get(&c).map(|picture| json!({"mime": picture.mime, "alt": picture.alt, "bytes": picture.data.len()})), - "spilledFrom": sh.spill_ranges.iter().find(|(a, r)| **a != c && r.contains(c)).map(|(a, _)| a.a1()), + "spilledFrom": sh.spill_anchor(c).map(|a| a.a1()), "spill": sh.spill_ranges.get(&c).map(|r| r.a1()), "style": style, "merge": sh.merge_at(c).map(|m| m.a1()), diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index b404b891c..52e0c08ed 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -170,6 +170,12 @@ impl Sheet { pub fn cell(&self, c: CellRef) -> Option<&Cell> { self.cells.get(c) } + /// The source of a non-anchor dynamic-array spill cell, from the current calc state. + /// Anchors, ordinary cells and cells outside successfully generated spills return `None`. + pub fn spill_anchor(&self, c: CellRef) -> Option { + self.spill_ranges.iter().find(|(anchor, range)| **anchor != c && range.contains(c)).map(|(anchor, _)| *anchor) + } + /// Displayed value of a cell: its own value, or a spilled value. pub fn value(&self, c: CellRef) -> Value { match self.cells.get(c) { diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 7f1ad87fc..0b8fddbec 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -39,20 +39,20 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { ui.painter().rect_stroke(field, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field.shrink2(vec2(6.0, 3.0)))); let id = egui::Id::new("gridcraft.formula_bar"); - let editing_here = app.editor.as_ref().is_some_and(|e| e.from_formula_bar); + let read_only = active_spill_anchor(app).is_some(); if app.editor.is_some() { editor_widget(app, &mut child, id, theme::ui_font(13.0), expanded, field.width() - 12.0); } else { // Show the active cell's content; clicking starts editing in the bar. let text = active_input(app); + let color = if read_only { t.text_disabled } else { t.text }; let resp = child - .add(egui::Label::new(egui::RichText::new(text).font(theme::ui_font(13.0)).color(t.text)).truncate().sense(Sense::click())); + .add(egui::Label::new(egui::RichText::new(text).font(theme::ui_font(13.0)).color(color)).truncate().sense(Sense::click())); let click = child.interact(field, id.with("click"), Sense::click()); - if resp.clicked() || click.clicked() { + if !read_only && (resp.clicked() || click.clicked()) { app.begin_edit(None, true); } } - let _ = editing_here; ui.allocate_space(vec2(avail, h - 6.0)); let chevron = if expanded { Icon::ChevronUp } else { Icon::Chevron }; if crate::widgets::icon_button(ui, chevron, t.text_dim, "Expand formula bar", vec2(20.0, 22.0)).clicked() { @@ -62,6 +62,14 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { }); } +/// 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()?; + let sh = d.wb.active()?; + let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); + sh.spill_anchor(at) +} + fn active_input(app: &SheetApp) -> String { let Some(d) = app.session.active() else { return String::new() }; let Some(sh) = d.wb.active() else { return String::new() }; @@ -69,6 +77,15 @@ fn active_input(app: &SheetApp) -> String { if let Some(picture) = sh.cell_pictures.get(&a) { return if picture.alt.is_empty() { "Picture in cell".into() } else { format!("Picture: {}", picture.alt) }; } + if let Some(anchor) = sh.spill_anchor(a) { + if gridcraft_engine::display::formula_hidden(&d.wb, sh, anchor) { + return String::new(); + } + return sh + .cell(anchor) + .map(|c| crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet)) + .unwrap_or_default(); + } if let Some(c) = sh.cell(a) { if c.formula.is_some() && d.wb.styles.get(c.style).protection.hidden && sh.is_protected() { return String::new(); @@ -81,17 +98,6 @@ fn active_input(app: &SheetApp) -> String { gridcraft_engine::display::edit_text(&d.wb, sh, a) }; } - // A spilled cell shows the anchor's formula greyed out in Excel; we show it plainly. - for (anchor, r) in &sh.spill_ranges { - if r.contains(a) - && let Some(f) = sh.cell(*anchor).and_then(|c| c.formula.as_ref()) - { - if gridcraft_engine::display::formula_hidden(&d.wb, sh, *anchor) { - return String::new(); - } - return crate::formula_locale::display(&format!("={}", f.text), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet); - } - } String::new() } @@ -474,3 +480,131 @@ pub fn function_signature(name: &str) -> Option { pub fn function_description(name: &str) -> Option { function_info(name).and_then(|f| f["description"].as_str().map(str::to_string)) } + +#[cfg(test)] +mod spill_tests { + use super::*; + use gridcraft_engine::Session; + + fn app() -> SheetApp { + let mut s = Session::new(); + s.new_workbook(); + SheetApp::new(s, Default::default()) + } + + fn set(app: &mut SheetApp, cell: &str, input: &str) { + app.run("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + + fn select(app: &mut SheetApp, cell: &str) { + app.cancel_edit(); + app.run("selection.set", json!({"cell": cell})).unwrap(); + } + + fn check(app: &mut SheetApp, cell: &str, text: &str, read_only: bool) { + select(app, cell); + assert_eq!(active_input(app), text); + assert_eq!(active_spill_anchor(app).is_some(), read_only); + app.begin_edit(None, true); + assert_eq!(app.editor.is_none(), read_only); + app.cancel_edit(); + if read_only { + app.begin_edit(Some("=SUM(1,2)".into()), true); + assert!(app.editor.is_none()); + } + } + + #[test] + fn selection_restores_editing_and_keeps_spill_formulas_only_at_anchor() { + let mut app = app(); + set(&mut app, "A1", "=SEQUENCE(5,1,10,10)"); + set(&mut app, "D1", "=SUM(1,2)"); + set(&mut app, "D2", "hello"); + for cell in ["A2", "A3", "A4", "A5"] { + check(&mut app, cell, "=SEQUENCE(5,1,10,10)", true); + } + check(&mut app, "A1", "=SEQUENCE(5,1,10,10)", false); + check(&mut app, "D1", "=SUM(1,2)", false); + check(&mut app, "D2", "hello", false); + check(&mut app, "E1", "", false); + let sh = app.session.active().unwrap().wb.active().unwrap(); + assert!(sh.cell(gridcraft_engine::core::CellRef::parse("A2").unwrap()).and_then(|c| c.formula.as_ref()).is_none()); + // Intentional grid replacement retains the existing entry behavior. + select(&mut app, "A2"); + app.begin_edit(Some("replacement".into()), false); + assert!(app.editor.is_some()); + } + + #[test] + fn spill_ownership_follows_recalculation_and_obstructions() { + let mut app = app(); + set(&mut app, "D1", "2"); + set(&mut app, "A1", "=SEQUENCE(D1,2)"); + check(&mut app, "B2", "=SEQUENCE(D1,2)", true); + set(&mut app, "D1", "4"); + check(&mut app, "B4", "=SEQUENCE(D1,2)", true); + set(&mut app, "D1", "1"); + check(&mut app, "B4", "", false); + check(&mut app, "B1", "=SEQUENCE(D1,2)", true); + set(&mut app, "A1", ""); + check(&mut app, "B1", "", false); + set(&mut app, "B2", "blocked"); + set(&mut app, "A1", "=SEQUENCE(3,2)"); + check(&mut app, "A2", "", false); + check(&mut app, "B2", "blocked", false); + set(&mut app, "B2", ""); + check(&mut app, "B2", "=SEQUENCE(3,2)", true); + } + + #[test] + fn formatted_children_and_other_dynamic_array_functions() { + let mut app = app(); + app.run("home.bold", json!({"range": "A2"})).unwrap(); + set(&mut app, "D1", "3"); + set(&mut app, "D2", "1"); + set(&mut app, "D3", "3"); + for formula in ["=SORT(D1:D3)", "=UNIQUE(D1:D3)", "=FILTER(D1:D3,D1:D3>0)"] { + set(&mut app, "A1", formula); + check(&mut app, "A2", formula, true); + } + app.run("home.insertSheet", json!({"name": "Other"})).unwrap(); + set(&mut app, "A1", "=SORT(Sheet1!D1:D3)"); + check(&mut app, "A2", "=SORT(Sheet1!D1:D3)", true); + app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + check(&mut app, "A2", "=FILTER(D1:D3,D1:D3>0)", true); + } + #[test] + fn formula_bar_click_and_theme_presentation() { + use egui_kittest::kittest::Queryable as _; + for dark in [false, true] { + let mut app = app(); + set(&mut app, "A1", "=SEQUENCE(3)"); + select(&mut app, "A2"); + let mut fonts_ready = false; + let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 100.0)).build_ui_state( + move |ui, app: &mut SheetApp| { + if !fonts_ready { + ui.ctx().set_fonts(theme::font_definitions()); + fonts_ready = true; + return; + } + ui.ctx().set_visuals(if dark { egui::Visuals::dark() } else { egui::Visuals::light() }); + show(app, ui); + }, + app, + ); + h.run_steps(3); + let disabled = Tokens::get(&h.ctx).text_disabled; + assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t) + if t.galley.job.text == "=SEQUENCE(3)" && t.galley.job.sections.iter().all(|section| section.format.color == disabled)))); + h.get_by_label("=SEQUENCE(3)").click(); + h.run_steps(3); + assert!(h.state().editor.is_none()); + select(h.state_mut(), "A1"); + h.run_steps(3); + h.get_by_label("=SEQUENCE(3)").click(); + h.run_steps(3); + assert!(h.state().editor.as_ref().is_some_and(|e| e.from_formula_bar)); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f96cdac3c..e7a44087e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -863,6 +863,11 @@ impl SheetApp { /// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it. pub fn begin_edit(&mut self, text: Option, from_formula_bar: bool) { + // The source formula is display-only on spill children. Grid replacement keeps its + // existing behavior, but no formula-bar entry path may edit that displayed formula. + if from_formula_bar && formula_bar::active_spill_anchor(self).is_some() { + return; + } if let Err(e) = self.commit_text_box_edit() { self.message = Some(("Text Box".into(), clean_error(&e))); return; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..b694d7f7f 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-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (spill-child formula bar is muted and read-only) · **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 @@ -20,7 +20,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 | done | +| 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 | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial | | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing | @@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Spill-child formula bar uses the anchor formula with disabled theme text and blocks formula-bar edit entry; selection and recalculation restore ordinary behavior | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | | 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 | From d48bcaf0433377b7bc985cc168798bf0af7fa1cc Mon Sep 17 00:00:00 2001 From: DCM4711 <107349915+DCM4711@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:55:20 +0200 Subject: [PATCH 05/32] Context menu: the right-click that opens it doesn't close it A quick right-click delivers press and release in one frame. The menu opens at the pointer, and its corner snaps to the nearest physical pixel (252.46 -> 252.5 on a Retina display), which can leave the pointer just outside it. The close check then saw that frame's press as a click outside the menu and closed it right away, so the menu often flashed and vanished on macOS. The menu remembers the frame it opened in, and that frame's press no longer counts as a click elsewhere. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 10 +- .../ui-egui/tests/context_menu_stays_open.rs | 102 ++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/context_menu_stays_open.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 97b787ef3..bf90b5f7c 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -114,6 +114,8 @@ pub struct GridState { /// Column and row of the clicked header cell, and where to open the menu. pub filter_menu: Option<(u32, u32, Pos2)>, pub context_menu: Option, + /// The frame a right-click opened `context_menu` in: that click's own press doesn't close it. + pub context_menu_frame: u64, pub marching_phase: f32, /// The selection outline on its way to a new selection. pub glide: crate::selection_glide::SelectionGlide, @@ -2321,6 +2323,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & app.grid.header_menu = Some((geo.mirror_pos(p), rows)); } else { app.grid.context_menu = Some(geo.mirror_pos(p)); + app.grid.context_menu_frame = ui.ctx().cumulative_frame_nr(); } } @@ -2913,9 +2916,14 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { } }); }); + // A quick right-click presses and releases in one frame, and the menu's corner snaps to the + // nearest physical pixel, which can put the pointer just outside it: that press opened the + // menu, so it doesn't count as a click elsewhere. + let opened_now = app.grid.context_menu_frame == ui.ctx().cumulative_frame_nr(); if close || ui.input(|i| i.key_pressed(egui::Key::Escape)) - || (ui.input(|i| i.pointer.any_pressed()) + || (!opened_now + && ui.input(|i| i.pointer.any_pressed()) && !resp.response.contains_pointer() && !resp.response.hovered() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !resp.response.rect.contains(p))) diff --git a/crates/ui-egui/tests/context_menu_stays_open.rs b/crates/ui-egui/tests/context_menu_stays_open.rs new file mode 100644 index 000000000..dada039a6 --- /dev/null +++ b/crates/ui-egui/tests/context_menu_stays_open.rs @@ -0,0 +1,102 @@ +//! A right-click on a cell opens its context menu, and the menu stays open until the user picks +//! an item, presses Escape or clicks elsewhere. + +use egui::{Event, Modifiers, PointerButton, Pos2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid}; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + harness_at(1.0) +} + +/// `ppp` physical pixels per point: 2 on a Retina display. +fn harness_at(ppp: f32) -> Harness { + let mut session = Session::new(); + session.new_workbook(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_pixels_per_point(ppp).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn center(h: &Harness, cell: &str) -> Pos2 { + let app = h.state(); + let sh = app.session.active().unwrap().wb.active().unwrap(); + grid::Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll).cell_rect(sh, CellRef::parse(cell).unwrap()).center() +} + +fn button(h: &mut Harness, pos: Pos2, pressed: bool) { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); +} + +/// Press and release arrive in the same frame (a quick click). +fn quick_right_click(h: &mut Harness, cell: &str) { + let pos = center(h, cell); + h.input_mut().events.push(Event::PointerMoved(pos)); + button(h, pos, true); + button(h, pos, false); + h.run_steps(4); +} + +/// Press and release in separate frames (a slower click). +fn slow_right_click(h: &mut Harness, cell: &str) { + let pos = center(h, cell); + h.input_mut().events.push(Event::PointerMoved(pos)); + h.run_steps(1); + button(h, pos, true); + h.run_steps(1); + button(h, pos, false); + h.run_steps(4); +} + +#[test] +fn a_quick_right_click_keeps_the_menu_open() { + let mut h = harness(); + quick_right_click(&mut h, "C5"); + assert!(h.state().grid.context_menu.is_some(), "the menu stays open"); +} + +#[test] +fn a_slow_right_click_keeps_the_menu_open() { + let mut h = harness(); + slow_right_click(&mut h, "C5"); + assert!(h.state().grid.context_menu.is_some(), "the menu stays open"); +} + +#[test] +fn right_clicking_another_cell_moves_the_menu_there() { + for quick in [true, false] { + let mut h = harness(); + slow_right_click(&mut h, "C5"); + if quick { + quick_right_click(&mut h, "M2"); + } else { + slow_right_click(&mut h, "M2"); + } + let at = h.state().grid.context_menu; + assert!(at.is_some(), "the menu stays open (quick: {quick})"); + assert_eq!(at, Some(center(&h, "M2")), "at the second cell (quick: {quick})"); + } +} + +/// A real pointer sits between pixels (x = 252.46), and the menu's corner snaps to the nearest +/// physical pixel (252.5 on a Retina display), just past the pointer. The press that comes in the +/// same frame as the release then counted as a press outside the menu and closed it at once. +#[test] +fn a_quick_right_click_between_pixels_keeps_the_menu_open_on_retina() { + let mut h = harness_at(2.0); + let pos = center(&h, "C5") + egui::vec2(0.46, 0.37); + h.input_mut().events.push(Event::PointerMoved(pos)); + button(&mut h, pos, true); + button(&mut h, pos, false); + h.run_steps(4); + assert!(h.state().grid.context_menu.is_some(), "the menu stays open"); +} From 7b10212b5a21d2f9587155ccef65d2e232826607 Mon Sep 17 00:00:00 2001 From: DCM4711 <107349915+DCM4711@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:43:51 +0200 Subject: [PATCH 06/32] =?UTF-8?q?Header=20menus:=20Format=20Cells=E2=80=A6?= =?UTF-8?q?=20for=20the=20selected=20rows=20or=20columns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As in Excel, right-clicking a row or column heading offers Format Cells…, between Clear Contents and Row Height… / Column Width…. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 3 ++ crates/ui-egui/tests/insert_copied_cells.rs | 31 +++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 97b787ef3..294b94d5e 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -3118,6 +3118,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { ("Delete", "home.deleteRows"), ("Clear Contents", "edit.clearContents"), ("-", ""), + ("Format Cells…", "ui:formatCells"), ("Row Height…", "ui:rowHeight"), ("AutoFit Row Height", "home.autofitRowHeight"), ("Hide", "home.hideRows"), @@ -3134,6 +3135,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { ("Delete", "home.deleteColumns"), ("Clear Contents", "edit.clearContents"), ("-", ""), + ("Format Cells…", "ui:formatCells"), ("Column Width…", "ui:columnWidth"), ("AutoFit Column Width", "home.autofitColumnWidth"), ("Hide", "home.hideColumns"), @@ -3156,6 +3158,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { match id { "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), "ui:rowHeight" => app.open_dialog("rowHeight", json!({})), + "ui:formatCells" => app.open_dialog("formatCells", json!({})), "ui:columnWidth" => app.open_dialog("columnWidth", json!({})), "edit.copy" | "edit.cut" => { app.copy_to_clipboard(ui.ctx(), id); diff --git a/crates/ui-egui/tests/insert_copied_cells.rs b/crates/ui-egui/tests/insert_copied_cells.rs index 89621f21b..d6ca79f2d 100644 --- a/crates/ui-egui/tests/insert_copied_cells.rs +++ b/crates/ui-egui/tests/insert_copied_cells.rs @@ -58,3 +58,34 @@ fn row_header_menu_inserts_copied_rows() { assert_eq!(value(&h, "A4"), Value::Number(3.0)); assert_eq!(value(&h, "A5"), Value::Number(4.0)); } + +fn right_click_col_header(h: &mut Harness, cell: &str) { + let app = h.state(); + let sh = app.session.active().unwrap().wb.active().unwrap(); + let geo = grid::Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll); + let x = geo.cell_rect(sh, CellRef::parse(cell).unwrap()).center().x; + let pos = pos2(x, (geo.rect.top() + geo.cells.top()) / 2.0); + h.input_mut().events.push(Event::PointerMoved(pos)); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +/// As in Excel, the row and column header menus offer Format Cells… for the selected lines. +#[test] +fn header_menus_open_format_cells() { + for rows in [true, false] { + let mut h = harness(); + if rows { + right_click_row_header(&mut h, "A3"); + } else { + right_click_col_header(&mut h, "B1"); + } + h.get_by_label("Format Cells…").click(); + h.run_steps(2); + let app = h.state(); + assert_eq!(app.dialog.as_ref().map(|d| d.name()), Some("formatCells"), "rows: {rows}"); + let sel = app.session.active().unwrap().selection.a1(); + assert_eq!(sel, if rows { "3:3" } else { "B:B" }); + } +} From 33abe3a9f78a6d9b72b3444e5272e895b025505b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 14 Oct 2026 22:57:29 +0200 Subject: [PATCH 07/32] UI: Protect Sheet entries become Unprotect Sheet on a protected sheet (#1346) Home > Format, HOP and RPS always opened the Protect Sheet dialog, which re-protected the sheet. open_dialog now turns protectSheet into unprotectSheet for a protected sheet, and unprotects at once when there is no password. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/es.rs | 1 + crates/ui-egui/src/i18n/fr.rs | 1 + crates/ui-egui/src/i18n/ko.rs | 1 + crates/ui-egui/src/i18n/pt.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/sr.rs | 1 + crates/ui-egui/src/i18n/zh.rs | 1 + crates/ui-egui/src/lib.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 9 +++------ crates/ui-egui/src/tabs.rs | 4 ++-- crates/ui-egui/tests/protect_password.rs | 19 +++++++++++++++++++ 14 files changed, 54 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..e30d8106b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password) (#1346, part 1) | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 7ed8372c7..9f2beee95 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -184,6 +184,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Protect\nWorkbook" => "Zamknout\nsešit", "Unprotect\nSheet" => "Odemknout\nlist", "Protect Sheet…" => "Zamknout list…", + "Unprotect Sheet…" => "Odemknout list…", "Comments" => "Komentáře", "Notes" => "Poznámky", "Page Break\nPreview" => "Náhled\nkonců stránek", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index c20c7d1b3..50330f384 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -184,6 +184,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Protect\nWorkbook" => "Arbeitsmappe\nschützen", "Unprotect\nSheet" => "Blattschutz\naufheben", "Protect Sheet…" => "Blatt schützen…", + "Unprotect Sheet…" => "Blattschutz aufheben…", "Comments" => "Kommentare", "Notes" => "Notizen", "Page Break\nPreview" => "Seitenumbruch-\nvorschau", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 4ce231bca..65b3d7f18 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -185,6 +185,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Protect\nWorkbook" => "Proteger\nlibro", "Unprotect\nSheet" => "Desproteger\nhoja", "Protect Sheet…" => "Proteger hoja…", + "Unprotect Sheet…" => "Desproteger hoja…", "Comments" => "Comentarios", "Notes" => "Notas", "Page Break\nPreview" => "Vista previa de\nsalto de página", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 5d20ab0ca..24848089c 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -206,6 +206,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Allow Edit Ranges" => "Autoriser la modification des plages", "Unprotect\nSheet" => "Ôter la protection\nde la feuille", "Protect Sheet…" => "Protéger la feuille…", + "Unprotect Sheet…" => "Ôter la protection de la feuille…", "Comments" => "Commentaires", "Notes" => "Notes", "Page Break\nPreview" => "Aperçu des sauts\nde page", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 1902ad301..f98ae51a3 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -203,6 +203,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Allow Edit Ranges" => "범위 편집 허용", "Unprotect\nSheet" => "시트 보호\n해제", "Protect Sheet…" => "시트 보호…", + "Unprotect Sheet…" => "시트 보호 해제…", "Comments" => "댓글", "Notes" => "메모", "Page Break\nPreview" => "페이지 나누기\n미리 보기", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index fb9236cdb..b4f65da54 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -204,6 +204,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Allow Edit Ranges" => "Permitir Edição de Intervalos", "Unprotect\nSheet" => "Desproteger\nPlanilha", "Protect Sheet…" => "Proteger Planilha…", + "Unprotect Sheet…" => "Desproteger Planilha…", "Comments" => "Comentários", "Notes" => "Notas", "Page Break\nPreview" => "Visualização\nde Quebras de Página", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 765f6bb32..90333030d 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -203,6 +203,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Allow Edit Ranges" => "Разрешить изменение диапазонов", "Unprotect\nSheet" => "Снять защиту\nлиста", "Protect Sheet…" => "Защитить лист…", + "Unprotect Sheet…" => "Снять защиту листа…", "Comments" => "Комментарии", "Notes" => "Заметки", "Page Break\nPreview" => "Предварительный просмотр\nразрывов страниц", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index cbf18d4c4..118f703d5 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -198,6 +198,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Allow Edit Ranges" => "Dozvoli uređivanje opsega", "Unprotect\nSheet" => "Ukloni zaštitu\nlista", "Protect Sheet…" => "Zaštiti list…", + "Unprotect Sheet…" => "Ukloni zaštitu lista…", "Comments" => "Komentari", "Notes" => "Beleške", "Page Break\nPreview" => "Pregled preloma\nstranica", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 00175925e..41c313e83 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -203,6 +203,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Allow Edit Ranges" => "允许编辑区域", "Unprotect\nSheet" => "撤销\n工作表保护", "Protect Sheet…" => "保护工作表…", + "Unprotect Sheet…" => "撤销工作表保护…", "Comments" => "批注", "Notes" => "备注", "Page Break\nPreview" => "分页符\n预览", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f96cdac3c..2d78f96b5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -708,11 +708,31 @@ impl SheetApp { } } + /// Whether the active sheet of the active document is protected (drives the Protect/Unprotect + /// Sheet labels and actions). + pub fn active_sheet_protected(&self) -> bool { + self.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false) + } + pub fn open_dialog(&mut self, name: &str, params: Json) { if let Err(e) = self.commit_text_box_edit() { self.message = Some(("Text Box".into(), clean_error(&e))); return; } + // Every Protect Sheet entry point (ribbon menus, keytips, tab menu) becomes Unprotect Sheet + // on a protected sheet. A sheet without a password is unprotected straight away. + let name = if name == "protectSheet" && self.active_sheet_protected() { "unprotectSheet" } else { name }; + if name == "unprotectSheet" + && self + .session + .active() + .and_then(|d| d.wb.active()) + .and_then(|s| s.protection.as_ref()) + .is_some_and(|p| p.password.is_none() && p.legacy_password_hash.is_none()) + { + self.run_or_alert("review.unprotectSheet", json!({})); + return; + } match name { "open" => { if let Some(start) = &self.services.open_async { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 9c6d89eeb..b35289a82 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -921,6 +921,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { if format_keys { format_popup = format_popup.open_memory(egui::SetOpenCommand::Bool(true)); } + let protect_label = if app.active_sheet_protected() { "Unprotect Sheet…" } else { "Protect Sheet…" }; format_popup.show(|ui| { menu_items( app, @@ -942,7 +943,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ("Rename Sheet", "dialog:renameSheet", json!({})), ("Move or Copy Sheet…", "dialog:moveSheet", json!({})), ("-", "", json!(null)), - ("Protect Sheet…", "dialog:protectSheet", json!({})), + (protect_label, "dialog:protectSheet", json!({})), ("Lock Cell", "home.lockCell", json!({})), ("Format Cells…", "dialog:formatCells", json!({})), ], @@ -2082,11 +2083,7 @@ fn review(app: &mut SheetApp, ui: &mut Ui) { let __k = big_button(ui, Icon::Lock, if protected { "Unprotect\nSheet" } else { "Protect\nSheet" }, "Protect Sheet", false); control_keytip(app, ui, &__k, "dialog:protectSheet"); if __k.clicked() { - if protected { - app.open_dialog("unprotectSheet", json!({})); - } else { - app.open_dialog("protectSheet", json!({})); - } + app.open_dialog("protectSheet", json!({})); } let __k = big_button(ui, Icon::Book, "Protect\nWorkbook", "Protect Workbook structure", false); control_keytip(app, ui, &__k, "review.protectWorkbook"); diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index b77524bb9..3dfae5be0 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -199,9 +199,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { app.run_or_alert("sheet.unhide", json!({})); } ui.separator(); - let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false); + let protected = app.active_sheet_protected(); if ui.button(if protected { "Unprotect Sheet…" } else { "Protect Sheet…" }).clicked() { - app.open_dialog(if protected { "unprotectSheet" } else { "protectSheet" }, json!({})); + app.open_dialog("protectSheet", json!({})); } if ui.button("Select All Sheets").clicked() { ui.close(); diff --git a/crates/ui-egui/tests/protect_password.rs b/crates/ui-egui/tests/protect_password.rs index ee68cf2b6..7a2fe551e 100644 --- a/crates/ui-egui/tests/protect_password.rs +++ b/crates/ui-egui/tests/protect_password.rs @@ -68,3 +68,22 @@ fn protect_and_unprotect_sheet_mask_the_password() { h.run_steps(3); assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.is_none()); } + +#[test] +fn protect_sheet_entry_unprotects_a_protected_sheet() { + // No password: the Protect Sheet entry points (Home > Format, HOP, RPS, tab menu) unprotect straight away. + let mut h = harness(); + h.state_mut().run("review.protectSheet", json!({})).unwrap(); + assert!(h.state().active_sheet_protected()); + h.state_mut().open_dialog("protectSheet", json!({})); + h.run_steps(3); + assert!(h.state().dialog.is_none(), "no password: no dialog"); + assert!(!h.state().active_sheet_protected()); + + // With a password the Unprotect dialog asks for it, and a wrong one leaves the sheet protected. + h.state_mut().run("review.protectSheet", json!({"password": SECRET})).unwrap(); + h.state_mut().open_dialog("protectSheet", json!({})); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("unprotectSheet")); + assert!(h.state().active_sheet_protected()); +} From 0d18137114fd841ff82811191042681a6e647700 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 14 Oct 2026 23:00:50 +0200 Subject: [PATCH 08/32] Protection: enforce selectLockedCells/selectUnlockedCells and offer them in Protect Sheet (#1346) selection.set refuses a cell the protection forbids selecting, selection.move steps on to the next selectable cell, and review.protectSheet keeps the sheet's current selection rights unless told otherwise. The dialog gains the two select options and insert/delete columns. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/edit.rs | 32 ++++++++++++++++++++++++++++++++ crates/engine/src/cmd/review.rs | 28 ++++++++++++++++++++++++++-- crates/ui-egui/src/dialogs.rs | 6 +++++- 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e30d8106b..ed706403c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,7 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| -| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password) (#1346, part 1) | +| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 82dbe0be7..ce926de0e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -362,6 +362,12 @@ fn range_fill(s: &mut Session, p: &Json) -> Result { // ---------------------------------------------------------------- selection +/// Whether the cell may be selected: a protected sheet can forbid selecting locked and/or unlocked cells. +fn cell_selectable(wb: &gridcraft_model::Workbook, sheet: &gridcraft_model::Sheet, c: CellRef) -> bool { + let Some(p) = sheet.protection.as_ref() else { return true }; + if wb.styles.get(sheet.style_id(c)).protection.locked { p.select_locked } else { p.select_unlocked } +} + fn selection_set(s: &mut Session, p: &Json) -> Result { if p.get("sheet").is_some() { let i = target_sheet(s, p)?; @@ -379,6 +385,9 @@ fn selection_set(s: &mut Session, p: &Json) -> Result { let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges }; if let Some(sh) = d.wb.active() { sel.expand_merges(sh); + if !cell_selectable(&d.wb, sh, sel.active) { + return Err(EngineError::Other("The cell you are trying to change is on a protected sheet that does not allow selecting it.".into())); + } } d.selection = sel; Ok(json!({"selection": d.selection.a1(), "active": d.selection.active.a1()})) @@ -447,6 +456,29 @@ fn selection_move(s: &mut Session, p: &Json) -> Result { { to = m.start; } + if !extend && !cell_selectable(&d.wb, sheet, to) { + // Step on to the next selectable cell in the same direction; stay put if there is none. + let (sr, sc) = (dr.signum(), dc.signum()); + let mut t = to; + let mut found = false; + if !jmp && (sr, sc) != (0, 0) { + for _ in 0..10_000 { + let n = t.offset_clamped(sr, sc); + if n == t { + break; + } + t = n; + if !sheet.is_row_hidden(t.row) && !sheet.is_col_hidden(t.col) && cell_selectable(&d.wb, sheet, t) { + found = true; + break; + } + } + } + if !found { + return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()})); + } + to = t; + } if extend { sel.extend_to(to); sel.expand_merges(sheet); diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index c55aac8e5..bd82d7c4f 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -139,11 +139,15 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result { return Err(EngineError::Other("The sheet is protected with a password. Unprotect it first (Review › Unprotect Sheet).".into())); } let b = |k: &str| bool_param(p, k).unwrap_or(false); + // Selection rights default to what the sheet has now (a file may forbid selecting locked cells). + let current = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.clone()); + let select_locked = bool_param(p, "selectLockedCells").unwrap_or_else(|| current.as_ref().is_none_or(|c| c.select_locked)); + let select_unlocked = bool_param(p, "selectUnlockedCells").unwrap_or_else(|| current.as_ref().is_none_or(|c| c.select_unlocked)); let prot = SheetProtection { password: None, legacy_password_hash: None, - select_locked: true, - select_unlocked: true, + select_locked, + select_unlocked, format_cells: b("formatCells"), format_columns: b("formatColumns"), format_rows: b("formatRows"), @@ -399,6 +403,26 @@ mod tests { assert_eq!(at(&s), (0, "A1".to_string())); } + #[test] + fn protection_that_forbids_selecting_locked_cells_is_enforced_and_kept() { + let mut s = book(); + s.execute("home.lockCell", json!({"range": "B2", "on": false})).unwrap(); + s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap(); + // Locked cells can't be selected; unlocked ones can. + assert!(s.execute("selection.set", json!({"cell": "A1"})).is_err()); + s.execute("selection.set", json!({"cell": "B2"})).unwrap(); + // Arrow keys skip locked cells, and stay put when nothing selectable is left. + s.execute("selection.move", json!({"dr": 0, "dc": 1})).unwrap(); + assert_eq!(s.doc().unwrap().selection.active.a1(), "B2"); + // Re-protecting without the flag keeps the sheet's selection rights. + s.execute("review.unprotectSheet", json!({})).unwrap(); + s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap(); + s.execute("review.protectSheet", json!({"sort": true})).unwrap(); + assert!(!s.doc().unwrap().wb.active().unwrap().protection.clone().unwrap().select_locked); + s.execute("review.unprotectSheet", json!({})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + } + #[test] fn sheet_passwords_use_excel_hashes_and_old_json_hashes_still_verify() { let mut s = book(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fb899c702..00a488cf3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -182,15 +182,19 @@ impl Dialog { vec![ Password { key: "password", label: "Password (optional):" }, Note("Allow all users of this sheet to:".into()), + Check { key: "selectLockedCells", label: "Select locked cells" }, + Check { key: "selectUnlockedCells", label: "Select unlocked cells" }, Check { key: "formatCells", label: "Format cells" }, Check { key: "formatColumns", label: "Format columns" }, Check { key: "formatRows", label: "Format rows" }, + Check { key: "insertColumns", label: "Insert columns" }, Check { key: "insertRows", label: "Insert rows" }, + Check { key: "deleteColumns", label: "Delete columns" }, Check { key: "deleteRows", label: "Delete rows" }, Check { key: "sort", label: "Sort" }, Check { key: "autofilter", label: "Use AutoFilter" }, ], - json!({}), + json!({"selectLockedCells": true, "selectUnlockedCells": true}), ), "allowEditRanges" => { let list = app.session.run("review.allowEditRanges", json!({})).unwrap_or(Json::Null); From 7ee11fe137cd4d5c0d9cdfef0513393c472ad92f Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Wed, 14 Oct 2026 19:05:03 -0500 Subject: [PATCH 09/32] XLSX: keep dynamic-array cell metadata (cm) on save A formula read with `cm` (Excel's XLDAPR dynamic-array record in xl/metadata.xml) now carries `Formula::dynamic`, and the writer keeps the `cm` and the metadata part for it even when the formula returns one value and the export shape analysis would not need array evaluation. Before, such formulas were saved as legacy formulas, so Excel recalculated some of them with implicit intersection (SUMPRODUCT(IFERROR(range/range,0)) changed value), and workbooks whose every dynamic formula was scalar lost xl/metadata.xml altogether. The mark survives moves, row and column insertion or deletion, sheet and name renames and LAMBDA call-case fixes; retyping a formula drops it and the writer decides from the formula's shape, as before. It is serialized only when set, so saved documents without it are unchanged. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/engine/src/cmd/edit.rs | 4 +- crates/engine/src/cmd/formulas.rs | 1 + crates/engine/tests/dynamicmetadata.rs | 87 +++++++++++++++++++++++ crates/model/src/cell.rs | 12 +++- crates/model/src/workbook.rs | 2 +- crates/xlsx/src/sheet_read.rs | 5 ++ crates/xlsx/src/sheet_write.rs | 4 ++ crates/xlsx/src/tests/cellmetadata.rs | 97 ++++++++++++++++++++++++++ crates/xlsx/src/tests/mod.rs | 1 + docs/file-format-parity.md | 13 +++- 11 files changed, 223 insertions(+), 7 deletions(-) create mode 100644 crates/engine/tests/dynamicmetadata.rs create mode 100644 crates/xlsx/src/tests/cellmetadata.rs diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..3ab4a4a8d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (XLSX dynamic-array formulas keep their `cm` metadata on save) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (10 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | @@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | | 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 82dbe0be7..074c26dc9 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -999,11 +999,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { // The array range moves with its formula. let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?))); + moved.dynamic = f.dynamic; return Some(std::sync::Arc::new(moved)); } let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); - Some(std::sync::Arc::new(Formula::from_expr(moved))) + Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) })) }; if link { let sheet_prefix = if dest_sheet == clip.sheet && same_doc { @@ -1205,6 +1206,7 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: & { let mut nf = Formula::from_expr(new); nf.array = array; + nf.dynamic = f.dynamic; cell.formula = Some(std::sync::Arc::new(nf)); } } diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 40df72df0..14f75c0d4 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -348,6 +348,7 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc let Some(e) = f.expr().and_then(rename) else { continue }; let mut nf = Formula::from_expr(e); nf.array = f.array; + nf.dynamic = f.dynamic; if let Some(cell) = sh.cells.get_mut(c) { cell.formula = Some(std::sync::Arc::new(nf)); } diff --git a/crates/engine/tests/dynamicmetadata.rs b/crates/engine/tests/dynamicmetadata.rs new file mode 100644 index 000000000..e1edfc958 --- /dev/null +++ b/crates/engine/tests/dynamicmetadata.rs @@ -0,0 +1,87 @@ +//! A formula opened with dynamic-array metadata keeps it through edits that only move or adjust +//! it, and loses it when it is retyped (the writer then decides from the formula's shape). + +use std::sync::Arc; + +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_engine::model::{Cell, Formula, Workbook}; +use gridcraft_engine::{DocState, Session, io}; +use serde_json::json; + +fn at(s: &str) -> CellRef { + CellRef::parse(s).unwrap() +} + +/// A workbook saved by GridCraft whose D1 formula came from a file with `cm`. +fn opened() -> Session { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + for r in 0..3 { + sh.set_value(CellRef::new(r, 1), Value::Number(f64::from(r + 1))); + sh.set_value(CellRef::new(r, 2), Value::Number(f64::from(r + 2))); + } + let mut f = Formula::new("SUMPRODUCT(B1:B3*C1:C3)"); + f.dynamic = true; + sh.cells.set(at("D1"), Cell::formula(f)); + let bytes = io::save_bytes(&wb, "dynamic.xlsx").unwrap(); + let (back, _) = io::open_bytes("dynamic.xlsx", &bytes).unwrap(); + let mut s = Session::new(); + s.add_document(DocState::new(back, None, "dynamic".into())); + s +} + +fn formula(s: &Session, cell: &str) -> Arc { + s.doc().unwrap().wb.sheet(0).unwrap().cell(at(cell)).unwrap().formula.clone().unwrap() +} + +/// Whether `cell` of the saved file carries dynamic-array metadata. +fn saved_dynamic(s: &Session, cell: &str) -> bool { + let bytes = io::save_bytes(&s.doc().unwrap().wb, "dynamic.xlsx").unwrap(); + let (back, _) = io::open_bytes("dynamic.xlsx", &bytes).unwrap(); + back.sheet(0).unwrap().cell(at(cell)).unwrap().formula.as_ref().unwrap().dynamic +} + +#[test] +fn opened_dynamic_formula_keeps_its_metadata_on_save() { + let s = opened(); + assert!(formula(&s, "D1").dynamic); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().value(at("D1")), Value::Number(20.0)); + assert!(saved_dynamic(&s, "D1")); +} + +#[test] +fn inserting_rows_and_moving_keep_the_mark() { + let mut s = opened(); + s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap(); + let f = formula(&s, "D2"); + assert!(f.dynamic); + assert_eq!(f.text, "SUMPRODUCT(B2:B4*C2:C4)"); + assert!(saved_dynamic(&s, "D2")); + s.execute("selection.set", json!({"range": "D2"})).unwrap(); + s.execute("edit.cut", json!({})).unwrap(); + s.execute("selection.set", json!({"range": "F2"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert!(formula(&s, "F2").dynamic); + assert!(saved_dynamic(&s, "F2")); +} + +#[test] +fn retyped_formula_drops_the_mark() { + let mut s = opened(); + s.execute("cell.set", json!({"cell": "D1", "input": "=SUMPRODUCT(B1:B3*C1:C3)"})).unwrap(); + assert!(!formula(&s, "D1").dynamic); + // SUMPRODUCT evaluates its arguments as arrays anyway: saved as a legacy formula. + assert!(!saved_dynamic(&s, "D1")); +} + +#[test] +fn dynamic_mark_is_serialized_only_when_set() { + let s = opened(); + let dynamic = serde_json::to_value(&*formula(&s, "D1")).unwrap(); + assert_eq!(dynamic["dynamic"], json!(true)); + let plain = serde_json::to_value(Formula::new("SUM(A1:A3)")).unwrap(); + assert!(plain.get("dynamic").is_none(), "{plain}"); + // Documents written before the field existed still load. + let old: Formula = serde_json::from_value(json!({"text": "SUM(A1:A3)", "array": null})).unwrap(); + assert!(!old.dynamic); +} diff --git a/crates/model/src/cell.rs b/crates/model/src/cell.rs index 8700f9a28..d4466365f 100644 --- a/crates/model/src/cell.rs +++ b/crates/model/src/cell.rs @@ -17,6 +17,12 @@ pub struct Formula { /// Legacy array formula (Ctrl+Shift+Enter) covering this range; the formula lives in the /// top-left cell. pub array: Option, + /// Read from a file that marks this formula as a dynamic-array formula (cell metadata `cm`). + /// The writer keeps the mark, so a formula Excel evaluates as an array is never saved as a + /// legacy formula (which Excel evaluates with implicit intersection). Formulas entered or + /// rewritten in GridCraft start without it, and the writer decides from their shape. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub dynamic: bool, } impl Formula { @@ -25,12 +31,12 @@ impl Formula { pub fn new(text: &str) -> Formula { let body = text.strip_prefix('=').unwrap_or(text); match gridcraft_formula::parse(body) { - Ok(e) => Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None }, - Err(_) => Formula { text: body.to_string(), expr: None, array: None }, + Ok(e) => Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None, dynamic: false }, + Err(_) => Formula { text: body.to_string(), expr: None, array: None, dynamic: false }, } } pub fn from_expr(e: Expr) -> Formula { - Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None } + Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None, dynamic: false } } /// The parsed expression, re-parsing after deserialization. pub fn expr(&self) -> Option { diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 828ca692e..05b4646f7 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -219,7 +219,7 @@ impl Workbook { let f = cell.formula.as_ref()?; let e = f.expr()?; let ne = spell_calls(e.clone(), &lambdas); - (ne != e).then(|| (c, Formula { array: f.array, ..Formula::from_expr(ne) })) + (ne != e).then(|| (c, Formula { array: f.array, dynamic: f.dynamic, ..Formula::from_expr(ne) })) }) .collect(); if fixed.is_empty() { diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 0194d2268..5a904fcd2 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -456,6 +456,11 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos } fn read_formula(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef, dynamic: bool) -> Option { + // Keep the file's dynamic-array mark, so saving never turns the formula into a legacy one. + read_formula_text(cx, st, f, pos, dynamic).map(|fm| Formula { dynamic, ..fm }) +} + +fn read_formula_text(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef, dynamic: bool) -> Option { let model = from_file(f.text.trim()); let text = model.as_str(); match f.attr("t").unwrap_or("normal") { diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 78e1afaae..6d9a4731b 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -667,6 +667,10 @@ fn write_cell( Value::Error(CellError::Spill | CellError::Calc) => Value::Error(CellError::Value), v => v, }; + // A formula read with dynamic-array metadata keeps it, even where the shape analysis would + // not need it: Excel decided the formula is evaluated as an array. + let own = formula.filter(|f| f.dynamic && f.array.is_none()).map(|_| RangeRef::cell(c)); + let dynamic = dynamic.or(own.as_ref()); // A non-spilling formula may still need array evaluation to avoid implicit intersection. if dynamic.is_some() && formula.is_some_and(|f| f.array.is_none()) { s.push_str(" cm=\"1\""); diff --git a/crates/xlsx/src/tests/cellmetadata.rs b/crates/xlsx/src/tests/cellmetadata.rs new file mode 100644 index 000000000..3e8791630 --- /dev/null +++ b/crates/xlsx/src/tests/cellmetadata.rs @@ -0,0 +1,97 @@ +//! Dynamic-array cell metadata (`cm`) read from a file is written back, so Excel keeps +//! evaluating those formulas as arrays rather than with implicit intersection. + +use gridcraft_core::CellRef; + +use crate::package::Package; +use crate::xml::El; +use crate::{read_xlsx, write_xlsx}; + +const METADATA: &str = r#""#; +const METADATA_REL: &str = + r#""#; + +/// Formulas Excel saves with `cm` although they return one value, and which GridCraft's own +/// shape analysis would save without it. +const DYNAMIC: [(&str, &str); 5] = [ + ("D1", "SUMPRODUCT(B1:B3*C1:C3)"), + ("D2", "SUMPRODUCT(IFERROR(B1:B3/C1:C3,0))"), + ("D3", "IF(AND(B1:B3>0),\"all\",\"some\")"), + ("D4", "MID(CELL(\"filename\",A1),FIND(\"]\",CELL(\"filename\",A1))+1,31)"), + ("D5", "SUMPRODUCT(--(YEAR(A1:A3)=2024))"), +]; + +fn source() -> Vec { + let mut rows = String::new(); + for r in 1..=3 { + rows.push_str(&format!(r#"{}{r}{}"#, 45290 + r, r + 1)); + let (address, text) = DYNAMIC[r as usize - 1]; + rows.push_str(&format!(r#"{}0"#, crate::xml::esc(text))); + } + for (address, text) in &DYNAMIC[3..] { + let r = &address[1..]; + rows.push_str(&format!( + r#"{}0"#, + crate::xml::esc(text) + )); + } + // The same SUMPRODUCT saved by legacy Excel: no `cm`. + rows.push_str(r#"SUMPRODUCT(B1:B3*C1:C3)20"#); + super::minimal(&format!("{rows}"), &[("xl/metadata.xml", METADATA)], METADATA_REL, "") +} + +fn cell<'a>(sheet: &'a El, address: &str) -> &'a El { + sheet.child("sheetData").unwrap().kids("row").flat_map(|r| r.kids("c")).find(|c| c.attr("r") == Some(address)).unwrap() +} + +fn at(s: &str) -> CellRef { + CellRef::parse(s).unwrap() +} + +fn assert_written_dynamic(bytes: &[u8]) { + let mut pkg = Package::open(bytes).unwrap(); + let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap(); + for (address, text) in DYNAMIC { + let c = cell(&sheet, address); + assert_eq!(c.attr("cm"), Some("1"), "{address} lost its dynamic-array metadata"); + let f = c.child("f").unwrap(); + assert_eq!((f.attr("t"), f.attr("ref")), (Some("array"), Some(address))); + assert_eq!(f.text, text); + } + let legacy = cell(&sheet, "D6"); + assert_eq!(legacy.attr("cm"), None, "a legacy formula must not become dynamic"); + assert_eq!(legacy.child("f").unwrap().attr("t"), None); + + let metadata = pkg.read_xml("xl/metadata.xml").unwrap().expect("metadata part written"); + assert_eq!(metadata.path(&["metadataTypes", "metadataType"]).unwrap().attr("name"), Some("XLDAPR")); + let record = metadata.path(&["cellMetadata", "bk", "rc"]).unwrap(); + assert_eq!((record.attr("t"), record.attr("v")), (Some("1"), Some("0"))); + assert!(pkg.rels("xl/workbook.xml").unwrap().iter().any(|r| r.kind == "sheetMetadata" && r.target == "xl/metadata.xml")); + let types = pkg.read_xml("[Content_Types].xml").unwrap().unwrap(); + assert!(types.kids("Override").any(|e| e.attr("PartName") == Some("/xl/metadata.xml") + && e.attr("ContentType") == Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml"))); +} + +#[test] +fn dynamic_array_metadata_is_read_onto_formulas() { + let (wb, report) = read_xlsx(&source()).unwrap(); + assert!(!report.warnings.iter().any(|w| w.contains("metadata")), "{:?}", report.warnings); + let sh = wb.sheet(0).unwrap(); + for (address, _) in DYNAMIC { + let f = sh.cell(at(address)).unwrap().formula.as_ref().unwrap(); + assert!(f.dynamic, "{address}"); + assert_eq!(f.array, None, "{address} is not a legacy CSE array"); + } + assert!(!sh.cell(at("D6")).unwrap().formula.as_ref().unwrap().dynamic); +} + +#[test] +fn dynamic_array_metadata_survives_saving_scalar_results() { + let (wb, _) = read_xlsx(&source()).unwrap(); + let bytes = write_xlsx(&wb).unwrap(); + assert_written_dynamic(&bytes); + // And again: the mark read from GridCraft's own file is kept too. + let (back, report) = read_xlsx(&bytes).unwrap(); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + assert_written_dynamic(&write_xlsx(&back).unwrap()); +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 517113d0f..7b22f3696 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -1,6 +1,7 @@ //! Integration-style tests: round trips, hand-written fixtures, hostile input and CSV. mod array_export; +mod cellmetadata; mod chart_bool_default; mod csv_tests; mod encrypted_tests; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 988472414..5247d18eb 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (XLSX chart sheets are kept on save (#727); previously minor: CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (XLSX dynamic-array formulas keep their `cm` metadata on save; previously trivial: XLSX chart sheets are kept on save (#727)) · **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** @@ -20,6 +20,16 @@ not interpreted as ink. This does not implement Office InkML or fix inserted ico Command and package tests cover repeated saves and unrelated cell edits; external Excel rendering and repair-free opening remain unverified. +Dynamic-array formulas keep their mark on save. A formula read with dynamic-array cell metadata +(`cm`, the `XLDAPR` record in `xl/metadata.xml`) is written back with it, together with the +metadata part, even when it returns one value and GridCraft's own shape analysis would not need +array evaluation. Dropping the mark turned such formulas into legacy ones, which Excel evaluates +with implicit intersection: `SUMPRODUCT(IFERROR(B1:B3/C1:C3,0))` recalculated to a different +value in Excel. Moving the formula, inserting or deleting rows and columns, and renaming sheets or +names keep the mark; retyping the formula drops it, and the writer then decides from the +formula's shape. Rich-value metadata other than cell pictures (data types, `#SPILL!` error +values) is still dropped. + | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | @@ -85,6 +95,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | XLSX formulas read with dynamic-array metadata (`cm`) keep it on save, so Excel does not recalculate them with implicit intersection | | 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) | | 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) | | 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) | From 01eb237209010bc5c1c8b28e80f9b5485215e0e1 Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Wed, 14 Oct 2026 19:29:44 -0500 Subject: [PATCH 10/32] Docs: corpus count for dropped dynamic-array metadata Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3ab4a4a8d..57d407738 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,7 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| -| 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (10 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) | +| 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | From 7f9f73d6ba4c2a176f8ab7fd4b27c72699beecfb Mon Sep 17 00:00:00 2001 From: adrdsouza <102761952+adrdsouza@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:10:07 +0530 Subject: [PATCH 11/32] fix(numfmt): share legacy colors across grid and PDF rendering --- crates/core/src/color.rs | 20 ++++++++++++++++++++ crates/core/src/lib.rs | 1 + crates/engine/src/cmd/print.rs | 26 +++++++++++--------------- crates/numfmt/src/lib.rs | 18 ++++++++++++++++++ crates/numfmt/src/tests.rs | 13 +++++++++++++ crates/ui-egui/src/grid.rs | 26 ++++++++++++++------------ crates/xlsx/src/tables.rs | 12 +----------- docs/ui-parity.md | 4 +++- 8 files changed, 81 insertions(+), 39 deletions(-) create mode 100644 crates/core/src/color.rs diff --git a/crates/core/src/color.rs b/crates/core/src/color.rs new file mode 100644 index 000000000..f2ea01fc7 --- /dev/null +++ b/crates/core/src/color.rs @@ -0,0 +1,20 @@ +//! Shared legacy indexed colours used by XLSX and number-format rendering. + +/// The 64-entry legacy indexed colour palette, stored as `RRGGBB`. +pub const INDEXED: [u32; 64] = [ + 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF, 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, + 0xFF00FF, 0x00FFFF, 0x800000, 0x008000, 0x000080, 0x808000, 0x800080, 0x008080, 0xC0C0C0, 0x808080, 0x9999FF, 0x993366, 0xFFFFCC, 0xCCFFFF, + 0x660066, 0xFF8080, 0x0066CC, 0xCCCCFF, 0x000080, 0xFF00FF, 0xFFFF00, 0x00FFFF, 0x800080, 0x800000, 0x008080, 0x0000FF, 0x00CCFF, 0xCCFFFF, + 0xCCFFCC, 0xFFFF99, 0x99CCFF, 0xFF99CC, 0xCC99FF, 0xFFCC99, 0x3366FF, 0x33CCCC, 0x99CC00, 0xFFCC00, 0xFF9900, 0xFF6600, 0x666699, 0x969696, + 0x003366, 0x339966, 0x003300, 0x333300, 0x993300, 0x993366, 0x333399, 0x333333, +]; + +/// Resolve an Excel number-format color index (`Color1` through `Color56`). +pub fn indexed_color(index: u8) -> Option<[u8; 3]> { + if !(1..=56).contains(&index) { + return None; + } + let slot = index.checked_add(7)?; + let rgb = *INDEXED.get(slot as usize)?; + Some([(rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8]) +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index d5adbc07e..6ceba5096 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -4,6 +4,7 @@ #![forbid(unsafe_code)] pub mod addr; +pub mod color; pub mod date; pub mod parse; pub mod value; diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index a10f19d46..f35455386 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -604,20 +604,6 @@ fn rgb_of(c: Color, wb: &Workbook) -> Option { c.resolve(&wb.theme) } -fn numfmt_rgb(c: gridcraft_numfmt::FormatColor) -> Rgb { - use gridcraft_numfmt::FormatColor as F; - match c { - F::Black | F::Indexed(_) => [0, 0, 0], - F::Blue => [0, 0, 255], - F::Cyan => [0, 255, 255], - F::Green => [0, 128, 0], - F::Magenta => [255, 0, 255], - F::Red => [255, 0, 0], - F::White => [255, 255, 255], - F::Yellow => [255, 255, 0], - } -} - fn blend(fg: [u8; 4]) -> Rgb { let a = fg[3] as u16; let mix = |c: u8| ((c as u16 * a + 255 * (255 - a)) / 255) as u8; @@ -1061,7 +1047,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 let avail = (w - 2.0 * pad - indent).max(0.0); let code = st.num_fmt.as_str(); let formatted = crate::display::format(&v, code, wb); - let mut color = formatted.color.map(numfmt_rgb).or_else(|| rgb_of(st.font.color, wb)).unwrap_or([0, 0, 0]); + let mut color = formatted.color.and_then(gridcraft_numfmt::FormatColor::rgb).or_else(|| rgb_of(st.font.color, wb)).unwrap_or([0, 0, 0]); if l.cf.as_ref().and_then(|x| x.style.as_ref()).is_some_and(|s| s.font.color != Color::Auto) { color = rgb_of(st.font.color, wb).unwrap_or(color); } @@ -1932,6 +1918,16 @@ mod tests { assert_eq!(expand_header_footer("&", &cx)[1].text, ""); } + #[test] + fn pdf_uses_indexed_number_format_palette_color() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "42"})).unwrap(); + s.execute("home.numberFormat", json!({"range": "A1", "format": "[Color4]0"})).unwrap(); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 1); + assert!(contains(&pdf, "0 1 0 rg"), "expected indexed Color4 green in PDF text stream"); + } + #[test] fn export_writes_file() { let mut s = s(); diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index 6280e1d26..06d0a8693 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -38,6 +38,24 @@ pub enum FormatColor { Indexed(u8), } +impl FormatColor { + /// RGB value used to render this number-format color. + pub fn rgb(self) -> Option<[u8; 3]> { + use FormatColor as F; + Some(match self { + F::Black => [0, 0, 0], + F::Blue => [0, 0, 255], + F::Cyan => [0, 255, 255], + F::Green => [0, 255, 0], + F::Magenta => [255, 0, 255], + F::Red => [255, 0, 0], + F::White => [255, 255, 255], + F::Yellow => [255, 255, 0], + F::Indexed(index) => return gridcraft_core::color::indexed_color(index), + }) + } +} + /// Best guess at the ribbon's number-format category. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum FormatKind { diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index e5687f5bd..acd09249b 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -1,5 +1,18 @@ use super::*; +#[test] +fn format_colors_resolve_named_and_indexed_rgb_safely() { + assert_eq!(FormatColor::Green.rgb(), Some([0, 255, 0])); + assert_eq!(FormatColor::Black.rgb(), Some([0, 0, 0])); + assert_eq!(FormatColor::Indexed(1).rgb(), Some([0, 0, 0])); + assert_eq!(FormatColor::Indexed(10).rgb(), Some([0, 128, 0])); + assert_eq!(FormatColor::Indexed(46).rgb(), Some([255, 102, 0])); + assert_eq!(FormatColor::Indexed(56).rgb(), Some([51, 51, 51])); + for index in [0, 57, 255] { + assert_eq!(FormatColor::Indexed(index).rgb(), None); + } +} + const S: DateSystem = DateSystem::D1900; fn f(code: &str, n: f64) -> String { diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 97b787ef3..93d579f79 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -432,18 +432,7 @@ fn col32(c: Color, wb: &Workbook, default: Color32) -> Color32 { } fn numfmt_color(c: gridcraft_engine::numfmt::FormatColor) -> Color32 { - use gridcraft_engine::numfmt::FormatColor as F; - match c { - F::Black => Color32::BLACK, - F::Blue => Color32::from_rgb(0, 0, 255), - F::Cyan => Color32::from_rgb(0, 255, 255), - F::Green => Color32::from_rgb(0, 128, 0), - F::Magenta => Color32::from_rgb(255, 0, 255), - F::Red => Color32::from_rgb(255, 0, 0), - F::White => Color32::WHITE, - F::Yellow => Color32::from_rgb(255, 255, 0), - F::Indexed(_) => Color32::BLACK, - } + c.rgb().map(|[r, g, b]| Color32::from_rgb(r, g, b)).unwrap_or(Color32::BLACK) } /// Last column of a Center Across Selection run starting at `start`: it extends over following columns (up to `limit`) while `continues` holds. @@ -3321,6 +3310,19 @@ mod selection_fill_tests { mod tests { use super::*; + #[test] + fn number_format_colors_resolve_named_and_indexed_rgb() { + use gridcraft_engine::numfmt::FormatColor as F; + assert_eq!(numfmt_color(F::Green), Color32::from_rgb(0, 255, 0)); + assert_eq!(numfmt_color(F::Indexed(1)), Color32::BLACK); + assert_eq!(numfmt_color(F::Indexed(10)), Color32::from_rgb(0, 128, 0)); + assert_eq!(numfmt_color(F::Indexed(46)), Color32::from_rgb(255, 102, 0)); + assert_eq!(numfmt_color(F::Indexed(56)), Color32::from_rgb(51, 51, 51)); + for index in [0, 57, 255] { + assert_eq!(numfmt_color(F::Indexed(index)), Color32::BLACK); + } + } + #[test] fn wheel_zoom_steps_along_the_ladder_and_stops_at_100() { for (from, zoom_in, to) in [ diff --git a/crates/xlsx/src/tables.rs b/crates/xlsx/src/tables.rs index fe7cbdc42..aba547f79 100644 --- a/crates/xlsx/src/tables.rs +++ b/crates/xlsx/src/tables.rs @@ -52,17 +52,7 @@ pub fn builtin_id(code: &str) -> Option { BUILTIN_FORMATS.iter().find(|(i, c)| *c == code && !(5..=8).contains(i)).map(|(i, _)| *i) } -/// The 64-entry legacy palette (`indexed` colours), RRGGBB. -pub const INDEXED: [u32; 64] = [ - 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF, // 0-7 - 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF, // 8-15 - 0x800000, 0x008000, 0x000080, 0x808000, 0x800080, 0x008080, 0xC0C0C0, 0x808080, // 16-23 - 0x9999FF, 0x993366, 0xFFFFCC, 0xCCFFFF, 0x660066, 0xFF8080, 0x0066CC, 0xCCCCFF, // 24-31 - 0x000080, 0xFF00FF, 0xFFFF00, 0x00FFFF, 0x800080, 0x800000, 0x008080, 0x0000FF, // 32-39 - 0x00CCFF, 0xCCFFFF, 0xCCFFCC, 0xFFFF99, 0x99CCFF, 0xFF99CC, 0xCC99FF, 0xFFCC99, // 40-47 - 0x3366FF, 0x33CCCC, 0x99CC00, 0xFFCC00, 0xFF9900, 0xFF6600, 0x666699, 0x969696, // 48-55 - 0x003366, 0x339966, 0x003300, 0x333300, 0x993300, 0x993366, 0x333399, 0x333333, // 56-63 -]; +pub use gridcraft_core::color::INDEXED; /// Paper sizes (`pageSetup paperSize`) we name. const PAPERS: [(u32, &str); 14] = [ diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..fbd4f6553 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-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (number-format named and indexed colors render in grid and PDF, #1452) · **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 @@ -48,6 +48,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); right-drag menu 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 | +| 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) | | Header resize and double-click AutoFit | done (#42, #88 for header dividers) | @@ -161,6 +162,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Number-format named colors use their RGB values; indexed `[Color1]`–`[Color56]` resolve through the shared legacy palette in grid and PDF (#1452) | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | | 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 | From 47268a42af5a62c06a83dfdd7ba0a366bb7e9f63 Mon Sep 17 00:00:00 2001 From: quantmind-br Date: Thu, 15 Oct 2026 10:49:13 -0300 Subject: [PATCH 12/32] UI: function help follows the interface language The argument hint, autocomplete, Insert Function and the Formulas-tab menus read per-language tables of function descriptions and argument names (i18n/_functions.rs), keyed by the catalog's English text and binary-searched. The hint is assembled from the formula syntax's function name and separator plus the language's argument names, all of them or none, so it never mixes two languages. Insert Function searches the translated descriptions and shows readable category names. SPANISH_HELP moves to es_functions.rs with its 10 functions. --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 37 ++++++--- crates/ui-egui/src/formula_bar.rs | 11 +-- crates/ui-egui/src/formula_locale.rs | 105 +++++++++++++++++------- crates/ui-egui/src/i18n.rs | 49 +++++++++++ crates/ui-egui/src/i18n/es_functions.rs | 35 ++++++++ crates/ui-egui/src/ribbon.rs | 2 +- docs/localization-parity.md | 13 ++- 8 files changed, 202 insertions(+), 51 deletions(-) create mode 100644 crates/ui-egui/src/i18n/es_functions.rs diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..426cae12f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fb899c702..093e88cd8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1746,30 +1746,30 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: } fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let language = app.ui.language; ui.horizontal(|ui| { - ui.label("Search:"); - let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text("e.g. lookup, average, date")); + ui.label(language.tr("Search:")); + let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text(language.tr("e.g. lookup, average, date"))); r.request_focus(); }); - let locale = app.ui.language.formula_locale(); + let locale = language.formula_locale(); let list = gridcraft_engine::cmd::formulas::function_list(); - let q = d.search.to_ascii_lowercase(); + // The names and descriptions searched are the ones shown, so in the interface language. + let q = d.search.to_lowercase(); let hits: Vec<&Json> = list .iter() .filter(|f| { q.is_empty() - || f["name"].as_str().is_some_and(|n| locale.function_name(n).to_ascii_lowercase().contains(&q)) - || f["description"].as_str().is_some_and(|n| n.to_ascii_lowercase().contains(&q)) + || f["name"].as_str().is_some_and(|n| locale.function_name(n).to_lowercase().contains(&q)) + || f["description"].as_str().is_some_and(|n| language.function_description(n).unwrap_or(n).to_lowercase().contains(&q)) }) .take(300) .collect(); egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { for (i, f) in hits.iter().enumerate() { let name = locale.function_name(f["name"].as_str().unwrap_or("")); - let r = ui.add( - egui::Button::selectable(i == d.list_index, format!("{name:<18} {}", f["category"].as_str().unwrap_or(""))) - .min_size(vec2(480.0, 20.0)), - ); + let category = language.tr(function_category_label(f["category"].as_str().unwrap_or(""))); + let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{name:<18} {category}")).min_size(vec2(480.0, 20.0))); if r.clicked() { d.list_index = i; } @@ -1780,9 +1780,10 @@ fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir } }); if let Some(f) = hits.get(d.list_index) { + let name = f["name"].as_str().unwrap_or(""); ui.separator(); - ui.label(egui::RichText::new(crate::formula_locale::signature(f["name"].as_str().unwrap_or(""), locale).unwrap_or_default()).strong()); - ui.label(crate::formula_locale::description(f["name"].as_str().unwrap_or(""), locale).unwrap_or_default()); + ui.label(egui::RichText::new(crate::formula_locale::signature(name, locale, language).unwrap_or_default()).strong()); + ui.label(crate::formula_locale::description(name, locale, language).unwrap_or_default()); } let mut open = true; ok_cancel(ui, confirm, &mut open); @@ -1800,6 +1801,18 @@ fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir } } +/// Excel's name for a function category, given the catalog's id (`MathTrig`). +fn function_category_label(id: &str) -> &str { + match id { + "DateTime" => "Date & Time", + "Lookup" => "Lookup & Reference", + "MathTrig" => "Math & Trig", + "AddIn" => "Add-in and Automation", + // Financial, Logical, Text, Statistical, Engineering, Database, Information, … + other => other, + } +} + fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.horizontal(|ui| { for tab in ["Find", "Replace"] { diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 7f1ad87fc..bf2c873a5 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -351,6 +351,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: // Autocomplete list and argument hint under the editor. if mine && ui.is_rect_visible(resp.rect) && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) { let below = resp.rect.left_bottom() + vec2(0.0, 4.0); + let language = app.ui.language; // Areas have their own clip rect: keep cell helpers inside the worksheet. let bounds = if ed.from_formula_bar { ui.ctx().content_rect() } else { ui.clip_rect() }; egui::Area::new(id.with("ac")).fixed_pos(below).constrain_to(bounds).order(egui::Order::Tooltip).show(ui.ctx(), |ui| { @@ -366,11 +367,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: if r.clicked() { pick = Some(i); } - if sel && let Some(desc) = crate::formula_locale::description(n, ed.locale) { + if sel && let Some(desc) = crate::formula_locale::description(n, ed.locale, language) { r.on_hover_text(desc); } } - if let Some(desc) = ed.autocomplete.get(ed.ac_index).and_then(|n| crate::formula_locale::description(n, ed.locale)) { + if let Some(desc) = ed.autocomplete.get(ed.ac_index).and_then(|n| crate::formula_locale::description(n, ed.locale, language)) { ui.separator(); ui.add(egui::Label::new(egui::RichText::new(desc).small()).wrap()); } @@ -380,12 +381,12 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ed.request_focus = true; } } else if let Some((name, arg)) = ed.current_function() - && let Some(sig) = crate::formula_locale::signature(&name, ed.locale) + && let Some(sig) = crate::formula_locale::signature(&name, ed.locale, language) { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 0.0; let (head, rest) = sig.split_once('(').unwrap_or((&sig, "")); - ui.label(egui::RichText::new(format!("{}(", ed.locale.function_name(head))).font(theme::ui_font(12.0))); + ui.label(egui::RichText::new(format!("{head}(")).font(theme::ui_font(12.0))); let inner = rest.trim_end_matches(')'); let parts: Vec<&str> = inner.split(&format!("{} ", ed.locale.list_separator())).collect(); for (i, part) in parts.iter().enumerate() { @@ -400,7 +401,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: }); // The description too, so values an argument takes (DATEDIF's units, #363) show // while it's typed; wrapped to the hint's width. - if let Some(desc) = crate::formula_locale::description(&name, ed.locale) { + if let Some(desc) = crate::formula_locale::description(&name, ed.locale, language) { ui.set_max_width(ui.min_rect().width().max(220.0)); ui.separator(); ui.add(egui::Label::new(egui::RichText::new(desc).small()).wrap()); diff --git a/crates/ui-egui/src/formula_locale.rs b/crates/ui-egui/src/formula_locale.rs index 6e833b95d..11b551b10 100644 --- a/crates/ui-egui/src/formula_locale.rs +++ b/crates/ui-egui/src/formula_locale.rs @@ -1,4 +1,5 @@ //! UI formula syntax boundary. Engine commands, control/API callers and XLSX keep English. +use crate::i18n::Language; use gridcraft_engine::formula::{self, FormulaLocale, ParseError}; use gridcraft_engine::model::Workbook; @@ -86,39 +87,55 @@ fn hide_udf_prefix(text: &str, locale: FormulaLocale) -> String { out } -// Descriptions/signatures for the supported Spanish function table; untranslated functions -// retain their English help. Parameter punctuation follows the same locale as entry. -const SPANISH_HELP: &[(&str, &str, &str)] = &[ - ("SUM", "SUMA(número1; [número2]; ...)", "Suma los números."), - ("AVERAGE", "PROMEDIO(número1; [número2]; ...)", "Devuelve el promedio de los números."), - ("IF", "SI(prueba_lógica; valor_si_verdadero; [valor_si_falso])", "Devuelve un valor según una condición."), - ("COUNT", "CONTAR(valor1; [valor2]; ...)", "Cuenta las celdas que contienen números."), - ("SUMIF", "SUMAR.SI(rango; criterio; [rango_suma])", "Suma las celdas que cumplen un criterio."), - ("COUNTIF", "CONTAR.SI(rango; criterio)", "Cuenta las celdas que cumplen un criterio."), - ("VLOOKUP", "BUSCARV(valor_buscado; matriz; indicador_columnas; [ordenado])", "Busca un valor en la primera columna de una tabla."), - ("TODAY", "HOY()", "Devuelve la fecha actual."), - ("TRUE", "VERDADERO()", "Devuelve el valor lógico VERDADERO."), - ("FALSE", "FALSO()", "Devuelve el valor lógico FALSO."), -]; - -pub(crate) fn signature(name: &str, locale: FormulaLocale) -> Option { - let canonical = locale.canonical_name(name); - if locale == FormulaLocale::Es - && let Some((_, signature, _)) = SPANISH_HELP.iter().find(|(name, ..)| *name == canonical) - { - return Some((*signature).into()); - } - crate::formula_bar::function_signature(canonical).map(|s| s.replace(',', &locale.list_separator().to_string())) +/// A function's argument hint. The name and separators follow the formula syntax; the argument +/// names follow the interface language, all of them or none, so a hint never mixes two languages. +pub(crate) fn signature(name: &str, locale: FormulaLocale, language: Language) -> Option { + let english = crate::formula_bar::function_signature(locale.canonical_name(name))?; + let Some((head, args)) = split_signature(&english) else { return Some(english) }; + let separator = format!("{} ", locale.list_separator()); + let args = match args.iter().map(|arg| translate_argument(arg, language)).collect::>>() { + Some(translated) => translated.join(&separator), + None => args.join(&separator), + }; + Some(format!("{}({args})", locale.function_name(head))) } -pub(crate) fn description(name: &str, locale: FormulaLocale) -> Option { - let canonical = locale.canonical_name(name); - if locale == FormulaLocale::Es - && let Some((_, _, description)) = SPANISH_HELP.iter().find(|(name, ..)| *name == canonical) - { - return Some((*description).into()); +/// A function's description in the interface language (English while untranslated). +pub(crate) fn description(name: &str, locale: FormulaLocale, language: Language) -> Option { + let english = crate::formula_bar::function_description(locale.canonical_name(name))?; + Some(language.function_description(&english).map_or(english, str::to_string)) +} + +/// `SUM(number1, [number2], ...)` -> `SUM` and its arguments as written. +fn split_signature(signature: &str) -> Option<(&str, Vec<&str>)> { + let (head, rest) = signature.split_once('(')?; + let inner = rest.strip_suffix(')')?; + Some((head, if inner.is_empty() { Vec::new() } else { inner.split(", ").collect() })) +} + +/// The catalog names of a signature's arguments (`number` for `[number2]`), without `...`; the +/// function help tables are checked against them. +#[cfg(test)] +pub(crate) fn argument_names(signature: &str) -> Vec<&str> { + split_signature(signature).map(|(_, args)| args.into_iter().map(argument_name).filter(|name| *name != "...").collect()).unwrap_or_default() +} + +/// `[number2]` -> `number`. A bracket can open or close on its own (SWITCH's +/// `[default_or_value2, result2]`, LAMBDA's `[parameter1, ...]`). +fn argument_name(arg: &str) -> &str { + let arg = arg.strip_prefix('[').unwrap_or(arg); + let arg = arg.strip_suffix(']').unwrap_or(arg); + arg.trim_end_matches(|c: char| c.is_ascii_digit()) +} + +/// `[number2]` -> `[número2]`: brackets, index and `...` stay. +fn translate_argument(arg: &str, language: Language) -> Option { + let name = argument_name(arg); + if name == "..." { + return Some(arg.to_string()); } - crate::formula_bar::function_description(canonical) + // `arg` is `[`? + `name` + index + `]`?, so the first match is the name itself. + Some(arg.replacen(name, language.function_argument(name)?, 1)) } /// Avoid suggesting an alias that would call a workbook/table name instead of the function. @@ -138,3 +155,31 @@ pub(crate) fn display_template(text: &str, locale: FormulaLocale, wb: &Workbook, } display(text, locale, wb, sheet) } + +#[cfg(test)] +mod tests { + use super::*; + + fn sig(name: &str, locale: FormulaLocale, language: Language) -> String { + signature(name, locale, language).unwrap_or_default() + } + + #[test] + fn function_help_follows_the_interface_language_and_the_formula_syntax() { + use FormulaLocale::{En, Es}; + // Spanish help over English formulas: the name and `,` stay as typed; brackets, indexes + // and `...` survive. + assert_eq!(sig("IF", En, Language::Es), "IF(prueba_lógica, [valor_si_verdadero], [valor_si_falso])"); + assert_eq!(sig("SUM", En, Language::Es), "SUM(número1, [número2], ...)"); + assert_eq!(description("IF", En, Language::Es).as_deref(), Some("Devuelve un valor según una condición.")); + // Spanish names and separators, from a localized name. + assert_eq!(sig("suma", Es, Language::Es), "SUMA(número1; [número2]; ...)"); + assert_eq!(description("SUMA", Es, Language::Es).as_deref(), Some("Suma los números.")); + // An argument list Spanish covers only in part stays English instead of mixing (the name + // is whatever the Spanish formula table calls SUMIFS). + let sumifs = sig("SUMIFS", Es, Language::Es); + assert!(sumifs.ends_with("(sum_range; criteria_range1; criteria1; ...)"), "{sumifs}"); + assert_eq!(description("SUMIFS", Es, Language::Es).as_deref(), Some("Adds the cells that meet every condition.")); + assert_eq!(sig("IF", En, Language::En), "IF(logical_test, [value_if_true], [value_if_false])"); + } +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 061698df9..76a98a209 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; mod cs; mod de; mod es; +mod es_functions; mod fr; mod ko; mod pt; @@ -132,6 +133,34 @@ impl Language { }; translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label)) } + + /// A worksheet function's description in this language, looked up by the English text of the + /// function catalog; `None` when untranslated, and the help shows the English. + pub fn function_description(self, english: &str) -> Option<&'static str> { + lookup(self.function_help()?.0, english) + } + + /// A function argument name in this language, given as the catalog spells it without + /// brackets or index (`number`, `logical_test`). + pub fn function_argument(self, english: &str) -> Option<&'static str> { + lookup(self.function_help()?.1, english) + } + + /// The function help tables (descriptions, argument names) of the languages that have them. + fn function_help(self) -> Option<(HelpTable, HelpTable)> { + match self { + Self::Es => Some((es_functions::DESCRIPTIONS, es_functions::ARGUMENTS)), + _ => None, + } + } +} + +/// English text and its translation, sorted by the English. +type HelpTable = &'static [(&'static str, &'static str)]; + +fn lookup(table: HelpTable, english: &str) -> Option<&'static str> { + let i = table.binary_search_by(|(key, _)| (*key).cmp(english)).ok()?; + table.get(i).map(|(_, translated)| *translated) } /// Deserializes a saved language leniently: an unknown or malformed value (a language removed in a @@ -594,6 +623,26 @@ const JAPANESE: &[(&str, &str)] = &[ mod tests { use super::*; + #[test] + fn function_help_is_sorted_and_keyed_by_current_catalog_text() { + // Lookups binary-search the tables. A key that no longer matches the catalog (an English + // description reworded, an argument renamed) would quietly show English again. + let catalog = gridcraft_engine::cmd::formulas::function_list(); + let descriptions: std::collections::HashSet<&str> = catalog.iter().filter_map(|f| f["description"].as_str()).collect(); + let arguments: std::collections::HashSet<&str> = + catalog.iter().filter_map(|f| f["signature"].as_str()).flat_map(crate::formula_locale::argument_names).collect(); + for language in Language::ALL { + let Some((description_table, argument_table)) = language.function_help() else { continue }; + for (table, catalog_text) in [(description_table, &descriptions), (argument_table, &arguments)] { + assert!(table.windows(2).all(|pair| pair[0].0 < pair[1].0), "{language:?} function help is not sorted and unique"); + for (english, translated) in table { + assert!(catalog_text.contains(english), "{language:?} translates {english:?}, which the function catalog doesn't use"); + assert!(!translated.is_empty(), "{language:?}: empty translation of {english:?}"); + } + } + } + } + #[test] fn translations_are_unique_and_preserve_unknown_text() { for (i, (en, ja)) in JAPANESE.iter().enumerate() { diff --git a/crates/ui-egui/src/i18n/es_functions.rs b/crates/ui-egui/src/i18n/es_functions.rs new file mode 100644 index 000000000..da607ed5b --- /dev/null +++ b/crates/ui-egui/src/i18n/es_functions.rs @@ -0,0 +1,35 @@ +//! Spanish help for the worksheet functions. It covers the 10 common functions Spanish had help +//! for (SUM, AVERAGE, IF…); the others keep their English help. + +/// Function descriptions. +pub(super) const DESCRIPTIONS: &[(&str, &str)] = &[ + ("Adds all the numbers in the arguments.", "Suma los números."), + ("Adds the cells that meet a condition.", "Suma las celdas que cumplen un criterio."), + ("Arithmetic mean of the numbers.", "Devuelve el promedio de los números."), + ("Counts the cells that meet a condition.", "Cuenta las celdas que cumplen un criterio."), + ("Counts the numbers.", "Cuenta las celdas que contienen números."), + ( + "Finds a value in the first column of a table and returns the value in the same row of another column.", + "Busca un valor en la primera columna de una tabla.", + ), + ("Returns one value when a condition is TRUE and another when it is FALSE.", "Devuelve un valor según una condición."), + ("Returns the current date.", "Devuelve la fecha actual."), + ("Returns the logical value FALSE.", "Devuelve el valor lógico FALSO."), + ("Returns the logical value TRUE.", "Devuelve el valor lógico VERDADERO."), +]; + +/// Argument names without brackets or index (`number`, not `[number2]`). +pub(super) const ARGUMENTS: &[(&str, &str)] = &[ + ("col_index_num", "indicador_columnas"), + ("criteria", "criterio"), + ("logical_test", "prueba_lógica"), + ("lookup_value", "valor_buscado"), + ("number", "número"), + ("range", "rango"), + ("range_lookup", "ordenado"), + ("sum_range", "rango_suma"), + ("table_array", "matriz"), + ("value", "valor"), + ("value_if_false", "valor_si_falso"), + ("value_if_true", "valor_si_verdadero"), +]; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 9c6d89eeb..07d307611 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1780,7 +1780,7 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { let locale = app.ui.language.formula_locale(); let name = app.session.active().map(|d| crate::formula_locale::completion_name(n, locale, &d.wb, d.wb.active_sheet)).unwrap_or(n); - let desc = crate::formula_locale::description(n, locale).unwrap_or_default(); + let desc = crate::formula_locale::description(n, locale, app.ui.language).unwrap_or_default(); if ui.button(name).on_hover_text(desc).clicked() { app.run_or_alert("formulas.insertFunction", json!({"name": n})); ui.close(); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 09197ff6c..699ee1547 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (function help follows the interface language) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, @@ -16,8 +16,14 @@ right-to-left sheets and full complex-script support. Part of each. The tables are used by the ribbon, sheet tabs and widgets (`ribbon.rs`, `tabs.rs`, `widgets.rs`), the Format Cells dialog, every dialog title and the shared OK/Cancel buttons; the other dialogs, menus, - context menus, task panes, status messages, command labels and function descriptions are still - English literals. Untranslated labels fall back to English. + context menus, task panes, status messages and command labels are still English literals. + Untranslated labels fall back to English. +- **Function help** (the argument hint, autocomplete, Insert Function and the Formulas-tab + menus) follows the interface language through per-language tables keyed by the catalog's + English text (`i18n/_functions.rs`): descriptions, and argument names from which the + hint is assembled with the formula syntax's function name and separator. Spanish translates + the 10 common functions it had help for (SUM, AVERAGE, IF…); other languages show the English + help. - **About 2,200 user-visible strings** in all (heuristic source scan: ~1,700 UI and command strings plus ~500 function descriptions), so each shipped language covers **~12%** (measured count / estimated total). @@ -78,6 +84,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Function help in the interface language: argument hints, autocomplete, Insert Function (search, categories) and the Formulas-tab menus read per-language tables of descriptions and argument names keyed by the catalog's English text; Spanish keeps its 10 functions in the new format | | 2026-10-12 | minor | Format Cells looks up its tabs, labels, options and note through `Language::tr` (Fill tab keyed `Format Cells\|Fill`); dialog titles and OK/Cancel too. Labels already in a table (Number, Currency, Fill…) show translated; the rest fall back to English until the tables add them | | 2026-10-12 | minor | Brazilian Portuguese catalog (`i18n/pt.rs`) grows from 377 to 923 entries: every key the other catalogs translate, including the dialog strings #669 starts looking up (~42%) | | 2026-10-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open | From ea01ad999b22d26218b405594384bab9985f3909 Mon Sep 17 00:00:00 2001 From: Eduardo Garcia Date: Thu, 15 Oct 2026 10:49:49 -0300 Subject: [PATCH 13/32] fix #1443: Ctrl+Q no longer closes the window outside macOS egui's default quit shortcut closed the window without asking to save. Clear it off macOS (Cmd+Q stays the app's Quit there) and add a regression test. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/lib.rs | 6 ++++ crates/ui-egui/tests/keyboard_shortcuts.rs | 34 ++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f96cdac3c..62d0d34f4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1050,6 +1050,12 @@ impl SheetApp { if ctx.options(|o| o.zoom_with_keyboard) { ctx.options_mut(|o| o.zoom_with_keyboard = false); } + // egui closes the window on Ctrl+Q (and with Shift or Alt held too) without asking to save + // (#1443). Excel for Windows has no such key (Ctrl+Q is Quick Analysis); macOS keeps Cmd+Q, + // the app's Quit there. + if ctx.os() != egui::os::OperatingSystem::Mac && ctx.options(|o| !o.quit_shortcuts.is_empty()) { + ctx.options_mut(|o| o.quit_shortcuts.clear()); + } let zoom = self.ui.zoom_factor(); if (ctx.zoom_factor() - zoom).abs() > 0.001 { ctx.set_zoom_factor(zoom); diff --git a/crates/ui-egui/tests/keyboard_shortcuts.rs b/crates/ui-egui/tests/keyboard_shortcuts.rs index c0a1307b3..ea593611d 100644 --- a/crates/ui-egui/tests/keyboard_shortcuts.rs +++ b/crates/ui-egui/tests/keyboard_shortcuts.rs @@ -214,3 +214,37 @@ fn alt_down_only_opens_enabled_table_filter_buttons() { assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "B1"); } } + +/// #1443: Ctrl+Q (also with Shift or Alt, which is what AltGr+Q reports on Windows) must not +/// close the window and lose unsaved input; macOS keeps Cmd+Q as the app's Quit. +#[test] +fn ctrl_q_does_not_close_the_window_outside_macos() { + let closes = |h: &mut Harness, k: Key, m: Modifiers| -> bool { + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: m }); + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: m }); + let mut closed = false; + for _ in 0..3 { + h.step(); + closed |= h.output().viewport_output.values().any(|v| v.commands.iter().any(|c| matches!(c, egui::ViewportCommand::Close))); + } + closed + }; + let ctrl_alt = Modifiers { alt: true, ..CTRL }; + for os in [egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] { + let mut h = harness(book("A1")); + h.ctx.set_os(os); + h.run_steps(2); + for (name, m) in [("Ctrl+Q", CTRL), ("Ctrl+Shift+Q", CTRL_SHIFT), ("Ctrl+Alt+Q", ctrl_alt)] { + assert!(!closes(&mut h, Key::Q, m), "{os:?}: {name} closed the window"); + } + assert!(h.ctx.options(|o| o.quit_shortcuts.is_empty()), "{os:?}: no quit shortcut left"); + } + // macOS: Cmd+Q stays the app's Quit. + let mut h = harness(book("A1")); + // The harness's first frame already ran as the host OS; restore egui's default, as a real + // macOS launch would have it. + h.ctx.options_mut(|o| o.quit_shortcuts = vec![egui::KeyboardShortcut::new(Modifiers::COMMAND, Key::Q)]); + h.ctx.set_os(egui::os::OperatingSystem::Mac); + h.run_steps(2); + assert!(h.ctx.options(|o| !o.quit_shortcuts.is_empty()), "Cmd+Q is still egui's quit shortcut on macOS"); +} From 27dab49e3dce585b03d474204a5df0577c8cd792 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:14:41 +0200 Subject: [PATCH 14/32] UI: Delete dismisses the AutoComplete proposal, and deleting text never brings it back (#1521) --- crates/ui-egui/src/formula_bar.rs | 51 ++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 7f1ad87fc..26f0c9bfd 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -154,6 +154,18 @@ fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) { }); } +/// Backspace and Delete take back the AutoComplete proposal (Excel's selected rest), not a typed character. +fn dismiss_completion(i: &mut egui::InputState, completion: &mut Option) { + if completion.is_some() && (i.consume_key(egui::Modifiers::NONE, Key::Backspace) || i.consume_key(egui::Modifiers::NONE, Key::Delete)) { + *completion = None; + } +} + +/// Column AutoComplete proposes only after typing: deleting characters (Backspace, Delete) brings no proposal back, as in Excel. +fn entry_shrank(chars_before: usize, text: &str) -> bool { + text.chars().count() < chars_before +} + /// The text editor used in the cell and in the formula bar (they share `app.editor`). pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32) { let Some(mut ed) = app.editor.take() else { return }; @@ -173,10 +185,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: let enter = i.consume_key(egui::Modifiers::NONE, Key::Enter) || i.consume_key(egui::Modifiers::SHIFT, Key::Enter); let tab = i.consume_key(egui::Modifiers::NONE, Key::Tab) || i.consume_key(egui::Modifiers::SHIFT, Key::Tab); let esc = i.consume_key(egui::Modifiers::NONE, Key::Escape); - // Backspace takes back the AutoComplete proposal (Excel's selected rest), not a typed character. - if ed.completion.is_some() && i.consume_key(egui::Modifiers::NONE, Key::Backspace) { - ed.completion = None; - } + dismiss_completion(i, &mut ed.completion); (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, m.shift) }); if alt_enter { @@ -279,6 +288,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: job.wrap.max_width = if multiline { wrap_width } else { f32::INFINITY }; ui.fonts_mut(|f| f.layout_job(job)) }; + let chars_before = ed.text.chars().count(); let edit = if multiline || ed.text.contains('\n') { TextEdit::multiline(&mut ed.text) } else { TextEdit::singleline(&mut ed.text) }; let mut output = edit .id(id) @@ -313,6 +323,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ed.completion = None; if app.ui.autocomplete && !ed.is_formula() + && !entry_shrank(chars_before, &ed.text) && ed.caret == ed.text.chars().count() && let Some(sh) = app.session.active().and_then(|d| d.wb.sheet(ed.sheet)) { @@ -474,3 +485,35 @@ pub fn function_signature(name: &str) -> Option { pub fn function_description(name: &str) -> Option { function_info(name).and_then(|f| f["description"].as_str().map(str::to_string)) } + +#[cfg(test)] +mod tests { + use super::*; + + fn press(key: Key) -> egui::RawInput { + let mut raw = egui::RawInput::default(); + raw.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + raw + } + + #[test] + fn backspace_and_delete_dismiss_completion() { + let ctx = egui::Context::default(); + for key in [Key::Backspace, Key::Delete] { + let mut completion = Some("SomeText".to_string()); + let _ = ctx.run_ui(press(key), |ui| ui.input_mut(|i| dismiss_completion(i, &mut completion))); + assert_eq!(completion, None, "{key:?} must drop the proposal"); + } + let mut completion = Some("SomeText".to_string()); + let _ = ctx.run_ui(press(Key::A), |ui| ui.input_mut(|i| dismiss_completion(i, &mut completion))); + assert!(completion.is_some()); + } + + #[test] + fn only_typing_proposes_completion() { + assert!(!entry_shrank(4, "SomeT")); + // Backspacing "Some" to "Som" must not bring "SomeText" back. + assert!(entry_shrank(4, "Som")); + assert!(!entry_shrank(4, "Same")); + } +} From c6be911b9972ac480078797eacbafe64e2288a06 Mon Sep 17 00:00:00 2001 From: ismaelezequiel Date: Thu, 15 Oct 2026 12:43:48 -0300 Subject: [PATCH 15/32] ui: truncate long workbook titles in the title bar --- crates/ui-egui/src/ribbon.rs | 114 +++++++++++++++++++-- crates/xlsx/src/tests/passthrough_tests.rs | 2 +- 2 files changed, 104 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 9c6d89eeb..f9ec3d1d0 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -112,17 +112,8 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { app.print_now(json!({})); } }); - // Centered title. - let title = app.session.active().map(|d| d.display_title()).unwrap_or_default(); - let dirty = app.session.active().is_some_and(|d| d.is_dirty()); - ui.painter().text( - rect.center(), - Align2::CENTER_CENTER, - format!("{title}{}", if dirty { " •" } else { "" }), - theme::ui_bold(13.0), - t.text, - ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let left_end = ui.min_rect().right(); + let right = ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); let __k = icon_button(ui, Icon::Search, t.text_dim, "Search commands (⌘⇧Space)", vec2(28.0, ch)); control_keytip(app, ui, &__k, "commandSearch"); @@ -157,11 +148,58 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { act(app, "view.darkMode", json!({})); } theme.context_menu(|ui| theme_choices(app, ui)); + ui.min_rect().left() }); + // Centred title, cut with "…" so it never runs under the controls on either side. + let title = app.session.active().map(|d| d.display_title()).unwrap_or_default(); + let dirty = app.session.active().is_some_and(|d| d.is_dirty()); + let max_w = title_max_width(rect.center().x, left_end, right.inner); + paint_title(ui, rect.center(), &title, dirty, max_w, t.text); }); }); } +/// Lay out the title truncated to fit `max_w` together with the unsaved marker (when `dirty`). +fn title_galleys( + painter: &egui::Painter, + title: &str, + dirty: bool, + max_w: f32, + color: Color32, +) -> (std::sync::Arc, Option>) { + let font = theme::ui_bold(13.0); + let dot = dirty.then(|| painter.layout_no_wrap(" •".to_string(), font.clone(), color)); + let dot_w = dot.as_ref().map_or(0.0, |g| g.size().x); + let mut job = egui::text::LayoutJob::simple_singleline(title.to_string(), font, color); + job.wrap = egui::text::TextWrapping::truncate_at_width((max_w - dot_w).max(0.0)); + (painter.layout_job(job), dot) +} + +/// Gap kept between the title and the controls beside it. +const TITLE_GAP: f32 = 12.0; + +/// The widest the centred title may be without reaching the controls that end at `left_end` or +/// start at `right_start`. +fn title_max_width(center_x: f32, left_end: f32, right_start: f32) -> f32 { + let half = (center_x - left_end).min(right_start - center_x) - TITLE_GAP; + (2.0 * half).max(0.0) +} + +/// Paint the workbook title centred on `center`, truncated with "…" to `max_w`. The unsaved +/// marker stays visible however short the title gets. +fn paint_title(ui: &Ui, center: egui::Pos2, title: &str, dirty: bool, max_w: f32, color: Color32) { + let (galley, dot) = title_galleys(ui.painter(), title, dirty, max_w, color); + let dot_w = dot.as_ref().map_or(0.0, |g| g.size().x); + let w = galley.size().x + dot_w; + let left = center.x - w / 2.0; + let top = center.y - galley.size().y / 2.0; + let galley_w = galley.size().x; + ui.painter().galley(pos2(left, top), galley, color); + if let Some(dot) = dot { + ui.painter().galley(pos2(left + galley_w, top), dot, color); + } +} + pub fn show(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let collapsed = app.ui.ribbon_collapsed; @@ -2498,6 +2536,60 @@ mod title_bar_tests { let lights_end = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT; assert_eq!(MAC_CONTENT_LEFT - lights_end, margin); } + + #[test] + fn title_fits_between_the_controls() { + // Room limited by the nearer side, symmetric about the centre. + assert_eq!(title_max_width(500.0, 300.0, 900.0), 2.0 * (200.0 - TITLE_GAP)); + assert_eq!(title_max_width(500.0, 100.0, 650.0), 2.0 * (150.0 - TITLE_GAP)); + // Controls overlapping the centre leave no room rather than a negative width. + assert_eq!(title_max_width(500.0, 520.0, 900.0), 0.0); + } + + /// Lay out a title in a real egui frame, returning (title text, title width, dot width). + fn layout_title(title: &str, dirty: bool, max_w: f32) -> (String, f32, f32) { + let ctx = egui::Context::default(); + ctx.set_fonts(theme::font_definitions()); + let mut out = (String::new(), 0.0, 0.0); + let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| { + let (galley, dot) = title_galleys(ui.painter(), title, dirty, max_w, Color32::WHITE); + // The glyphs actually drawn: `galley.text()` is the untruncated source. + let shown = galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| g.chr)).collect(); + out = (shown, galley.size().x, dot.map_or(0.0, |d| d.size().x)); + }); + frame.textures_delta.clear(); // nothing consumes the atlas in a headless test + out + } + + #[test] + fn long_title_is_truncated_with_an_ellipsis() { + let long = format!("upload-{}-cleaned (1).xlsx", "3FMyNTYg2gDaANiAAACq20AAAAQMBqug".repeat(4)); + let (text, w, _) = layout_title(&long, false, 300.0); + assert!(text.ends_with('…'), "truncated title ends with an ellipsis: {text:?}"); + assert!(text.chars().count() < long.chars().count()); + assert!(w <= 300.0, "title stays within its room: {w}"); + } + + #[test] + fn short_title_is_left_whole() { + let (text, _, _) = layout_title("Book1.xlsx", false, 300.0); + assert_eq!(text, "Book1.xlsx"); + } + + #[test] + fn unsaved_dot_survives_truncation() { + let long = "a very long workbook name ".repeat(20); + let (text, w, dot_w) = layout_title(&long, true, 200.0); + assert!(dot_w > 0.0, "the unsaved marker is laid out"); + assert!(text.ends_with('…')); + assert!(w + dot_w <= 200.0, "title and marker fit together: {}", w + dot_w); + } + + #[test] + fn no_room_draws_nothing_but_does_not_crash() { + let (_, w, _) = layout_title("Book1.xlsx", true, 0.0); + assert!(w <= 20.0, "a zero-width slot leaves at most the ellipsis: {w}"); + } } #[cfg(test)] diff --git a/crates/xlsx/src/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs index d844daa05..eff83703e 100644 --- a/crates/xlsx/src/tests/passthrough_tests.rs +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -477,7 +477,7 @@ fn kept_vml_drawings_are_charged_to_the_kept_total() { + wb.sheets.iter().filter_map(|s| s.preserved.as_ref()).map(|p| p.byte_len()).sum::(); assert!(kept <= one + VML.len() / 2, "kept {kept} bytes"); assert!(wb.sheet(0).unwrap().preserved.as_ref().is_some_and(|p| p.vml.is_some())); - assert!(!wb.sheet(1).unwrap().preserved.as_ref().is_some_and(|p| p.vml.is_some())); + assert!(wb.sheet(1).unwrap().preserved.as_ref().is_none_or(|p| p.vml.is_none())); assert!(rep.warnings.iter().any(|w| w.contains("\"Other\"") && w.contains("too large")), "{:?}", rep.warnings); // Both sheets keep their drawings when there is room. let (wb, _) = read_xlsx(&rich_package_with(CONTENT_TYPES, true)).unwrap(); From 7a352cee0950aa81a581fad1e565a2388627140e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 15 Oct 2026 21:08:21 +0200 Subject: [PATCH 16/32] Grid: an armed Format Painter paints whole rows and columns from their headers (refs #1211) --- crates/engine/src/cmd/edit.rs | 57 ++++++++++++ crates/ui-egui/src/grid.rs | 28 +++--- crates/ui-egui/tests/format_painter.rs | 118 +++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 82dbe0be7..351fea1cd 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1557,6 +1557,63 @@ fn format_painter(s: &mut Session, p: &Json) -> Result { let Some(src_sheet) = wb.sheet(sheet) else { return Ok(()) }; let (h, w) = (src.height(), src.width()); let dest = if dest.is_single() { RangeRef::new(dest.start, dest.start.offset_clamped(h as i64 - 1, w as i64 - 1)) } else { dest }; + // Whole rows or columns take a line format, as the ribbon's formatting commands give + // them, instead of a style on each of their cells (a column alone has a million). The + // line format comes from the first cell of the source pattern across the line; cells + // already stored follow the pattern. Row heights and column widths stay as they are. + let rows = dest.is_full_rows() && !dest.is_full_cols(); + let cols = dest.is_full_cols() && !dest.is_full_rows(); + let src_full = (rows && src.is_full_rows()) || (cols && src.is_full_cols()); + if rows || cols { + let (first, src_first, n) = if rows { (dest.start.row, src.start.row, h) } else { (dest.start.col, src.start.col, w) }; + let lines = if rows { dest.start.row..=dest.end.row } else { dest.start.col..=dest.end.col }; + for line in lines { + let sl = src_first + (line - first) % n; + // The source cell for position `k` along the line. + let at = |k: u32| if rows { CellRef::new(sl, src.start.col + k % w) } else { CellRef::new(src.start.row + k % h, sl) }; + let line_style = if src_full { + if rows { src_sheet.rows.get(&sl) } else { src_sheet.cols.get(&sl) }.and_then(|i| i.style) + } else { + Some(src_sheet.style_id(at(0))) + }; + let line_style = line_style.map(|st| if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) }); + let (dest_line, src_line) = if rows { + (RangeRef::rows(line, line), RangeRef::rows(sl, sl)) + } else { + (RangeRef::cols(line, line), RangeRef::cols(sl, sl)) + }; + let mut cells: Vec = + cx.wb.sheet(dest_sheet).map(|sh| sh.cells.iter_range(dest_line).map(|(c, _)| c).collect()).unwrap_or_default(); + if src_full { + cells.extend( + src_sheet + .cells + .iter_range(src_line) + .map(|(c, _)| if rows { CellRef::new(line, c.col) } else { CellRef::new(c.row, line) }), + ); + } + if cols { + // A row format outranks a column format: the column's cells in formatted + // rows need a style of their own. + cells.extend( + cx.wb + .sheet(dest_sheet) + .into_iter() + .flat_map(|sh| sh.rows.iter().filter(|(_, i)| i.style.is_some()).map(|(r, _)| CellRef::new(*r, line))), + ); + } + let sh = cx.sheet_mut(dest_sheet)?; + (if rows { &mut sh.rows } else { &mut sh.cols }).entry(line).or_default().style = line_style; + for c in cells { + let st = src_sheet.style_id(at(if rows { c.col } else { c.row })); + let st = if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) }; + cx.sheet_mut(dest_sheet)?.set_style(c, st); + cx.touch(dest_sheet, c); + } + } + *cx.sel = crate::Selection::range(dest); + return Ok(()); + } for c in dest.iter().take(2_000_000) { let sc = CellRef::new(src.start.row + (c.row - dest.start.row) % h, src.start.col + (c.col - dest.start.col) % w); let st = src_sheet.style_id(sc); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 9a953c36e..25e33ad4a 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2019,8 +2019,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & if point_header(app, wb, sh, RangeRef::cols(c, c)) { return; } + // An armed Format Painter paints the columns dragged over instead of moving them. if resp.drag_started() && !mods.any() + && app.session.format_painter.is_none() && sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, c)) @@ -2264,17 +2266,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & { select_to_pointer(app, geo, sh, wb, p); } - // An armed Format Painter pastes onto the cells just clicked or dragged over - // (not while pointing a formula reference, which also drags a Select). - if app.session.format_painter.is_some() && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) { - let dest = app.session.active().map(|d| d.selection.current().a1()); - if let Some(dest) = dest - && let Err(e) = app.run("edit.formatPainter", json!({"apply": dest})) - { - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); - } - } + format_painter_apply(app); } + // Whole rows or columns clicked or dragged over by their headers. + Drag::Rows(_) | Drag::Cols(_) => format_painter_apply(app), Drag::Fill { target: Some(t) } => { let src = sel.current(); if t != src { @@ -2342,6 +2337,19 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & keyboard(app, &ctx, resp, geo, sh); } +/// An armed Format Painter pastes onto the cells just clicked or dragged over (not while +/// pointing a formula reference, which also drags a Select). +fn format_painter_apply(app: &mut SheetApp) { + if app.session.format_painter.is_some() && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) { + let dest = app.session.active().map(|d| d.selection.current().a1()); + if let Some(dest) = dest + && let Err(e) = app.run("edit.formatPainter", json!({"apply": dest})) + { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } +} + /// Point mode on a header: inserts the whole-column or whole-row reference instead of selecting. fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef) -> bool { let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false }; diff --git a/crates/ui-egui/tests/format_painter.rs b/crates/ui-egui/tests/format_painter.rs index 1a9c5742c..54a78930a 100644 --- a/crates/ui-egui/tests/format_painter.rs +++ b/crates/ui-egui/tests/format_painter.rs @@ -76,3 +76,121 @@ fn ribbon_button_picks_up_the_format_and_a_click_on_a_cell_pastes_it() { assert!(h.state().session.format_painter.is_none(), "a single-click painter disarms after one use"); assert!(!pressed(&h)); } + +fn header_point(h: &Harness, header: &str) -> Pos2 { + 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); + match header.parse::() { + Ok(row) => { + let cell = geo.cell_rect(sheet, CellRef::new(row - 1, 0)).center(); + Pos2::new(geo.cells.left() - geo.header_w * 0.5, cell.y) + } + Err(_) => { + let cell = geo.cell_rect(sheet, CellRef::parse(&format!("{header}1")).unwrap()).center(); + Pos2::new(cell.x, geo.cells.top() - geo.header_h * 0.5) + } + } +} + +fn drag(h: &mut Harness, from: Pos2, to: Pos2) { + for event in [ + Event::PointerMoved(from), + Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerMoved(to), + Event::PointerMoved(to + egui::vec2(1.0, 0.0)), + Event::PointerButton { pos: to + egui::vec2(1.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ] { + h.input_mut().events.push(event); + h.run_steps(1); + } + h.run_steps(2); +} + +fn stored_cells(h: &Harness) -> usize { + h.state().session.active().unwrap().wb.active().unwrap().cells.len() +} + +#[test] +fn a_click_on_a_row_header_pastes_the_format_on_the_whole_row() { + let mut h = harness(); + let source = style(&h, "A1"); + h.get_by_label("Format Painter").click(); + h.run_steps(2); + + let at = header_point(&h, "5"); + drag(&mut h, at, at); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "5:5"); + for a1 in ["A5", "K5", "XFD5"] { + assert_eq!(style(&h, a1), source, "{a1} must take A1's format"); + } + assert_ne!(style(&h, "A6"), source, "row 6 was not painted"); + // The row takes a row format: its 16384 cells are not stored one by one. + assert_eq!(stored_cells(&h), 1); + assert!(h.state().session.format_painter.is_none(), "a single-click painter disarms after one use"); + assert!(!pressed(&h)); +} + +#[test] +fn dragging_over_column_headers_with_a_kept_painter_formats_every_column() { + let mut h = harness(); + let source = style(&h, "A1"); + h.state_mut().session.execute("edit.formatPainter", json!({"sticky": true})).unwrap(); + h.run_steps(2); + + let (from, to) = (header_point(&h, "C"), header_point(&h, "E")); + drag(&mut h, from, to); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "C:E"); + for a1 in ["C2", "D500", "E1048576"] { + assert_eq!(style(&h, a1), source, "{a1} must take A1's format"); + } + for a1 in ["B2", "F2"] { + assert_ne!(style(&h, a1), source, "{a1} is outside the dragged headers"); + } + assert_eq!(stored_cells(&h), 1, "whole columns take a column format, not a million cell styles"); + assert!(h.state().session.format_painter.is_some(), "a double-click painter stays armed"); + + let at = header_point(&h, "8"); + drag(&mut h, at, at); + assert_eq!(style(&h, "Z8"), source, "the kept painter paints the next header clicked too"); +} + +#[test] +fn dragging_from_the_header_of_the_picked_up_column_paints_instead_of_moving_it() { + let mut h = harness(); + let session = &mut h.state_mut().session; + session.execute("range.setValues", json!({"range": "C1", "values": [["kept"]]})).unwrap(); + session.execute("home.italic", json!({"range": "C:C", "on": true})).unwrap(); + session.execute("selection.set", json!({"range": "C:C"})).unwrap(); + session.execute("edit.formatPainter", json!({})).unwrap(); + h.run_steps(2); + let source = style(&h, "C2"); + + let (from, to) = (header_point(&h, "C"), header_point(&h, "E")); + drag(&mut h, from, to); + let sheet = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!(sheet.value(CellRef::parse("C1").unwrap()), "kept".into(), "column C must stay where it is"); + assert_eq!(style(&h, "E2"), source, "E must take C's format"); + assert!(h.state().session.format_painter.is_none()); +} + +#[test] +fn a_two_cell_source_on_a_column_header_paints_a_column_format() { + let mut h = harness(); + let source = style(&h, "A1"); + let session = &mut h.state_mut().session; + session.execute("home.italic", json!({"range": "A2", "on": true})).unwrap(); + session.execute("home.fillColor", json!({"range": "7:7", "color": "#00FF00"})).unwrap(); + session.execute("selection.set", json!({"range": "A1:A2"})).unwrap(); + session.execute("edit.formatPainter", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(stored_cells(&h), 2); + + let at = header_point(&h, "C"); + drag(&mut h, at, at); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "C:C"); + assert_eq!(style(&h, "C1048575"), source, "the column takes the pattern's first cell as its format"); + assert_eq!(style(&h, "C7"), source, "row 7's own format must not hide the painted column"); + // Only C7 is stored on top of A1:A2: not a million cells, one per pattern repeat. + assert_eq!(stored_cells(&h), 3); +} From f3398ee218feb43751ac9279d1cac62e651d0261 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 04:27:49 +0200 Subject: [PATCH 17/32] Grid: a scrolled Table's header names and filter buttons in the column headings (#1423) --- crates/ui-egui/src/grid.rs | 91 +++++++++++++- crates/ui-egui/tests/table_headings.rs | 165 +++++++++++++++++++++++++ docs/ui-parity.md | 4 +- 3 files changed, 253 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/tests/table_headings.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 00e0c18be..edd108ecf 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -737,7 +737,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } // Headers. if sh.show_headings { - paint_headers(&painter, &geo, sh, &sel, &t, &quads); + let headings = table_headings(&geo, sh, sel.active); + paint_headers(&painter, &geo, sh, &sel, &t, &quads, &headings); paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t); } crate::outline::paint(&painter, &geo, sh, &t); @@ -1387,10 +1388,7 @@ pub(crate) fn paint_quadrant( if !range.contains(c) { continue; } - let r = filter_button_rect(geo, sh, c); - p.rect_filled(r, 2.0, Color32::from_gray(250)); - p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(170)), StrokeKind::Inside); - crate::icons::paint(p, r.shrink(1.5), if active { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron }, Color32::from_gray(70)); + paint_filter_button(p, filter_button_rect(geo, sh, c), active); } } @@ -1402,6 +1400,67 @@ pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect { Rect::from_min_size(pos2(rect.right() - s - pad, rect.bottom() - s - pad), vec2(s, s)) } +/// A Table column heading that shows the column's header name in place of its letters. +#[derive(Clone, Debug)] +pub struct TableHeading { + pub col: u32, + /// The table's header row, which the filter menu is keyed by. + pub header_row: u32, + pub name: String, + /// The heading slot over the column (logical coordinates). + pub rect: Rect, + /// The filter button, when the table shows filter buttons. + pub button: Option, + /// The column is filtered (the button shows the funnel). + pub filtered: bool, +} + +/// The Table whose header names replace the column letters: the one holding the active cell, +/// with its header row scrolled out of view and some of its rows still in view (Excel, #1423). +pub fn heading_table<'a>(geo: &Geo, sh: &'a Sheet, active: CellRef) -> Option<&'a gridcraft_engine::model::Table> { + // Page Layout view keeps the letters. + if geo.paper.is_some() || geo.header_h <= 0.0 { + return None; + } + let tb = sh.tables.iter().find(|t| t.header_row && t.range.contains(active))?; + let header = tb.range.start.row; + // A header row in the frozen rows is always on screen. + if header < geo.fr { + return None; + } + let top = geo.cells.top() + geo.frozen_h; + let bottom = |r: u32| geo.y(sh, r) + sh.row_height(r) * geo.z; + (bottom(header) <= top + 0.5 && bottom(tb.range.end.row) > top + 0.5).then_some(tb) +} + +/// The headings of `heading_table`'s visible columns. +pub fn table_headings(geo: &Geo, sh: &Sheet, active: CellRef) -> Vec { + let Some(tb) = heading_table(geo, sh, active) else { return Vec::new() }; + let af = sh.autofilter.as_ref().filter(|af| af.range == tb.range); + let mut out = Vec::new(); + for col in tb.range.start.col..=tb.range.end.col { + let w = sh.col_width(col) * geo.z; + let off = col - tb.range.start.col; + let Some(column) = tb.columns.get(off as usize) else { continue }; + if w <= 0.0 { + continue; + } + let rect = Rect::from_min_size(pos2(geo.x(sh, col), geo.head_top()), vec2(w, geo.header_h)); + // Clear of the 4-point column resize edge, and never wider than the column. + let s = (rect.height() - 6.0).clamp(10.0, 16.0).min(w - 7.0); + let button = (tb.filter_button && s > 0.0).then(|| Rect::from_min_size(pos2(rect.right() - s - 5.0, rect.center().y - s / 2.0), vec2(s, s))); + let filtered = tb.filter.iter().any(|(o, _)| *o == off) || af.is_some_and(|af| af.criteria.iter().any(|(o, _)| *o == off)); + out.push(TableHeading { col, header_row: tb.range.start.row, name: column.name.clone(), rect, button, filtered }); + } + out +} + +fn paint_filter_button(p: &Painter, r: Rect, filtered: bool) { + p.rect_filled(r, 2.0, Color32::from_gray(250)); + p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(170)), StrokeKind::Inside); + crate::icons::paint(p, r.shrink(1.5), if filtered { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron }, Color32::from_gray(70)); +} + /// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the /// first row's and first column's cells, the bottom and right edges from the last row's and last /// column's cells, one segment per visible cell (limited to the visible `rows` × `cols`). @@ -1696,6 +1755,7 @@ pub(crate) fn paint_headers( sel: &gridcraft_engine::Selection, t: &Tokens, quads: &[((u32, u32), (u32, u32), Rect)], + headings: &[TableHeading], ) { let font = theme::ui_font((11.5 * (geo.z / DISPLAY_SCALE).max(0.75)).min(16.0)); // The headers' own area: inside the outline bands. @@ -1731,7 +1791,21 @@ pub(crate) fn paint_headers( } pc.line_segment([pos2(cr.right() - 0.5, cr.top() + 3.0), pos2(cr.right() - 0.5, cr.bottom())], Stroke::new(1.0, t.header_line)); let color = if col_sel(c) { t.header_sel_text } else { t.header_text }; - pc.text(cr.center(), Align2::CENTER_CENTER, col_to_letters(c), font.clone(), color); + match headings.iter().find(|h| h.col == c) { + Some(h) => { + // The name is cut off at the button, as a cell's text is at its neighbour. + let end = h.button.map_or(cr.right(), |b| b.left()) - 2.0; + let room = Rect::from_min_max(cr.min, pos2(end.max(cr.left()), cr.bottom())).intersect(pc.clip_rect()); + let pad = 4.0 * (geo.z / DISPLAY_SCALE).max(0.75); + pc.with_clip_rect(room).text(pos2(cr.left() + pad, cr.center().y), Align2::LEFT_CENTER, &h.name, font.clone(), color); + if let Some(b) = h.button { + paint_filter_button(&pc, b, h.filtered); + } + } + None => { + pc.text(cr.center(), Align2::CENTER_CENTER, col_to_letters(c), font.clone(), color); + } + } } } let pr = p.with_clip_rect(Rect::from_min_max(pos2(left.left(), clip.top()), pos2(left.right(), clip.bottom()))); @@ -2016,6 +2090,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } let c = geo.col_at(sh, p.x); + // A Table heading's filter button, while the header names stand in for the letters. + if let Some(h) = table_headings(geo, sh, sel.active).into_iter().find(|h| h.col == c && h.button.is_some_and(|b| b.contains(p))) { + app.grid.filter_menu = Some((c, h.header_row, geo.mirror_pos(p))); + return; + } if point_header(app, wb, sh, RangeRef::cols(c, c)) { return; } diff --git a/crates/ui-egui/tests/table_headings.rs b/crates/ui-egui/tests/table_headings.rs new file mode 100644 index 000000000..5a2685e01 --- /dev/null +++ b/crates/ui-egui/tests/table_headings.rs @@ -0,0 +1,165 @@ +//! A Table's header names and filter buttons stand in for the column letters while its header row +//! is scrolled out of view and the active cell is inside it, as in Excel (#1423). + +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, RangeRef}; +use gridcraft_engine::model::Sheet; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::grid::{Geo, heading_table, table_headings}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// A Table on `range` (header row 3) with these header names and numbers below. +fn table(s: &mut Session, range: &str, names: &[&str]) { + let r = RangeRef::parse(range).unwrap(); + let mut rows = vec![names.iter().map(|n| json!(n)).collect::>()]; + for i in r.start.row + 1..=r.end.row { + rows.push(names.iter().map(|_| json!(i)).collect()); + } + s.execute("range.setValues", json!({"range": r.start.a1(), "values": rows})).unwrap(); + s.execute("insert.table", json!({"range": range, "header": true})).unwrap(); +} + +fn session(active: &str) -> Session { + let mut s = Session::new(); + s.new_workbook(); + table(&mut s, "A3:E40", &["Bank", "Date", "Type", "Amount", "Ref"]); + s.execute("selection.set", json!({"cell": active})).unwrap(); + s +} + +fn sheet(s: &Session) -> Sheet { + s.active().unwrap().wb.active().unwrap().clone() +} + +/// The grid scrolled so that `row` is the first one under the frozen rows. +fn geo_at(sh: &Sheet, row: u32) -> Geo { + let fr = sh.freeze.map_or(0, |f| f.0); + let scroll = vec2(0.0, (sh.row_top(row) - sh.row_top(fr)) as f32); + Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 600.0)), scroll) +} + +fn names(geo: &Geo, sh: &Sheet, active: &str) -> Vec<(u32, String)> { + table_headings(geo, sh, CellRef::parse(active).unwrap()).into_iter().map(|h| (h.col, h.name)).collect() +} + +fn owned(v: &[(u32, &str)]) -> Vec<(u32, String)> { + v.iter().map(|(c, n)| (*c, n.to_string())).collect() +} + +#[test] +fn header_names_replace_the_letters_only_while_the_header_row_is_out_of_view() { + let s = session("C20"); + let sh = sheet(&s); + let c20 = CellRef::parse("C20").unwrap(); + assert!(heading_table(&geo_at(&sh, 0), &sh, c20).is_none(), "header row 3 on screen: letters"); + assert!(heading_table(&geo_at(&sh, 2), &sh, c20).is_none(), "header row 3 at the top: letters"); + assert_eq!(heading_table(&geo_at(&sh, 3), &sh, c20).map(|t| t.range.a1()).as_deref(), Some("A3:E40")); + assert_eq!(names(&geo_at(&sh, 10), &sh, "C20"), owned(&[(0, "Bank"), (1, "Date"), (2, "Type"), (3, "Amount"), (4, "Ref")])); + // Scrolled past the table's last row, the letters come back. + assert!(heading_table(&geo_at(&sh, 40), &sh, c20).is_none()); +} + +#[test] +fn an_active_cell_outside_the_table_keeps_the_letters() { + let s = session("G20"); + let sh = sheet(&s); + // The table's rows fill the screen, so only where the active cell is tells the two apart. + assert!(heading_table(&geo_at(&sh, 10), &sh, CellRef::parse("G20").unwrap()).is_none()); + assert!(heading_table(&geo_at(&sh, 10), &sh, CellRef::parse("E20").unwrap()).is_some()); +} + +#[test] +fn a_header_row_in_the_frozen_pane_keeps_the_letters() { + let mut s = session("C20"); + s.execute("view.freezePanes", json!({"cell": "A4"})).unwrap(); + let sh = sheet(&s); + assert_eq!(sh.freeze, Some((3, 0))); + assert!(heading_table(&geo_at(&sh, 10), &sh, CellRef::parse("C20").unwrap()).is_none()); + + // Frozen above the header row, the header row scrolls under the frozen rows. + s.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + let sh = sheet(&s); + assert_eq!(sh.freeze, Some((1, 0))); + assert!(heading_table(&geo_at(&sh, 2), &sh, CellRef::parse("C20").unwrap()).is_none()); + assert!(heading_table(&geo_at(&sh, 3), &sh, CellRef::parse("C20").unwrap()).is_some()); +} + +#[test] +fn with_two_tables_the_names_come_from_the_active_cells_table() { + let mut s = Session::new(); + s.new_workbook(); + table(&mut s, "A3:C40", &["Bank", "Date", "Type"]); + table(&mut s, "E3:G40", &["Region", "Units", "Price"]); + let sh = sheet(&s); + let geo = geo_at(&sh, 10); + assert_eq!(names(&geo, &sh, "F20"), owned(&[(4, "Region"), (5, "Units"), (6, "Price")])); + assert_eq!(names(&geo, &sh, "B20"), owned(&[(0, "Bank"), (1, "Date"), (2, "Type")])); + assert_eq!(names(&geo, &sh, "D20"), owned(&[])); +} + +#[test] +fn hidden_columns_leave_the_other_names_over_their_own_columns() { + let mut s = session("C20"); + s.execute("home.hideColumns", json!({"cols": "B:B"})).unwrap(); + let sh = sheet(&s); + assert_eq!(sh.col_width(1), 0.0); + let geo = geo_at(&sh, 10); + let headings = table_headings(&geo, &sh, CellRef::parse("C20").unwrap()); + let got: Vec<(u32, &str)> = headings.iter().map(|h| (h.col, h.name.as_str())).collect(); + assert_eq!(got, [(0, "Bank"), (2, "Type"), (3, "Amount"), (4, "Ref")]); + for h in &headings { + let cell = geo.cell_rect(&sh, CellRef::new(10, h.col)); + assert_eq!((h.rect.left(), h.rect.right()), (cell.left(), cell.right()), "{} sits over column {}", h.name, h.col); + let b = h.button.unwrap(); + assert!(b.left() > cell.left() && b.right() < cell.right() && b.top() >= geo.head_top() && b.bottom() <= geo.cells.top()); + } +} + +fn click(h: &mut Harness, at: Pos2) { + let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.input_mut().events.extend([Event::PointerMoved(at), press(true)]); + h.step(); + h.input_mut().events.push(press(false)); + h.run_steps(3); +} + +#[test] +fn the_button_in_a_replaced_heading_opens_that_columns_filter_menu() { + let mut s = session("C20"); + s.execute("home.hideColumns", json!({"cols": "B:B"})).unwrap(); + s.execute("selection.set", json!({"cell": "C20"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + let sh = sheet(&h.state().session); + let scroll = sh.row_top(10) as f32; + h.state_mut().view_mut().unwrap().scroll.y = scroll; + h.run_steps(2); + if let Ok(path) = std::env::var("GRIDCRAFT_TABLE_HEADINGS_SCREENSHOT") { + h.render().unwrap().save(path).unwrap(); + } + let app = h.state(); + let geo = Geo::new(&sh, app.grid.rect.unwrap(), app.view().scroll); + let headings = table_headings(&geo, &sh, CellRef::parse("C20").unwrap()); + let amount = headings.iter().find(|x| x.name == "Amount").unwrap(); + assert_eq!(amount.col, 3); + + click(&mut h, amount.button.unwrap().center()); + let menu = h.state().grid.filter_menu.map(|(c, r, _)| (c, r)); + assert_eq!(menu, Some((3, 2)), "the menu of column D, keyed by the table's header row"); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "C20", "the button doesn't select"); + + // Beside the button the heading still selects the whole column. + h.state_mut().grid.filter_menu = None; + click(&mut h, pos2(amount.rect.left() + 6.0, amount.rect.center().y)); + assert_eq!(h.state().grid.filter_menu, None); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "D:D"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..6c6383c4b 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-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (a scrolled Table's header names in the column headings, #1423) · **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. | Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), no "Show outline symbols" option yet, and a block of cells still groups rows without the Rows / Columns dialog | | Reorder selected whole columns by dragging their headers | done (10-11) | | Freeze panes, split panes | freeze done; split to verify | +| A Table's header names and filter buttons in the column headings once its header row scrolls away | done (#1423): with the active cell in the Table, as in Excel; the button opens the column's filter menu, the rest of the heading still selects the column. Plain AutoFilter ranges keep their letters (Excel does the same) | | Copy as HTML table for other apps | done (#36, 10-10) | | Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | | Move and resize charts, pictures and shapes | charts can't be resized (#190) | @@ -161,6 +162,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | With the active cell in a Table whose header row is scrolled out of view, the column headings show the header names and their filter buttons in place of the letters (#1423) | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | | 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 | From 295f7dde3300b0341ec5b0ae7d49563c182097ab Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 14:09:48 +0200 Subject: [PATCH 18/32] =?UTF-8?q?Fill=20handle:=20right-drag=20opens=20the?= =?UTF-8?q?=20fill=20menu=20(Copy=20Cells,=20Fill=20Series,=20Fill=20Forma?= =?UTF-8?q?tting=20Only,=20Fill=20Without=20Formatting,=20Series=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/cmd/edit.rs | 3 +- crates/engine/src/fill.rs | 21 +- crates/engine/src/tests.rs | 37 +++ crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/grid.rs | 77 ++++- crates/ui-egui/src/lib.rs | 1 + .../ui-egui/tests/fill_handle_right_drag.rs | 268 ++++++++++++++++++ docs/ui-parity.md | 5 +- 8 files changed, 399 insertions(+), 15 deletions(-) create mode 100644 crates/ui-egui/tests/fill_handle_right_drag.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index c69bac208..c5c84146b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -79,7 +79,7 @@ pub fn specs() -> Vec { "AutoFill", [], None, - "{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies)", + "{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up)", has_doc, auto_fill ), @@ -1357,6 +1357,7 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result { "alternate" => crate::fill::FillMode::Alternate, "formats" => crate::fill::FillMode::Formats, "values" => crate::fill::FillMode::ValuesOnly, + "extend" => crate::fill::FillMode::Extend, _ => crate::fill::FillMode::Series, }; if target.count() > 5_000_000 || src.count() > 5_000_000 { diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index 5848bb0ca..29a6f3f9f 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -19,7 +19,17 @@ pub enum FillMode { Alternate, Copy, Formats, + /// Fill Without Formatting: extends like a plain drag, the targets keep their formats. ValuesOnly, + /// Fill Series from the right-drag menu: extends like a plain drag, and a lone number counts up. + Extend, +} + +impl FillMode { + /// The modes that continue the source's series rather than copy it. + fn extends_series(self) -> bool { + matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly) + } } /// Built-in custom lists. @@ -67,15 +77,18 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F cols.into_iter().map(|c| CellRef::new(lane, c)).collect() }; let fmt = |c: &(CellRef, Option)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str(); - let pattern = if matches!(mode, FillMode::Series | FillMode::Alternate) { + let pattern = if mode.extends_series() || mode == FillMode::Alternate { let detected = Pattern::detect(&src_cells, &custom, cx.wb.date_system, src_cells.first().map(fmt).unwrap_or_default()); - if mode == FillMode::Alternate { detected.alternate(&src_cells) } else { detected } + match (mode, detected) { + (FillMode::Alternate, p) | (FillMode::Extend, p @ Pattern::Copy) => p.alternate(&src_cells), + (_, p) => p, + } } else { Pattern::Copy }; // A source with no common pattern (`Product 1`, `On backorder`): each cell continues its // own series, one step per repeat of the source. - let per_cell: Vec = if mode == FillMode::Series && matches!(pattern, Pattern::Copy) && src_cells.len() > 1 { + let per_cell: Vec = if mode.extends_series() && matches!(pattern, Pattern::Copy) && src_cells.len() > 1 { src_cells.iter().map(|c| Pattern::detect(std::slice::from_ref(c), &custom, cx.wb.date_system, fmt(c))).collect() } else { Vec::new() @@ -93,7 +106,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F writes.push((*t, Some(existing))); continue; } - FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy => { + FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend => { let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style }; if let Some(f) = &cell.formula { if let Some(e) = f.expr() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ae499a189..b3427f16f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -767,6 +767,43 @@ fn ctrl_drag_fill_swaps_copy_and_series() { assert_eq!(v(&s, "G3"), Value::Empty); } +#[test] +fn fill_series_from_the_right_drag_menu_counts_a_lone_number() { + // Excel's right-drag Fill Series: a lone number counts up, everything else extends as a plain drag. + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "7"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A3", "mode": "extend"})).unwrap(); + assert_eq!([v(&s, "A2"), v(&s, "A3")], [Value::Number(8.0), Value::Number(9.0)]); + s.execute("range.setValues", json!({"range": "B1", "values": [[1], [3]]})).unwrap(); + s.execute("edit.autoFill", json!({"source": "B1:B2", "target": "B1:B4", "mode": "extend"})).unwrap(); + assert_eq!([v(&s, "B3"), v(&s, "B4")], [Value::Number(5.0), Value::Number(7.0)]); + s.execute("cell.set", json!({"cell": "C1", "input": "Item 9"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "C1", "target": "C1:C2", "mode": "extend"})).unwrap(); + assert_eq!(v(&s, "C2"), Value::from("Item 10")); + // A source with no common pattern continues each cell's own series, as a plain drag does. + s.execute("range.setValues", json!({"range": "D1", "values": [["Product 1"], ["On backorder"]]})).unwrap(); + s.execute("edit.autoFill", json!({"source": "D1:D2", "target": "D1:D4", "mode": "extend"})).unwrap(); + assert_eq!([v(&s, "D3"), v(&s, "D4")], [Value::from("Product 2"), Value::from("On backorder")]); +} + +#[test] +fn fill_without_formatting_extends_the_series_and_keeps_the_target_formats() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [null], [9]]})).unwrap(); + s.execute("home.bold", json!({"range": "A1:A2", "on": true})).unwrap(); + s.execute("home.italic", json!({"range": "A4", "on": true})).unwrap(); + s.execute("edit.autoFill", json!({"source": "A1:A2", "target": "A1:A4", "mode": "values"})).unwrap(); + assert_eq!([v(&s, "A3"), v(&s, "A4")], [Value::Number(3.0), Value::Number(4.0)]); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + let a4 = d.wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())); + assert!(a4.font.italic && !a4.font.bold, "A4 keeps its own formats"); + assert!(!d.wb.styles.get(sh.style_id(CellRef::parse("A3").unwrap())).font.bold); + s.execute("range.setValues", json!({"range": "B1", "values": [["Product 1"], ["On backorder"]]})).unwrap(); + s.execute("edit.autoFill", json!({"source": "B1:B2", "target": "B1:B4", "mode": "values"})).unwrap(); + assert_eq!([v(&s, "B3"), v(&s, "B4")], [Value::from("Product 2"), Value::from("On backorder")]); +} + #[test] fn insert_delete_cells_adjust() { let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fb899c702..838b6367d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -332,7 +332,7 @@ impl Dialog { Number { key: "step", label: "Step value:" }, Text { key: "stop", label: "Stop value:" }, ], - json!({"direction": "columns", "type": "linear", "dateUnit": "day", "step": 1}), + json!({"direction": if p("direction") == "rows" { "rows" } else { "columns" }, "type": "linear", "dateUnit": "day", "step": 1}), ), "comment" => { let threaded = p("threaded").as_bool().unwrap_or(true); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 00e0c18be..4842fcf0d 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -78,8 +78,10 @@ pub enum Drag { start: f32, orig: f32, }, + /// Dragging the fill handle; `menu` when the right button drags it (the fill menu opens on release). Fill { target: Option, + menu: bool, }, /// Dragging a freeze handle: `cols` for the column bar, `to` the line count it would freeze. Freeze { @@ -114,6 +116,8 @@ pub struct GridState { /// Column and row of the clicked header cell, and where to open the menu. pub filter_menu: Option<(u32, u32, Pos2)>, pub context_menu: Option, + /// After a right-drag of the fill handle: the source, the dragged range and where to open the menu. + pub fill_menu: Option<(RangeRef, RangeRef, Pos2)>, pub marching_phase: f32, /// The selection outline on its way to a new selection. pub glide: crate::selection_glide::SelectionGlide, @@ -749,7 +753,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { crate::paper_view::paint_rulers(app, &painter, rect, &geo, &t); } // Interaction. - let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu); + let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu, app.grid.fill_menu); crate::paper_view::interact(app, ui, &resp, &geo, sh, &wb); interact(app, ui, &resp, &geo, sh, &wb); crate::text_box::show(app, ui, &geo, sh); @@ -760,6 +764,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker); header_menu(app, ui, previous_popups.2 == app.grid.header_menu); context_menu(app, ui); + fill_menu(app, ui, previous_popups.3 == app.grid.fill_menu); let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) }; vertical_scrollbar(app, ui, sh, &geo, track_x, max_y, &t); if app.session.clipboard.is_some() { @@ -1650,7 +1655,7 @@ pub(crate) fn paint_selection( p.rect_filled(h, 0.0, t.sel_border); } // Fill-handle drag preview. - if let crate::grid::Drag::Fill { target: Some(tr) } = &app.grid.drag + if let crate::grid::Drag::Fill { target: Some(tr), .. } = &app.grid.drag && range_in_pane(*tr, rows, cols) { let rr = geo.range_rect(sh, *tr); @@ -2088,7 +2093,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } if on_handle(p) { - app.grid.drag = Drag::Fill { target: None }; + app.grid.drag = Drag::Fill { target: None, menu: resp.drag_started_by(egui::PointerButton::Secondary) }; return; } if on_cur_border(p) && resp.drag_started() { @@ -2223,10 +2228,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & Drag::Freeze { cols, .. } => { app.grid.drag = Drag::Freeze { cols, to: Some(freeze_boundary(geo, sh, p, cols)) }; } - Drag::Fill { .. } => { + Drag::Fill { menu, .. } => { let src = sel.current(); let target = fill_target(src, pc); - app.grid.drag = Drag::Fill { target: Some(target) }; + app.grid.drag = Drag::Fill { target: Some(target), menu }; scroll_by(app, auto); } Drag::Move { grab, .. } => { @@ -2263,7 +2268,17 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & select_to_pointer(app, geo, sh, wb, p); } } - Drag::Fill { target: Some(t) } => { + // 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 } => { + if let Some(p) = pos + && t != sel.current() + && !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) + { + app.grid.fill_menu = Some((sel.current(), t, geo.mirror_pos(p))); + } + } + Drag::Fill { target: Some(t), menu: false } => { let src = sel.current(); if t != src { // Ctrl (Cmd on macOS) swaps copying and extending, as in Excel. @@ -2574,7 +2589,16 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: } let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f))); // An open filter menu owns the keyboard even when a click inside it left focus on nothing (#1516). - if editing || app.dialog.is_some() || other_focus || app.message.is_some() || app.grid.filter_menu.is_some() { + // So do the fill menu (its Esc closes it, not the copy) and the right-drag that opens it (an + // arrow key would move the selection the menu fills from). + if editing + || app.dialog.is_some() + || other_focus + || app.message.is_some() + || app.grid.filter_menu.is_some() + || app.grid.fill_menu.is_some() + || matches!(app.grid.drag, Drag::Fill { menu: true, .. }) + { return; } if ctx.memory(|m| m.focused()).is_none() { @@ -2939,6 +2963,45 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { } } +/// The menu a right-drag of the fill handle opens on release, as in Excel. +fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { + let Some((src, target, at)) = app.grid.fill_menu else { return }; + let mut close = false; + let response = egui::Area::new(egui::Id::new("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(190.0); + let items = [ + ("Copy Cells", "copy"), + ("Fill Series", "extend"), + ("Fill Formatting Only", "formats"), + ("Fill Without Formatting", "values"), + ("-", ""), + ("Series…", "ui:series"), + ]; + let lang = crate::i18n::current(ui.ctx()); + for (label, mode) in items { + if label == "-" { + ui.separator(); + continue; + } + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { + close = true; + if mode == "ui:series" { + let _ = app.session.run("selection.set", json!({"range": target.union(&src).a1(), "active": src.start.a1()})); + let direction = if target.width() != src.width() { "rows" } else { "columns" }; + app.open_dialog("series", json!({"direction": direction})); + } else if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1(), "mode": mode})) { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } + } + }); + }); + if close || popup_dismissed(&response.response, was_open) { + app.grid.fill_menu = None; + } +} + /// Used by `EditState` users to keep the active cell visible while pointing. pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool) { let Some(d) = app.session.active() else { return }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f96cdac3c..4b4a1ba4f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1175,6 +1175,7 @@ impl SheetApp { && !ctx.text_edit_focused() && self.grid.context_menu.is_none() && self.grid.header_menu.is_none() + && self.grid.fill_menu.is_none() && self.grid.filter_menu.is_none() && self.grid.list_picker.is_none() && self.grid.renaming_tab.is_none() diff --git a/crates/ui-egui/tests/fill_handle_right_drag.rs b/crates/ui-egui/tests/fill_handle_right_drag.rs new file mode 100644 index 000000000..9562c1d63 --- /dev/null +++ b/crates/ui-egui/tests/fill_handle_right_drag.rs @@ -0,0 +1,268 @@ +//! Dragging the fill handle with the right button fills nothing on release: it opens Excel's fill +//! menu (Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…). + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, RangeRef, Value}; +use gridcraft_ui_egui::{SheetApp, grid::Drag, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(setup: impl FnOnce(&mut Session)) -> Harness { + let mut session = Session::new(); + session.new_workbook(); + setup(&mut session); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +/// B1:B2 holds 1, 2 and is selected. +fn one_two(s: &mut Session) { + s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2]]})).unwrap(); + s.execute("selection.set", json!({"range": "B1:B2"})).unwrap(); +} + +fn corner(h: &Harness, cell: &str) -> Pos2 { + 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); + geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).right_bottom() - vec2(1.0, 1.0) +} + +fn center(h: &Harness, cell: &str) -> Pos2 { + 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); + geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).center() +} + +fn press(h: &mut Harness, pos: Pos2, button: PointerButton) { + h.input_mut().events.extend([Event::PointerMoved(pos), Event::PointerButton { pos, button, pressed: true, modifiers: Modifiers::NONE }]); + h.step(); +} + +fn move_to(h: &mut Harness, from: Pos2, to: Pos2) { + for i in 1..=10 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 10.0))); + h.step(); + } +} + +fn release(h: &mut Harness, pos: Pos2, button: PointerButton) { + h.input_mut().events.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn escape(h: &mut Harness) { + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); +} + +/// Drags the handle of the current selection (whose bottom-right cell is `from`) to `to`. +fn drag_handle(h: &mut Harness, from: &str, to: &str, button: PointerButton) { + let (start, end) = (corner(h, from), center(h, to)); + press(h, start, button); + move_to(h, start, end); + release(h, end, button); +} + +fn value(h: &Harness, cell: &str) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap()) +} + +fn values(h: &Harness, cells: &[&str]) -> Vec { + cells.iter().map(|c| value(h, c)).collect() +} + +fn bold(h: &Harness, cell: &str) -> bool { + let d = h.state().session.active().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).font.bold +} + +fn italic(h: &Harness, cell: &str) -> bool { + let d = h.state().session.active().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).font.italic +} + +fn fills(h: &Harness) -> usize { + h.state().session.journal.iter().filter(|(id, _)| id == "edit.autoFill").count() +} + +fn selection(h: &Harness) -> String { + h.state().session.active().unwrap().selection.a1() +} + +fn n(x: f64) -> Value { + Value::Number(x) +} + +#[test] +fn right_drag_opens_the_fill_menu_and_fills_nothing() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Secondary); + + let menu = h.state().grid.fill_menu.map(|(src, target, _)| (src, target)); + assert_eq!(menu, Some((RangeRef::parse("B1:B2").unwrap(), RangeRef::parse("B1:B6").unwrap()))); + assert_eq!(values(&h, &["B3", "B6"]), [Value::Empty, Value::Empty]); + assert_eq!(fills(&h), 0); + assert!(h.state().grid.context_menu.is_none(), "not the cell context menu"); + assert_eq!(selection(&h), "B1:B2"); +} + +#[test] +fn left_drag_still_fills_the_series() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + + assert_eq!(values(&h, &["B3", "B4", "B5", "B6"]), [n(3.0), n(4.0), n(5.0), n(6.0)]); + assert!(h.state().grid.fill_menu.is_none()); +} + +#[test] +fn copy_cells_repeats_the_source() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Secondary); + h.get_by_label("Copy Cells").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B3", "B4", "B5", "B6"]), [n(1.0), n(2.0), n(1.0), n(2.0)]); + assert!(h.state().grid.fill_menu.is_none()); + assert_eq!(selection(&h), "B1:B6"); +} + +#[test] +fn fill_series_counts_up_from_a_lone_number() { + let mut h = harness(|s| { + s.execute("cell.set", json!({"cell": "B1", "input": "5"})).unwrap(); + s.execute("selection.set", json!({"cell": "B1"})).unwrap(); + }); + drag_handle(&mut h, "B1", "B4", PointerButton::Secondary); + h.get_by_label("Fill Series").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B2", "B3", "B4"]), [n(6.0), n(7.0), n(8.0)]); +} + +/// B1:B2 = 1, 2 in bold, B4 = 9 in italic. +fn formatted(s: &mut Session) { + s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [null], [9]]})).unwrap(); + s.execute("home.bold", json!({"range": "B1:B2", "on": true})).unwrap(); + s.execute("home.italic", json!({"range": "B4", "on": true})).unwrap(); + s.execute("selection.set", json!({"range": "B1:B2"})).unwrap(); +} + +#[test] +fn fill_formatting_only_leaves_the_values() { + let mut h = harness(formatted); + drag_handle(&mut h, "B2", "B4", PointerButton::Secondary); + h.get_by_label("Fill Formatting Only").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B3", "B4"]), [Value::Empty, n(9.0)]); + assert!(bold(&h, "B3") && bold(&h, "B4")); +} + +#[test] +fn fill_without_formatting_extends_the_series_under_the_targets_formats() { + let mut h = harness(formatted); + drag_handle(&mut h, "B2", "B4", PointerButton::Secondary); + h.get_by_label("Fill Without Formatting").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B3", "B4"]), [n(3.0), n(4.0)]); + assert!(!bold(&h, "B3") && !bold(&h, "B4"), "the source's bold stays behind"); + assert!(italic(&h, "B4")); +} + +#[test] +fn series_opens_the_series_dialog_on_the_dragged_cells_in_their_direction() { + let mut h = harness(|s| { + s.execute("cell.set", json!({"cell": "B1", "input": "1"})).unwrap(); + s.execute("selection.set", json!({"cell": "B1"})).unwrap(); + }); + drag_handle(&mut h, "B1", "E1", PointerButton::Secondary); + h.get_by_label("Series…").click(); + h.run_steps(3); + + assert_eq!(selection(&h), "B1:E1"); + let dialog = h.state().dialog.as_ref().map(|d| (d.name().to_string(), d.values.get("direction").cloned())); + assert_eq!(dialog, Some(("series".into(), Some(json!("rows"))))); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert_eq!(values(&h, &["C1", "D1", "E1"]), [n(2.0), n(3.0), n(4.0)]); +} + +#[test] +fn escape_mid_right_drag_cancels_it() { + let mut h = harness(|s| { + one_two(s); + s.execute("edit.copy", json!({"range": "D1"})).unwrap(); + s.execute("selection.set", json!({"range": "B1:B2"})).unwrap(); + }); + assert!(h.state().session.clipboard.is_some()); + let (start, end) = (corner(&h, "B2"), center(&h, "B6")); + press(&mut h, start, PointerButton::Secondary); + move_to(&mut h, start, end); + escape(&mut h); + assert_eq!(h.state().grid.drag, Drag::None, "the dashed outline goes"); + assert!(h.query_by_label("Copy Cells").is_none(), "the menu doesn't flash for the Esc frame"); + release(&mut h, end, PointerButton::Secondary); + + assert!(h.state().grid.fill_menu.is_none()); + assert!(h.state().grid.context_menu.is_none()); + assert_eq!(values(&h, &["B3", "B6"]), [Value::Empty, Value::Empty]); + assert!(h.state().session.clipboard.is_some(), "Esc cancels the drag, not the copy"); +} + +#[test] +fn arrow_keys_mid_right_drag_leave_the_source_alone() { + let mut h = harness(one_two); + let (start, end) = (corner(&h, "B2"), center(&h, "B6")); + press(&mut h, start, PointerButton::Secondary); + move_to(&mut h, start, end); + h.input_mut().events.push(Event::Key { key: Key::ArrowDown, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + release(&mut h, end, PointerButton::Secondary); + + assert_eq!(selection(&h), "B1:B2"); + assert_eq!(h.state().grid.fill_menu.map(|(src, ..)| src), Some(RangeRef::parse("B1:B2").unwrap())); +} + +#[test] +fn escape_closes_the_menu_without_filling() { + let mut h = harness(|s| { + one_two(s); + s.execute("edit.copy", json!({"range": "D1"})).unwrap(); + s.execute("selection.set", json!({"range": "B1:B2"})).unwrap(); + }); + drag_handle(&mut h, "B2", "B6", PointerButton::Secondary); + assert!(h.state().grid.fill_menu.is_some()); + escape(&mut h); + h.run_steps(2); + + assert!(h.state().grid.fill_menu.is_none()); + assert_eq!(values(&h, &["B3", "B6"]), [Value::Empty, Value::Empty]); + assert!(h.state().session.clipboard.is_some(), "Esc closes the menu, not the copy"); +} + +#[test] +fn right_click_on_the_handle_opens_the_context_menu() { + let mut h = harness(one_two); + let at = corner(&h, "B2"); + press(&mut h, at, PointerButton::Secondary); + release(&mut h, at, PointerButton::Secondary); + + assert!(h.state().grid.context_menu.is_some()); + assert!(h.state().grid.fill_menu.is_none()); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..0ee2d89d0 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-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (right-drag on the fill handle opens the fill menu) · **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 @@ -45,7 +45,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) | | 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); right-drag menu missing | +| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; Fill Days/Weekdays/Months/Years, 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 | @@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Right-drag on the fill handle opens the fill menu on release: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; Esc cancels the drag or closes the menu without filling | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | | 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 | From 14f2ea9f57f98d7c7f4f77b2cc9e97d0f21c618f Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 14:53:20 +0200 Subject: [PATCH 19/32] Cell editor: the caret contrasts with the cell's fill (fixes #1408) --- crates/ui-egui/src/grid.rs | 9 ++++ crates/ui-egui/tests/cell_editor_caret.rs | 59 +++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 crates/ui-egui/tests/cell_editor_caret.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 00e0c18be..73d912907 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2725,6 +2725,9 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, let id = egui::Id::new("gridcraft.cell_editor"); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0)))); child.set_clip_rect(clip); + // The theme's caret is meant for the UI chrome and vanishes on some fills (and, in the dark + // theme, on the white paper): pick black or white against the fill, like Excel. + child.visuals_mut().text_cursor.stroke.color = ink_on(fill); crate::formula_bar::editor_widget(app, &mut child, id, font, false, erect.width() - 4.0); } @@ -2734,6 +2737,12 @@ fn popup_dismissed(response: &egui::Response, was_open: bool) -> bool { response.ctx.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)) || (was_open && response.clicked_elsewhere()) } +/// Black or white, whichever reads on `fill` (the threshold chart labels use). +fn ink_on(fill: Color32) -> Color32 { + let l = 0.299 * fill.r() as f32 + 0.587 * fill.g() as f32 + 0.114 * fill.b() as f32; + if l < 150.0 { Color32::WHITE } else { Color32::BLACK } +} + /// The table whose header cell (`row`, `col`) was clicked, not just the first table in the column. fn filter_table_range(sh: &Sheet, col: u32, row: u32) -> Option { sh.tables.iter().find(|t| t.range.start.row == row && col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range) diff --git a/crates/ui-egui/tests/cell_editor_caret.rs b/crates/ui-egui/tests/cell_editor_caret.rs new file mode 100644 index 000000000..95b0ff6f3 --- /dev/null +++ b/crates/ui-egui/tests/cell_editor_caret.rs @@ -0,0 +1,59 @@ +//! The caret of the in-cell editor stays visible on the cell's fill, whatever the theme. +use egui::{Color32, Context, RawInput, Rect, Shape, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::{SheetApp, theme}; +use serde_json::json; + +fn frame(ctx: &Context, app: &mut SheetApp) -> egui::FullOutput { + let mut output = + ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() }, |ui| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }); + output.textures_delta.clear(); + output +} + +/// The caret painted while editing A1 over `fill` (`None`: no fill), with the app in `dark` mode. +fn caret(fill: Option<&str>, dark: bool) -> Color32 { + let ctx = Context::default(); + theme::apply(&ctx, dark); + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + if let Some(color) = fill { + app.run("home.fillColor", json!({"range": "A1", "color": color})).unwrap(); + } + for _ in 0..4 { + frame(&ctx, &mut app); + } + app.begin_edit(Some("abc".into()), false); + for _ in 0..4 { + frame(&ctx, &mut app); + } + let output = frame(&ctx, &mut app); + // The editor paints its backdrop first; the caret is the vertical segment after it. + let paper = fill.map_or(Color32::WHITE, |c| Color32::from_hex(c).unwrap()); + let backdrop = output + .shapes + .iter() + .rposition(|s| matches!(&s.shape, Shape::Rect(r) if r.fill == paper && r.rect.width() > 20.0)) + .expect("editor backdrop painted"); + output.shapes[backdrop..] + .iter() + .find_map(|s| match &s.shape { + Shape::LineSegment { points, stroke } if points[0].x == points[1].x => Some(stroke.color), + _ => None, + }) + .expect("caret painted") +} + +#[test] +fn caret_contrasts_with_the_cell_fill() { + assert_eq!(caret(Some("#1F3864"), false), Color32::WHITE, "dark fill, light theme"); + assert_eq!(caret(Some("#1F3864"), true), Color32::WHITE, "dark fill, dark theme"); + assert_eq!(caret(Some("#FFFF00"), true), Color32::BLACK, "light fill, dark theme"); + assert_eq!(caret(None, true), Color32::BLACK, "no fill, dark theme: the cell is still white paper"); + assert_eq!(caret(None, false), Color32::BLACK, "no fill, light theme"); +} From e8f049943f465013b584b8ef8693058fd938c367 Mon Sep 17 00:00:00 2001 From: Nick Gill Date: Fri, 16 Oct 2026 12:54:04 -0400 Subject: [PATCH 20/32] Charts: series that refer to workbook names ([0]!Name) plot their data (#1686) Excel writes a workbook-scoped defined name in a chart reference as `[0]!Name`. The reader kept that text, the lexer took `[0]` for a structured reference, and the series evaluated to #NAME?: a chart over dynamic (OFFSET) names opened blank with one #NAME? category. - Read: `[0]!Name` becomes `Name` in c:f (cat, val, xVal, yVal, bubbleSize, tx), chartex cx:f and _xlchart names. External books ([1]Sheet1!...), quoted sheet names and strings are untouched. - Write: a bare workbook-scoped name is written back as `[0]!Name`; a sheet-scoped name stays bare. A series name that is a defined name is written as a strRef instead of literal text. - Resolve: a series name that is a defined name is evaluated. Co-Authored-By: Claude Opus 5.5 --- crates/chart/src/resolve.rs | 5 +- crates/engine/tests/chart_workbook_names.rs | 127 ++++++++++++++++++++ crates/xlsx/src/chart.rs | 73 +++++++++-- crates/xlsx/src/chartex.rs | 6 +- crates/xlsx/src/read.rs | 2 +- crates/xlsx/src/tests/chart_book_names.rs | 100 +++++++++++++++ crates/xlsx/src/tests/mod.rs | 1 + docs/chart-parity.md | 8 +- docs/file-format-parity.md | 3 +- 9 files changed, 303 insertions(+), 22 deletions(-) create mode 100644 crates/engine/tests/chart_workbook_names.rs create mode 100644 crates/xlsx/src/tests/chart_book_names.rs diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index c007e3e90..9ec019e51 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -156,13 +156,14 @@ fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec { .collect() } -/// A series name: a reference/formula is evaluated, a quoted or bare literal is used as is. +/// A series name: a reference/formula or a defined name (`[0]!Title` in a file) is evaluated, +/// a quoted or other bare literal is used as is. fn series_name(wb: &Workbook, sheet: usize, name: &str) -> String { let t = name.trim(); if let Some(q) = t.strip_prefix('"').and_then(|q| q.strip_suffix('"')) { return q.replace("\"\"", "\""); } - if t.starts_with('=') || parse_ref(wb, sheet, t).is_some() && t.contains('!') { + if t.starts_with('=') || parse_ref(wb, sheet, t).is_some() && t.contains('!') || wb.name(t, sheet).is_some() { let vals = eval_list(wb, sheet, t); let parts: Vec = vals.iter().map(value_text).filter(|s| !s.is_empty()).take(8).collect(); return parts.join(" "); diff --git a/crates/engine/tests/chart_workbook_names.rs b/crates/engine/tests/chart_workbook_names.rs new file mode 100644 index 000000000..289a86dba --- /dev/null +++ b/crates/engine/tests/chart_workbook_names.rs @@ -0,0 +1,127 @@ +//! Chart series that point at workbook-scoped defined names. Excel writes these as `[0]!Name` +//! in ``; they used to read back verbatim and evaluate to `#NAME?`, so a chart +//! driven by dynamic (OFFSET) names opened blank with a single `#NAME?` category. + +use std::io::{Read, Write}; + +use gridcraft_engine::{Session, io}; +use gridcraft_model::Workbook; +use serde_json::json; + +const TASKS: usize = 6; + +/// A plan table, OFFSET names over its structured references (and names built on names), and +/// a stacked bar chart whose series name those names. +fn plan() -> Workbook { + let mut s = Session::new(); + s.new_workbook(); + let mut rows = vec![json!(["Task", "Start", "Done", "Left"])]; + for i in 0..TASKS { + rows.push(json!([format!("Task {}", i + 1), (45_200 + 7 * i).to_string(), (i + 1).to_string(), (TASKS - i).to_string()])); + } + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + let range = format!("A1:D{}", TASKS + 1); + s.execute("insert.table", json!({"range": range, "header": true, "name": "Plan"})).unwrap(); + for (name, refers) in [ + ("TaskNames", "=OFFSET(Plan[[#Headers],[Task]],1,0,COUNTA(Plan[Task]),1)"), + ("TaskStart", "=OFFSET(TaskNames,0,1)"), + ("TaskDone", "=OFFSET(TaskNames,0,2)"), + ("TaskLeft", "=OFFSET(TaskNames,0,3)"), + ("LeftTitle", "=Sheet1!$D$1"), + ] { + s.execute("formulas.defineName", json!({"name": name, "refersTo": refers})).unwrap(); + } + s.execute("insert.chart", json!({"range": range, "type": "bar", "subtype": "stacked"})).unwrap(); + let mut wb = Workbook::clone(&s.doc().unwrap().wb); + let chart = &mut wb.sheet_mut(0).unwrap().charts[0]; + assert_eq!(chart.series.len(), 3); + for (ser, (name, values)) in chart.series.iter_mut().zip([("Sheet1!$B$1", "TaskStart"), ("Sheet1!$C$1", "TaskDone"), ("LeftTitle", "TaskLeft")]) { + ser.name = Some(name.into()); + ser.categories = Some("TaskNames".into()); + ser.values = values.into(); + } + wb +} + +fn chart_xml(bytes: &[u8]) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut f = z.by_name("xl/charts/chart1.xml").unwrap(); + let mut xml = String::new(); + f.read_to_string(&mut xml).unwrap(); + xml +} + +/// The package with `edit` applied to its chart part. +fn edit_chart(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + let name = f.name().to_string(); + let mut data = Vec::new(); + f.read_to_end(&mut data).unwrap(); + if name == "xl/charts/chart1.xml" { + data = edit(String::from_utf8(data).unwrap()).into_bytes(); + } + out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(&data).unwrap(); + } + out.finish().unwrap().into_inner() +} + +fn reopen(bytes: &[u8]) -> Workbook { + io::open_bytes("plan.xlsx", bytes).unwrap().0 +} + +#[test] +fn workbook_names_in_chart_series_resolve_after_save_and_reopen() { + let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap(); + // Written as Excel writes them. + let xml = chart_xml(&bytes); + for f in ["[0]!TaskNames", "[0]!TaskStart", "[0]!TaskDone", "[0]!TaskLeft", "[0]!LeftTitle"] { + assert!(xml.contains(&format!("{f}")), "{f} missing from {xml}"); + } + // A series name that is a defined name stays a reference, not literal text. + assert!(!xml.contains("LeftTitle")); + + let wb = reopen(&bytes); + let chart = &wb.sheet(0).unwrap().charts[0]; + assert_eq!(chart.series[0].categories.as_deref(), Some("TaskNames")); + assert_eq!(chart.series[0].values, "TaskStart"); + assert_eq!(chart.series[2].name.as_deref(), Some("LeftTitle")); + + let data = gridcraft_chart::resolve(&wb, 0, chart); + let tasks: Vec = (1..=TASKS).map(|i| format!("Task {i}")).collect(); + assert_eq!(data.categories, tasks); + let names: Vec<&str> = data.series.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["Start", "Done", "Left"]); + let start: Vec> = (0..TASKS).map(|i| Some((45_200 + 7 * i) as f64)).collect(); + assert_eq!(data.series[0].values, start); + let left: Vec> = (0..TASKS).map(|i| Some((TASKS - i) as f64)).collect(); + assert_eq!(data.series[2].values, left); + + // A second save and reopen gives the same chart data. + let again = reopen(&io::save_bytes(&wb, "plan.xlsx").unwrap()); + assert_eq!(gridcraft_chart::resolve(&again, 0, &again.sheet(0).unwrap().charts[0]), data); +} + +#[test] +fn a_missing_workbook_name_resolves_to_no_values_without_panicking() { + let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap(); + let bytes = edit_chart(&bytes, |x| x.replace("[0]!TaskLeft", "[0]!NoSuchName")); + let wb = reopen(&bytes); + let chart = &wb.sheet(0).unwrap().charts[0]; + assert_eq!(chart.series[2].values, "NoSuchName"); + let data = gridcraft_chart::resolve(&wb, 0, chart); + assert!(data.series[2].values.iter().all(Option::is_none), "{:?}", data.series[2].values); + // The other series still plot. + assert_eq!(data.series[0].values.len(), TASKS); +} + +#[test] +fn external_workbook_refs_in_chart_series_are_kept_as_they_were() { + let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap(); + let bytes = edit_chart(&bytes, |x| x.replace("[0]!TaskDone", "[1]Sheet1!$C$2:$C$7")); + let wb = reopen(&bytes); + assert_eq!(wb.sheet(0).unwrap().charts[0].series[1].values, "[1]Sheet1!$C$2:$C$7"); +} diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index dee33c83d..f14cec83d 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -61,12 +61,49 @@ fn group_kind(g: &El) -> Option { }) } +/// Excel's file notation for "this workbook" in front of a workbook-scoped defined name. +const THIS_BOOK: &str = "[0]!"; + +/// A chart reference (``) read from a file → model text: drops a leading `=` and the +/// `[0]!` in front of workbook-scoped names (`[0]!Sales` → `Sales`), which the formula parser +/// would take for a structured reference. External books (`[1]Sheet1!$A$1`), quoted sheet +/// names and string literals are kept as they are. +pub(crate) fn chart_ref_from_file(text: &str) -> String { + let t = text.trim(); + let t = t.strip_prefix('=').unwrap_or(t); + let mut out = String::with_capacity(t.len()); + let mut quote: Option = None; + // The last character kept: `[0]!` only counts at the start or after an operator, + // never inside a structured reference (`T[[0]]`) or a longer name. + let mut prev: Option = None; + let mut i = 0; + while let Some(c) = t.get(i..).and_then(|r| r.chars().next()) { + let rest = t.get(i..).unwrap_or(""); + if quote.is_none() + && rest.starts_with(THIS_BOOK) + && prev.is_none_or(|p| !(p.is_alphanumeric() || "_.\\[]'\"!$#".contains(p))) + && rest.get(THIS_BOOK.len()..).and_then(|n| n.chars().next()).is_some_and(|n| n.is_alphabetic() || n == '_' || n == '\\') + { + i += THIS_BOOK.len(); + continue; + } + match (c, quote) { + ('"' | '\'', None) => quote = Some(c), + (q, Some(open)) if q == open => quote = None, + _ => {} + } + out.push(c); + prev = Some(c); + i += c.len_utf8(); + } + out +} + fn data_ref(e: Option<&El>) -> Option { let e = e?; for k in ["numRef", "strRef", "multiLvlStrRef"] { if let Some(f) = e.child(k).and_then(|r| r.child("f")) { - let t = f.text.trim(); - return Some(t.strip_prefix('=').unwrap_or(t).to_string()); + return Some(chart_ref_from_file(&f.text)); } } // Literal values become an array constant. @@ -84,14 +121,9 @@ fn data_ref(e: Option<&El>) -> Option { } fn read_series(s: &El) -> Series { - let name = s.child("tx").and_then(|tx| { - if let Some(f) = tx.path(&["strRef", "f"]) { - let t = f.text.trim(); - Some(t.strip_prefix('=').unwrap_or(t).to_string()) - } else { - tx.child("v").map(|v| v.text.clone()) - } - }); + let name = s + .child("tx") + .and_then(|tx| tx.path(&["strRef", "f"]).map(|f| chart_ref_from_file(&f.text)).or_else(|| tx.child("v").map(|v| v.text.clone()))); let sp = s.child("spPr"); let color = sp .and_then(|p| p.child("solidFill")) @@ -306,6 +338,21 @@ fn is_ref(f: &str) -> bool { matches!(gridcraft_formula::parse(f), Ok(Expr::Ref(_))) } +/// The name `f` is when it is a bare workbook-scoped defined name seen from sheet `si` (a +/// sheet-scoped name of the same spelling on `si` wins, as in formulas). +fn book_name(wb: &Workbook, si: usize, f: &str) -> Option { + let Ok(Expr::Name(n)) = gridcraft_formula::parse(f.trim()) else { return None }; + wb.name(&n, si).is_some_and(|d| d.scope.is_none()).then_some(n) +} + +/// A chart reference for ``: Excel writes a workbook-scoped name as `[0]!Name`. +fn chart_ref_to_file(wb: &Workbook, si: usize, f: &str) -> String { + match book_name(wb, si, f) { + Some(n) => format!("{THIS_BOOK}{n}"), + None => crate::fmla::text_to_file(f), + } +} + fn num_data(tag: &str, f: Option<&str>, wb: &Workbook, si: usize) -> String { let f = f.unwrap_or("").trim(); if f.is_empty() { @@ -322,7 +369,7 @@ fn num_data(tag: &str, f: Option<&str>, wb: &Workbook, si: usize) -> String { let _ = write!(s, ""); return s; } - let mut s = format!("{}", esc(&crate::fmla::text_to_file(f))); + let mut s = format!("{}", esc(&chart_ref_to_file(wb, si, f))); if let Some(vals) = ref_values(wb, si, f) { let _ = write!(s, "General", vals.len()); for (i, v) in vals.iter().enumerate() { @@ -383,7 +430,7 @@ fn str_data(tag: &str, f: &str, wb: &Workbook, si: usize) -> String { let _ = write!(s, ""); return s; } - let mut s = format!("{}", esc(&crate::fmla::text_to_file(f))); + let mut s = format!("{}", esc(&chart_ref_to_file(wb, si, f))); if let Some(vals) = ref_values(wb, si, f) { let _ = write!(s, "", vals.len()); for (i, v) in vals.iter().enumerate() { @@ -400,7 +447,7 @@ fn str_data(tag: &str, f: &str, wb: &Workbook, si: usize) -> String { fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String { let mut x = format!(""); if let Some(n) = &s.name { - if is_ref(n) { + if is_ref(n) || book_name(wb, si, n).is_some() { x.push_str(&str_data("tx", n, wb, si)); } else { let lit = n.trim().strip_prefix('"').and_then(|t| t.strip_suffix('"')).unwrap_or(n); diff --git a/crates/xlsx/src/chartex.rs b/crates/xlsx/src/chartex.rs index 2ab8c221b..e9f8729a4 100644 --- a/crates/xlsx/src/chartex.rs +++ b/crates/xlsx/src/chartex.rs @@ -48,15 +48,13 @@ fn kind_of(layout: &str) -> Option { fn formula(dim: &El) -> Option { let f = dim.child("f")?; - let t = f.text.trim(); - Some(crate::fmla::from_file(t.strip_prefix('=').unwrap_or(t))) + Some(crate::fmla::from_file(&crate::chart::chart_ref_from_file(&f.text))) } fn text(tx: &El) -> Option { if let Some(d) = tx.child("txData") { if let Some(f) = d.child("f") { - let t = f.text.trim(); - return Some(t.strip_prefix('=').unwrap_or(t).to_string()); + return Some(crate::chart::chart_ref_from_file(&f.text)); } return d.child("v").map(|v| v.text.clone()); } diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 1a479081c..ee93c1b77 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -265,7 +265,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { continue; } if lname.starts_with("_xlchart.") { - chart_names.push((lname, crate::fmla::from_file(text))); + chart_names.push((lname, crate::fmla::from_file(&crate::chart::chart_ref_from_file(text)))); continue; } if lname == "_xlnm.print_area" || lname == "_xlnm.print_titles" { diff --git a/crates/xlsx/src/tests/chart_book_names.rs b/crates/xlsx/src/tests/chart_book_names.rs new file mode 100644 index 000000000..d491826f3 --- /dev/null +++ b/crates/xlsx/src/tests/chart_book_names.rs @@ -0,0 +1,100 @@ +//! `[0]!Name` ("this workbook") in chart references: read as the bare name, written back for +//! workbook-scoped names only. + +use std::io::Read; +use std::sync::Arc; + +use gridcraft_model::{Anchor, Chart, ChartKind, DefinedName, LegendPos, Series, Sheet, Workbook}; + +use crate::chart::chart_ref_from_file; +use crate::{read_xlsx, write_xlsx}; + +#[test] +fn this_book_prefix_is_dropped_only_in_front_of_names() { + for (file, model) in [ + ("[0]!TaskNames", "TaskNames"), + ("=[0]!TaskNames", "TaskNames"), + (" [0]!_Hidden ", "_Hidden"), + ("([0]!a,[0]!b)", "(a,b)"), + // External books keep their index: they go through externalLink parts. + ("[1]Sheet1!$A$1:$A$5", "[1]Sheet1!$A$1:$A$5"), + ("[2]!Rates", "[2]!Rates"), + // Not a name after the prefix, or not at a name's start. + ("[0]!$A$1", "[0]!$A$1"), + ("Plan[[0]]", "Plan[[0]]"), + // Inside a quoted sheet name or a string. + ("'[0]!x'!$A$1", "'[0]!x'!$A$1"), + ("\"[0]!x\"", "\"[0]!x\""), + ("'It''s [0]!'!$A$1", "'It''s [0]!'!$A$1"), + ("Sheet1!$B$2:$B$9", "Sheet1!$B$2:$B$9"), + ("", ""), + ("[0]!", "[0]!"), + ("[0]!é", "é"), + ] { + assert_eq!(chart_ref_from_file(file), model, "{file}"); + } +} + +fn series(name: &str, values: &str) -> Series { + Series { + name: Some(name.into()), + categories: Some("Items".into()), + values: values.into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + smooth: false, + trendlines: Vec::new(), + } +} + +fn name(n: &str, scope: Option, formula: &str) -> DefinedName { + DefinedName { name: n.into(), scope, formula: formula.into(), comment: String::new(), hidden: false } +} + +#[test] +fn workbook_names_are_written_with_the_prefix_and_sheet_names_without() { + let mut wb = Workbook::default(); + let mut s = Sheet::new("Data"); + 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("Title", "Amounts"), series("Data!$B$1", "Local"), series("\"Plain\"", "Data!$B$2:$B$4")], + legend: LegendPos::Bottom, + data_labels: false, + gridlines: true, + style: 2, + x_title: None, + y_title: None, + source: None, + by_rows: false, + pivot: None, + }); + wb.sheets = vec![Arc::new(s)]; + wb.names = vec![ + name("Items", None, "Data!$A$2:$A$4"), + name("Amounts", None, "OFFSET(Items,0,1)"), + name("Title", None, "Data!$B$1"), + name("Local", Some(0), "Data!$C$2:$C$4"), + ]; + 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("[0]!Items"), "{xml}"); + assert!(xml.contains("[0]!Amounts"), "{xml}"); + assert!(xml.contains("[0]!Title"), "{xml}"); + // A sheet-scoped name is not this workbook's name: no prefix. + assert!(xml.contains("Local"), "{xml}"); + assert!(xml.contains("Plain"), "{xml}"); + + let (back, _) = read_xlsx(&bytes).unwrap(); + let got: Vec<(Option<&str>, Option<&str>, &str)> = + back.sheets[0].charts[0].series.iter().map(|s| (s.name.as_deref(), s.categories.as_deref(), s.values.as_str())).collect(); + assert_eq!( + got, + [(Some("Title"), Some("Items"), "Amounts"), (Some("Data!$B$1"), Some("Items"), "Local"), (Some("Plain"), Some("Items"), "Data!$B$2:$B$4"),] + ); +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 517113d0f..2bf6af7b0 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -1,6 +1,7 @@ //! Integration-style tests: round trips, hand-written fixtures, hostile input and CSV. mod array_export; +mod chart_book_names; mod chart_bool_default; mod csv_tests; mod encrypted_tests; diff --git a/docs/chart-parity.md b/docs/chart-parity.md index 9058b0935..1e8e3e2ac 100644 --- a/docs/chart-parity.md +++ b/docs/chart-parity.md @@ -1,6 +1,6 @@ # Chart parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (chart sheets are kept on save, still not editable (#727); previously minor: trendlines: model, XLSX round-trip, drawing, Add Chart Element) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-16 · **Change:** trivial (series that refer to workbook-scoped defined names (`[0]!Name` in the file) plot their data instead of `#NAME?` (#1686); previously trivial: chart sheets are kept on save, still not editable (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's chart types and chart elements against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated). @@ -66,6 +66,11 @@ extensions) is lost when GridCraft saves a workbook it opened. That is the main **~30% ready**: a user who opens an Excel workbook with charts and saves it gets simpler charts back. +Series data, categories and names may refer to defined names, as dynamic (OFFSET) ranges do: +Excel writes a workbook-scoped name as `[0]!Name` in ``. GridCraft reads it as the bare +name and writes it back with `[0]!`; a sheet-scoped name is written bare and external books +(`[1]Sheet1!…`) are kept as they are (#1686). Such series get no `numCache`/`strCache` on save. + ## Work (40–60 h) 1. Kinds survive XLSX round-trip (merge PRs #158, #161): 2–4 h. Chart resizing (#190): 1–2 h. @@ -80,6 +85,7 @@ back. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | Series that refer to workbook-scoped defined names (`[0]!Name`) resolve instead of showing `#NAME?`, and keep the prefix on save (#1686) | | 2026-10-12 | trivial | chart sheets are kept on save, still not editable (#727) | | 2026-10-11 | minor | Trendlines: all six types modelled, round-tripped through `c:trendline`, drawn with forecast and equation/R² labels; `chart.addElement` `trendline` | | 2026-10-11 | minor | PivotCharts (`insert.pivotChart`) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 158149fb3..8b2379f20 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (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; previously trivial: XLSX chart sheets are kept on save (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-16 · **Change:** trivial (XLSX chart series that refer to workbook-scoped defined names as `[0]!Name` read and write that notation (#1686); previously trivial: 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) · **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** @@ -85,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | XLSX charts: `[0]!Name` (a workbook-scoped defined name) in series references is read as the name, so the series resolve instead of showing `#NAME?`, and is written back for workbook-scoped names (#1686) | | 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped | | 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) | | 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) | From 8e7438b434d777502dfd0d624b9ac5f42b29acd5 Mon Sep 17 00:00:00 2001 From: Nick Gill Date: Fri, 16 Oct 2026 13:32:54 -0400 Subject: [PATCH 21/32] Charts: write names in chart references qualified, as Excel needs (#1686) Checked black-box in Excel for Windows (Microsoft 365) with synthetic workbooks: a bare name in draws the whole chart blank, without a repair prompt, for workbook- and sheet-scoped names alike, while `[0]!Name` and `Sheet1!Name` plot. - Write: a sheet-scoped name is written `Sheet!Name` (quoted when needed) instead of bare; an already qualified name keeps its sheet; an undefined name gets `[0]!`. A reference to an unknown sheet is kept as written. A qualified series name is written as a strRef. - Resolve: a series name `Sheet1!Name` is looked up on that sheet, as the evaluator does, instead of being shown as literal text. Co-Authored-By: Claude Opus 5.5 --- crates/chart/src/resolve.rs | 11 +++- crates/engine/tests/chart_workbook_names.rs | 33 +++++++----- crates/xlsx/src/chart.rs | 38 +++++++++---- crates/xlsx/src/tests/chart_book_names.rs | 59 +++++++++++++++++---- docs/chart-parity.md | 8 +-- docs/file-format-parity.md | 2 +- 6 files changed, 114 insertions(+), 37 deletions(-) diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index 9ec019e51..84a0d3212 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -156,6 +156,15 @@ fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec { .collect() } +/// 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(), + } +} + /// A series name: a reference/formula or a defined name (`[0]!Title` in a file) is evaluated, /// a quoted or other bare literal is used as is. fn series_name(wb: &Workbook, sheet: usize, name: &str) -> String { @@ -163,7 +172,7 @@ fn series_name(wb: &Workbook, sheet: usize, name: &str) -> String { if let Some(q) = t.strip_prefix('"').and_then(|q| q.strip_suffix('"')) { return q.replace("\"\"", "\""); } - if t.starts_with('=') || parse_ref(wb, sheet, t).is_some() && t.contains('!') || wb.name(t, sheet).is_some() { + if t.starts_with('=') || parse_ref(wb, sheet, t).is_some() && t.contains('!') || is_defined_name(wb, sheet, t) { let vals = eval_list(wb, sheet, t); let parts: Vec = vals.iter().map(value_text).filter(|s| !s.is_empty()).take(8).collect(); return parts.join(" "); diff --git a/crates/engine/tests/chart_workbook_names.rs b/crates/engine/tests/chart_workbook_names.rs index 289a86dba..0ac156b62 100644 --- a/crates/engine/tests/chart_workbook_names.rs +++ b/crates/engine/tests/chart_workbook_names.rs @@ -10,8 +10,8 @@ use serde_json::json; const TASKS: usize = 6; -/// A plan table, OFFSET names over its structured references (and names built on names), and -/// a stacked bar chart whose series name those names. +/// A plan table, OFFSET names over its structured references (and names built on names; one +/// scoped to the sheet), and a stacked bar chart whose series name those names. fn plan() -> Workbook { let mut s = Session::new(); s.new_workbook(); @@ -22,20 +22,23 @@ fn plan() -> Workbook { s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); let range = format!("A1:D{}", TASKS + 1); s.execute("insert.table", json!({"range": range, "header": true, "name": "Plan"})).unwrap(); - for (name, refers) in [ - ("TaskNames", "=OFFSET(Plan[[#Headers],[Task]],1,0,COUNTA(Plan[Task]),1)"), - ("TaskStart", "=OFFSET(TaskNames,0,1)"), - ("TaskDone", "=OFFSET(TaskNames,0,2)"), - ("TaskLeft", "=OFFSET(TaskNames,0,3)"), - ("LeftTitle", "=Sheet1!$D$1"), + for (name, refers, scope) in [ + ("TaskNames", "=OFFSET(Plan[[#Headers],[Task]],1,0,COUNTA(Plan[Task]),1)", "Workbook"), + ("TaskStart", "=OFFSET(TaskNames,0,1)", "Workbook"), + ("TaskDone", "=OFFSET(TaskNames,0,2)", "Workbook"), + ("TaskLeft", "=OFFSET(TaskNames,0,3)", "Sheet1"), + ("LeftTitle", "=Sheet1!$D$1", "Workbook"), + ("DoneTitle", "=Sheet1!$C$1", "Sheet1"), ] { - s.execute("formulas.defineName", json!({"name": name, "refersTo": refers})).unwrap(); + s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap(); } s.execute("insert.chart", json!({"range": range, "type": "bar", "subtype": "stacked"})).unwrap(); let mut wb = Workbook::clone(&s.doc().unwrap().wb); let chart = &mut wb.sheet_mut(0).unwrap().charts[0]; assert_eq!(chart.series.len(), 3); - for (ser, (name, values)) in chart.series.iter_mut().zip([("Sheet1!$B$1", "TaskStart"), ("Sheet1!$C$1", "TaskDone"), ("LeftTitle", "TaskLeft")]) { + for (ser, (name, values)) in + chart.series.iter_mut().zip([("Sheet1!$B$1", "TaskStart"), ("Sheet1!DoneTitle", "TaskDone"), ("LeftTitle", "TaskLeft")]) + { ser.name = Some(name.into()); ser.categories = Some("TaskNames".into()); ser.values = values.into(); @@ -76,11 +79,14 @@ fn reopen(bytes: &[u8]) -> Workbook { #[test] fn workbook_names_in_chart_series_resolve_after_save_and_reopen() { let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap(); - // Written as Excel writes them. + // Qualified, as Excel needs them: it draws a chart with a bare name in `` blank. let xml = chart_xml(&bytes); - for f in ["[0]!TaskNames", "[0]!TaskStart", "[0]!TaskDone", "[0]!TaskLeft", "[0]!LeftTitle"] { + for f in ["[0]!TaskNames", "[0]!TaskStart", "[0]!TaskDone", "Sheet1!TaskLeft", "[0]!LeftTitle"] { assert!(xml.contains(&format!("{f}")), "{f} missing from {xml}"); } + assert!(!xml.contains("Task"), "a bare name in {xml}"); + // Series names that are defined names stay references, qualified or not. + assert!(xml.contains("Sheet1!DoneTitle"), "{xml}"); // A series name that is a defined name stays a reference, not literal text. assert!(!xml.contains("LeftTitle")); @@ -88,6 +94,7 @@ fn workbook_names_in_chart_series_resolve_after_save_and_reopen() { let chart = &wb.sheet(0).unwrap().charts[0]; assert_eq!(chart.series[0].categories.as_deref(), Some("TaskNames")); assert_eq!(chart.series[0].values, "TaskStart"); + assert_eq!(chart.series[2].values, "Sheet1!TaskLeft"); assert_eq!(chart.series[2].name.as_deref(), Some("LeftTitle")); let data = gridcraft_chart::resolve(&wb, 0, chart); @@ -108,7 +115,7 @@ fn workbook_names_in_chart_series_resolve_after_save_and_reopen() { #[test] fn a_missing_workbook_name_resolves_to_no_values_without_panicking() { let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap(); - let bytes = edit_chart(&bytes, |x| x.replace("[0]!TaskLeft", "[0]!NoSuchName")); + let bytes = edit_chart(&bytes, |x| x.replace("Sheet1!TaskLeft", "[0]!NoSuchName")); let wb = reopen(&bytes); let chart = &wb.sheet(0).unwrap().charts[0]; assert_eq!(chart.series[2].values, "NoSuchName"); diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index f14cec83d..68af6cfc2 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -5,7 +5,7 @@ use std::fmt::Write as _; use gridcraft_core::Value; use gridcraft_formula::{Expr, SheetSel}; -use gridcraft_model::{Chart, ChartKind, Color, LegendPos, LineDash, Series, Trendline, TrendlineKind, Workbook}; +use gridcraft_model::{Chart, ChartKind, Color, DefinedName, LegendPos, LineDash, Series, Trendline, TrendlineKind, Workbook}; use crate::drawing::{dml_color, srgb}; use crate::xml::{El, esc, num}; @@ -338,21 +338,41 @@ fn is_ref(f: &str) -> bool { matches!(gridcraft_formula::parse(f), Ok(Expr::Ref(_))) } -/// The name `f` is when it is a bare workbook-scoped defined name seen from sheet `si` (a -/// sheet-scoped name of the same spelling on `si` wins, as in formulas). -fn book_name(wb: &Workbook, si: usize, f: &str) -> Option { +/// A chart reference that is a name: its sheet when qualified (`Sheet1!Sales`), the bare +/// name, and the definition formulas would find from sheet `si` (none when undefined). `None` +/// 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 }; - wb.name(&n, si).is_some_and(|d| d.scope.is_none()).then_some(n) + match n.split_once('!') { + Some((s, bare)) => { + let i = wb.sheet_index(s.trim_matches('\''))?; + Some((Some(i), bare.to_string(), wb.name(bare, i))) + } + None => { + let def = wb.name(&n, si); + Some((None, n, def)) + } + } } -/// A chart reference for ``: Excel writes a workbook-scoped name as `[0]!Name`. +/// A chart reference for ``. Excel needs a name qualified there: with a bare `Name` it +/// draws the whole chart blank, with no repair prompt (observed in Excel for Windows, +/// Microsoft 365). A workbook-scoped name is written `[0]!Name`, as Excel writes it, a +/// sheet-scoped one `Sheet!Name`; an undefined name gets `[0]!`, as the file it came from had. fn chart_ref_to_file(wb: &Workbook, si: usize, f: &str) -> String { - match book_name(wb, si, f) { - Some(n) => format!("{THIS_BOOK}{n}"), + let on_sheet = |i: usize, n: &str| wb.sheet(i).map(|sh| format!("{}!{n}", gridcraft_formula::quote_sheet(&sh.name))); + match chart_name(wb, si, f) { + Some((Some(i), n, _)) => on_sheet(i, &n).unwrap_or_else(|| crate::fmla::text_to_file(f)), + Some((None, n, def)) => def.and_then(|d| d.scope).and_then(|i| on_sheet(i, &n)).unwrap_or_else(|| format!("{THIS_BOOK}{n}")), None => crate::fmla::text_to_file(f), } } +/// A series name that refers to a defined name (as opposed to literal text that parses as one). +fn is_defined_name(wb: &Workbook, si: usize, f: &str) -> bool { + chart_name(wb, si, f).is_some_and(|(_, _, d)| d.is_some()) +} + fn num_data(tag: &str, f: Option<&str>, wb: &Workbook, si: usize) -> String { let f = f.unwrap_or("").trim(); if f.is_empty() { @@ -447,7 +467,7 @@ fn str_data(tag: &str, f: &str, wb: &Workbook, si: usize) -> String { fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String { let mut x = format!(""); if let Some(n) = &s.name { - if is_ref(n) || book_name(wb, si, n).is_some() { + if is_ref(n) || is_defined_name(wb, si, n) { x.push_str(&str_data("tx", n, wb, si)); } else { let lit = n.trim().strip_prefix('"').and_then(|t| t.strip_suffix('"')).unwrap_or(n); diff --git a/crates/xlsx/src/tests/chart_book_names.rs b/crates/xlsx/src/tests/chart_book_names.rs index d491826f3..3182cbae1 100644 --- a/crates/xlsx/src/tests/chart_book_names.rs +++ b/crates/xlsx/src/tests/chart_book_names.rs @@ -53,8 +53,10 @@ fn name(n: &str, scope: Option, formula: &str) -> DefinedName { DefinedName { name: n.into(), scope, formula: formula.into(), comment: String::new(), hidden: false } } +/// Excel draws a chart blank when a `` holds a bare name (observed in Excel for Windows, +/// Microsoft 365, with no repair prompt); `[0]!Name` and `Sheet!Name` both plot. #[test] -fn workbook_names_are_written_with_the_prefix_and_sheet_names_without() { +fn chart_names_are_written_qualified() { let mut wb = Workbook::default(); let mut s = Sheet::new("Data"); s.charts.push(Chart { @@ -62,7 +64,14 @@ fn workbook_names_are_written_with_the_prefix_and_sheet_names_without() { 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("Title", "Amounts"), series("Data!$B$1", "Local"), series("\"Plain\"", "Data!$B$2:$B$4")], + series: vec![ + series("Title", "Amounts"), + series("Data!$B$1", "Local"), + series("\"Plain\"", "Data!$B$2:$B$4"), + series("'My Data'!Other", "'My Data'!Other"), + series("Literal", "NoSuchName"), + series("Gone!Thing", "Gone!Thing"), + ], legend: LegendPos::Bottom, data_labels: false, gridlines: true, @@ -73,28 +82,58 @@ fn workbook_names_are_written_with_the_prefix_and_sheet_names_without() { by_rows: false, pivot: None, }); - wb.sheets = vec![Arc::new(s)]; + wb.sheets = vec![Arc::new(s), Arc::new(Sheet::new("My Data"))]; wb.names = vec![ name("Items", None, "Data!$A$2:$A$4"), name("Amounts", None, "OFFSET(Items,0,1)"), name("Title", None, "Data!$B$1"), name("Local", Some(0), "Data!$C$2:$C$4"), + name("Other", Some(1), "'My Data'!$A$1:$A$3"), ]; 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("[0]!Items"), "{xml}"); - assert!(xml.contains("[0]!Amounts"), "{xml}"); - assert!(xml.contains("[0]!Title"), "{xml}"); - // A sheet-scoped name is not this workbook's name: no prefix. - assert!(xml.contains("Local"), "{xml}"); - assert!(xml.contains("Plain"), "{xml}"); + for f in [ + // Workbook-scoped: `[0]!`, as Excel writes it. + "[0]!Items", + "[0]!Amounts", + "[0]!Title", + // Sheet-scoped: qualified with its sheet. + "Data!Local", + "'My Data'!Other", + "'My Data'!Other", + // Undefined: `[0]!`, as a file that referred to it had. + "[0]!NoSuchName", + // Literal series names stay literal. + "Plain", + "Literal", + // An unknown sheet is kept as written, never re-qualified. + "Gone!Thing", + ] { + assert!(xml.contains(f), "{f} missing from {xml}"); + } + for bare in ["Local", "Items", "Amounts", "Other", "NoSuchName"] { + assert!(!xml.contains(bare), "bare {bare} in {xml}"); + } let (back, _) = read_xlsx(&bytes).unwrap(); let got: Vec<(Option<&str>, Option<&str>, &str)> = back.sheets[0].charts[0].series.iter().map(|s| (s.name.as_deref(), s.categories.as_deref(), s.values.as_str())).collect(); assert_eq!( got, - [(Some("Title"), Some("Items"), "Amounts"), (Some("Data!$B$1"), Some("Items"), "Local"), (Some("Plain"), Some("Items"), "Data!$B$2:$B$4"),] + [ + (Some("Title"), Some("Items"), "Amounts"), + (Some("Data!$B$1"), Some("Items"), "Data!Local"), + (Some("Plain"), Some("Items"), "Data!$B$2:$B$4"), + (Some("'My Data'!Other"), Some("Items"), "'My Data'!Other"), + (Some("Literal"), Some("Items"), "NoSuchName"), + (Some("Gone!Thing"), Some("Items"), "Gone!Thing"), + ] ); + // A second save writes the same references. + let mut again = String::new(); + let bytes2 = write_xlsx(&back).unwrap(); + zip::ZipArchive::new(std::io::Cursor::new(&bytes2)).unwrap().by_name("xl/charts/chart1.xml").unwrap().read_to_string(&mut again).unwrap(); + 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)); } diff --git a/docs/chart-parity.md b/docs/chart-parity.md index 1e8e3e2ac..6fcc2880c 100644 --- a/docs/chart-parity.md +++ b/docs/chart-parity.md @@ -68,8 +68,10 @@ back. Series data, categories and names may refer to defined names, as dynamic (OFFSET) ranges do: Excel writes a workbook-scoped name as `[0]!Name` in ``. GridCraft reads it as the bare -name and writes it back with `[0]!`; a sheet-scoped name is written bare and external books -(`[1]Sheet1!…`) are kept as they are (#1686). Such series get no `numCache`/`strCache` on save. +name and writes it back with `[0]!`; a sheet-scoped name is written `Sheet!Name`. Excel needs the +qualifier: with a bare name in `` it draws the whole chart blank, without a repair prompt +(observed in Excel for Windows, Microsoft 365). External books (`[1]Sheet1!…`) are kept as they +are (#1686). Such series get no `numCache`/`strCache` on save (#1690). ## Work (40–60 h) @@ -85,7 +87,7 @@ name and writes it back with `[0]!`; a sheet-scoped name is written bare and ext | Date | Change | Summary | |---|---|---| -| 2026-10-16 | trivial | Series that refer to workbook-scoped defined names (`[0]!Name`) resolve instead of showing `#NAME?`, and keep the prefix on save (#1686) | +| 2026-10-16 | trivial | Series that refer to defined names (`[0]!Name`, `Sheet!Name`) resolve instead of showing `#NAME?`, and are saved qualified, as Excel needs (#1686) | | 2026-10-12 | trivial | chart sheets are kept on save, still not editable (#727) | | 2026-10-11 | minor | Trendlines: all six types modelled, round-tripped through `c:trendline`, drawn with forecast and equation/R² labels; `chart.addElement` `trendline` | | 2026-10-11 | minor | PivotCharts (`insert.pivotChart`) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8b2379f20..0da65233b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -85,7 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| -| 2026-10-16 | trivial | XLSX charts: `[0]!Name` (a workbook-scoped defined name) in series references is read as the name, so the series resolve instead of showing `#NAME?`, and is written back for workbook-scoped names (#1686) | +| 2026-10-16 | trivial | XLSX charts: `[0]!Name` (a workbook-scoped defined name) in series references is read as the name, so the series resolve instead of showing `#NAME?`. Names in chart references are written qualified (`[0]!Name`, or `Sheet!Name` for a sheet-scoped name): Excel draws a chart with a bare name blank (#1686) | | 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped | | 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) | | 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) | From 2321afd677eb59a70275a2db033cdaf1039e6537 Mon Sep 17 00:00:00 2001 From: parsherr Date: Sat, 17 Oct 2026 12:13:47 +0300 Subject: [PATCH 22/32] i18n: translate sheet tab menu and AutoSave toggle (fixes #1537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sheet tab context menu buttons (Insert Sheet, Rename, Move or Copy…, Duplicate, Tab Color, Hide, Unhide…, Protect/Unprotect Sheet, Select All Sheets), the navigation tooltips (Previous/Next/All sheets, New sheet), and the AutoSave toggle label and tooltip in the ribbon were all hardcoded English — they were never routed through lang.tr(). Fix: - tabs.rs: wrap every hardcoded UI string with app.ui.language.tr(…) - ribbon.rs: wrap the AutoSave label and hover text with lang.tr(…) - i18n/cs,de,es,fr,ko,pt,ru,sr,zh: add the 130 missing translation entries (each language only gets the entries it was missing) Translations produced from first-principles per language, following the terminology used for other sheet/workbook strings already in each file. Closes #1537 --- crates/ui-egui/src/i18n/cs.rs | 11 +++++++++++ crates/ui-egui/src/i18n/de.rs | 15 +++++++++++++++ crates/ui-egui/src/i18n/es.rs | 14 ++++++++++++++ crates/ui-egui/src/i18n/fr.rs | 16 ++++++++++++++++ crates/ui-egui/src/i18n/ko.rs | 16 ++++++++++++++++ crates/ui-egui/src/i18n/pt.rs | 10 ++++++++++ crates/ui-egui/src/i18n/ru.rs | 16 ++++++++++++++++ crates/ui-egui/src/i18n/sr.rs | 16 ++++++++++++++++ crates/ui-egui/src/i18n/zh.rs | 16 ++++++++++++++++ crates/ui-egui/src/ribbon.rs | 4 ++-- crates/ui-egui/src/tabs.rs | 30 +++++++++++++++--------------- 11 files changed, 147 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 7ed8372c7..75b0758c1 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -535,6 +535,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Zoom Out" => "Oddálit", // widgets.rs (alphabetical keys). "Dismiss" => "Zavřít", + "Rename" => "Přejmenovat", + "Move or Copy…" => "Přesunout nebo kopírovat…", + "Duplicate" => "Duplikovat", + "Tab Color" => "Barva záložky", + "No Color" => "Bez barvy", + "Unhide…" => "Zobrazit…", + "Unprotect Sheet…" => "Zrušit ochranu listu…", + "Select All Sheets" => "Vybrat všechny listy", + "AutoSave" => "Automatické ukládání", + "AutoSave: save after every change (workbooks saved to a file)" => "Automatické ukládání: ukládat po každé změně (sešity uložené do souboru)", + "All sheets" => "Všechny listy", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index c20c7d1b3..3d064ca2e 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -411,6 +411,21 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Dunkel", "Sheet Options|View" => "Anzeige", "Print" => "Drucken", + "Insert Sheet" => "Blatt einfügen", + "Rename" => "Umbenennen", + "Move or Copy…" => "Verschieben oder kopieren…", + "Duplicate" => "Duplizieren", + "Tab Color" => "Registerfarbe", + "No Color" => "Keine Farbe", + "Unhide…" => "Einblenden…", + "Unprotect Sheet…" => "Blattschutz aufheben…", + "Select All Sheets" => "Alle Blätter auswählen", + "AutoSave" => "AutoSpeichern", + "AutoSave: save after every change (workbooks saved to a file)" => "AutoSpeichern: nach jeder Änderung speichern (Arbeitsmappen, die in einer Datei gespeichert sind)", + "Previous sheet" => "Vorheriges Blatt", + "Next sheet" => "Nächstes Blatt", + "All sheets" => "Alle Blätter", + "New sheet (⇧F11)" => "Neues Blatt (⇧F11)", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 4ce231bca..35136687f 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -440,6 +440,20 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Compact" => "Compacto", "Outline" => "Esquema", "Tabular" => "Tabular", + "Insert Sheet" => "Insertar hoja", + "Rename" => "Cambiar nombre", + "Move or Copy…" => "Mover o copiar…", + "Duplicate" => "Duplicar", + "Tab Color" => "Color de etiqueta", + "No Color" => "Sin color", + "Unhide…" => "Mostrar…", + "Select All Sheets" => "Seleccionar todas las hojas", + "AutoSave" => "Autoguardar", + "AutoSave: save after every change (workbooks saved to a file)" => "Autoguardar: guardar después de cada cambio (libros guardados en un archivo)", + "Previous sheet" => "Hoja anterior", + "Next sheet" => "Hoja siguiente", + "All sheets" => "Todas las hojas", + "New sheet (⇧F11)" => "Nueva hoja (⇧F11)", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 5d20ab0ca..e35b98758 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -410,6 +410,22 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "Calcul du classeur", "Enable iterative calculation" => "Activer le calcul itératif", "Calculation settings are saved with the workbook." => "Les paramètres de calcul sont enregistrés avec le classeur.", + "Insert Sheet" => "Insérer une feuille", + "Rename" => "Renommer", + "Move or Copy…" => "Déplacer ou copier…", + "Duplicate" => "Dupliquer", + "Tab Color" => "Couleur de l'onglet", + "No Color" => "Sans couleur", + "Hide" => "Masquer", + "Unhide…" => "Afficher…", + "Unprotect Sheet…" => "Ôter la protection de la feuille…", + "Select All Sheets" => "Sélectionner toutes les feuilles", + "AutoSave" => "Enregistrement automatique", + "AutoSave: save after every change (workbooks saved to a file)" => "Enregistrement automatique : enregistrer après chaque modification (classeurs enregistrés dans un fichier)", + "Previous sheet" => "Feuille précédente", + "Next sheet" => "Feuille suivante", + "All sheets" => "Toutes les feuilles", + "New sheet (⇧F11)" => "Nouvelle feuille (⇧F11)", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 1902ad301..8867ced39 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -410,6 +410,22 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "통합 문서 계산", "Enable iterative calculation" => "반복 계산 사용", "Calculation settings are saved with the workbook." => "계산 설정은 통합 문서와 함께 저장됩니다.", + "Insert Sheet" => "시트 삽입", + "Rename" => "이름 바꾸기", + "Move or Copy…" => "이동 또는 복사…", + "Duplicate" => "복제", + "Tab Color" => "탭 색상", + "No Color" => "색 없음", + "Hide" => "숨기기", + "Unhide…" => "숨기기 취소…", + "Unprotect Sheet…" => "시트 보호 해제…", + "Select All Sheets" => "모든 시트 선택", + "AutoSave" => "자동 저장", + "AutoSave: save after every change (workbooks saved to a file)" => "자동 저장: 변경할 때마다 저장 (파일로 저장된 통합 문서)", + "Previous sheet" => "이전 시트", + "Next sheet" => "다음 시트", + "All sheets" => "모든 시트", + "New sheet (⇧F11)" => "새 시트 (⇧F11)", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index fb9236cdb..c2bbfc481 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -956,6 +956,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Protect\nSheet" => "Proteger\nPlanilha", "Watch\nWindow" => "Janela de\nInspeção", "Workbook\nStatistics" => "Estatísticas da\nPasta de Trabalho", + "Rename" => "Renomear", + "Move or Copy…" => "Mover ou copiar…", + "Duplicate" => "Duplicar", + "Tab Color" => "Cor da guia", + "No Color" => "Sem cor", + "Unhide…" => "Reexibir…", + "Unprotect Sheet…" => "Desproteger planilha…", + "Select All Sheets" => "Selecionar todas as planilhas", + "AutoSave: save after every change (workbooks saved to a file)" => "Salvamento automático: salvar após cada alteração (pastas de trabalho salvas em um arquivo)", + "All sheets" => "Todas as planilhas", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 765f6bb32..adb34e35c 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -410,6 +410,22 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "Вычисления в книге", "Enable iterative calculation" => "Включить итеративные вычисления", "Calculation settings are saved with the workbook." => "Параметры вычислений сохраняются вместе с книгой.", + "Insert Sheet" => "Вставить лист", + "Rename" => "Переименовать", + "Move or Copy…" => "Переместить или скопировать…", + "Duplicate" => "Дублировать", + "Tab Color" => "Цвет вкладки", + "No Color" => "Без цвета", + "Hide" => "Скрыть", + "Unhide…" => "Показать…", + "Unprotect Sheet…" => "Снять защиту листа…", + "Select All Sheets" => "Выделить все листы", + "AutoSave" => "Автосохранение", + "AutoSave: save after every change (workbooks saved to a file)" => "Автосохранение: сохранять после каждого изменения (книги, сохранённые в файл)", + "Previous sheet" => "Предыдущий лист", + "Next sheet" => "Следующий лист", + "All sheets" => "Все листы", + "New sheet (⇧F11)" => "Новый лист (⇧F11)", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index cbf18d4c4..e61938385 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -408,6 +408,22 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Bottom Margin" => "Donja margina", "Header Margin" => "Margina zaglavlja", "Footer Margin" => "Margina podnožja", + "Insert Sheet" => "Убаци лист", + "Rename" => "Преименуј", + "Move or Copy…" => "Премести или копирај…", + "Duplicate" => "Дупликат", + "Tab Color" => "Боја картице", + "No Color" => "Без боје", + "Hide" => "Сакриј", + "Unhide…" => "Прикажи…", + "Unprotect Sheet…" => "Уклони заштиту листа…", + "Select All Sheets" => "Изабери све листове", + "AutoSave" => "Аутоматско чување", + "AutoSave: save after every change (workbooks saved to a file)" => "Аутоматско чување: сачувај после сваке измене (свеске сачуване у датотеку)", + "Previous sheet" => "Претходни лист", + "Next sheet" => "Следећи лист", + "All sheets" => "Сви листови", + "New sheet (⇧F11)" => "Нови лист (⇧F11)", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 00175925e..b086f8c92 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -402,6 +402,22 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "工作簿计算", "Enable iterative calculation" => "启用迭代计算", "Calculation settings are saved with the workbook." => "计算设置随工作簿一起保存。", + "Insert Sheet" => "插入工作表", + "Rename" => "重命名", + "Move or Copy…" => "移动或复制…", + "Duplicate" => "复制", + "Tab Color" => "标签颜色", + "No Color" => "无颜色", + "Hide" => "隐藏", + "Unhide…" => "取消隐藏…", + "Unprotect Sheet…" => "取消工作表保护…", + "Select All Sheets" => "选择所有工作表", + "AutoSave" => "自动保存", + "AutoSave: save after every change (workbooks saved to a file)" => "自动保存:每次更改后保存(已保存到文件的工作簿)", + "Previous sheet" => "上一个工作表", + "Next sheet" => "下一个工作表", + "All sheets" => "所有工作表", + "New sheet (⇧F11)" => "新建工作表 (⇧F11)", _ => return None, }) } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 9c6d89eeb..2b0b3bc37 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -52,14 +52,14 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { ui.horizontal_centered(|ui| { ui.add_space(TITLE_LEFT_PAD); // AutoSave toggle (saves after each change when the workbook has a file). - ui.label(egui::RichText::new("AutoSave").font(theme::ui_font(12.5)).color(t.text_dim)); + ui.label(egui::RichText::new(app.ui.language.tr("AutoSave")).font(theme::ui_font(12.5)).color(t.text_dim)); let on = app.grid.autosave(); let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click()); ui.painter().rect_filled(r, 8.0, if on { t.accent } else { Color32::TRANSPARENT }); ui.painter().rect_stroke(r, 8.0, Stroke::new(1.0, if on { t.accent } else { t.text_dim }), StrokeKind::Inside); let knob = if on { pos2(r.right() - 8.0, r.center().y) } else { pos2(r.left() + 8.0, r.center().y) }; ui.painter().circle_filled(knob, 5.0, if on { Color32::WHITE } else { t.text_dim }); - if resp.on_hover_text("AutoSave: save after every change (workbooks saved to a file)").clicked() { + if resp.on_hover_text(app.ui.language.tr("AutoSave: save after every change (workbooks saved to a file)")).clicked() { app.grid.toggle_autosave(); } ui.add_space(6.0); diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index b77524bb9..41569e994 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -43,14 +43,14 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { 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, "Previous sheet", vec2(24.0, 24.0)).clicked() { + 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, "Next sheet", vec2(24.0, 24.0)).clicked() { + 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, "All sheets", vec2(24.0, 24.0)); - all.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "All sheets")); + 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::WidgetType::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(); @@ -168,42 +168,42 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { } resp.context_menu(|ui| { ui.set_min_width(180.0); - if ui.button("Insert Sheet").clicked() { + if ui.button(app.ui.language.tr("Insert Sheet")).clicked() { app.run_or_alert("home.insertSheet", json!({"before": i})); } if ui.button("Delete").clicked() { app.run_or_alert("home.deleteSheet", json!({"sheet": i})); } - if ui.button("Rename").clicked() { + if ui.button(app.ui.language.tr("Rename")).clicked() { app.grid.renaming_tab = Some(i); app.grid.rename_text = name.clone(); } - if ui.button("Move or Copy…").clicked() { + if ui.button(app.ui.language.tr("Move or Copy…")).clicked() { app.open_dialog("moveSheet", json!({"sheet": i})); } - if ui.button("Duplicate").clicked() { + 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("Tab Color", |ui| { + 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(ui, &colors, "No Color") { + if let Some(c) = crate::widgets::color_palette(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("Hide").clicked() { + if ui.button(app.ui.language.tr("Hide")).clicked() { app.run_or_alert("sheet.hide", json!({"sheet": i})); } - if ui.button("Unhide…").clicked() { + if ui.button(app.ui.language.tr("Unhide…")).clicked() { app.run_or_alert("sheet.unhide", json!({})); } ui.separator(); let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false); - if ui.button(if protected { "Unprotect Sheet…" } else { "Protect Sheet…" }).clicked() { + if ui.button(if protected { app.ui.language.tr("Unprotect Sheet…") } else { app.ui.language.tr("Protect Sheet…") }).clicked() { app.open_dialog(if protected { "unprotectSheet" } else { "protectSheet" }, json!({})); } - if ui.button("Select All Sheets").clicked() { + if ui.button(app.ui.language.tr("Select All Sheets")).clicked() { ui.close(); } }); @@ -212,7 +212,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { }); }); ui.add_space(6.0); - if crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, "New sheet (⇧F11)", vec2(26.0, 26.0)).clicked() { + 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!({})); } // The track ends where the grid does, so it stops under the vertical scrollbar. From e60a74e9cfc277e33451b8547c2d92d5e9259d16 Mon Sep 17 00:00:00 2001 From: Cyan Date: Sun, 18 Oct 2026 17:05:55 +0800 Subject: [PATCH 23/32] Clicking a row/column while in a formula now highlights that row/column --- crates/ui-egui/src/editor.rs | 44 ++++++++++++++----- crates/ui-egui/tests/formula_point_headers.rs | 4 ++ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index 4aada2953..f79169fce 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -208,20 +208,29 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option, Tok::Sheet(a, _) => (Some(a.clone()), toks[i].start, i + 1), _ => (None, toks[i].start, i), }; - if let Some(Tok::Word(w)) = toks.get(j).map(|t| &t.tok) - && let Some(c) = CellRef::parse(w) - { - let mut end = toks[j].end; - let mut range = RangeRef::cell(c); - if let (Some(Tok::Colon | Tok::Trim(_)), Some(Tok::Word(w2))) = (toks.get(j + 1).map(|t| &t.tok), toks.get(j + 2).map(|t| &t.tok)) - && let Some(c2) = CellRef::parse(w2) - { - range = RangeRef::new(c, c2); - end = toks[j + 2].end; - i = j + 3; + if let Some(Tok::Word(w)) = toks.get(j).map(|t| &t.tok) { + let range_end = if matches!(toks.get(j + 1).map(|t| &t.tok), Some(Tok::Colon | Tok::Trim(_))) { + toks.get(j + 2).and_then(|token| { + let end = match &token.tok { + Tok::Word(w) => Some(w.clone()), + Tok::Number(n) if n.fract() == 0.0 && *n >= 1.0 => Some(format!("{n}")), + _ => None, + }?; + RangeRef::parse(&format!("{w}:{end}")).map(|range| (range, token.end)) + }) } else { + None + }; + let (range, end) = if let Some(range_end) = range_end { + i = j + 3; + range_end + } else if let Some(c) = CellRef::parse(w) { i = j + 1; - } + (RangeRef::cell(c), toks[j].end) + } else { + i += 1; + 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, @@ -329,4 +338,15 @@ mod tests { assert_eq!(r[1].2.a1(), "B2:C3"); assert_eq!(r[3].3.as_deref(), Some("Sheet2")); } + + #[test] + fn whole_row_and_column_ref_spans() { + let refs = formula_refs("=SUM(C:C,4:6,$D:$E,Sheet2!7:8)"); + assert_eq!(refs.len(), 4); + assert_eq!(refs[0].2.a1(), "C:C"); + assert_eq!(refs[1].2.a1(), "4:6"); + assert_eq!(refs[2].2.a1(), "D:E"); + assert_eq!(refs[3].2.a1(), "7:8"); + assert_eq!(refs[3].3.as_deref(), Some("Sheet2")); + } } diff --git a/crates/ui-egui/tests/formula_point_headers.rs b/crates/ui-egui/tests/formula_point_headers.rs index 4313fb9f6..846fcb350 100644 --- a/crates/ui-egui/tests/formula_point_headers.rs +++ b/crates/ui-egui/tests/formula_point_headers.rs @@ -52,6 +52,8 @@ fn column_header_click_inserts_a_column_reference() { let app = h.state(); assert_eq!(app.editor.as_ref().map(|e| e.text.as_str()), Some("=SUM(C:C")); assert_eq!(app.session.active().unwrap().selection.current().a1(), "F8"); + let text = app.editor.as_ref().map_or("", |ed| ed.text.as_str()); + assert_eq!(gridcraft_ui_egui::editor::formula_refs(text).first().map(|r| r.2.a1()).as_deref(), Some("C:C")); } #[test] @@ -62,6 +64,8 @@ fn row_header_click_inserts_a_row_reference() { let app = h.state(); assert_eq!(app.editor.as_ref().map(|e| e.text.as_str()), Some("=SUM(4:4")); assert_eq!(app.session.active().unwrap().selection.current().a1(), "F8"); + let text = app.editor.as_ref().map_or("", |ed| ed.text.as_str()); + assert_eq!(gridcraft_ui_egui::editor::formula_refs(text).first().map(|r| r.2.a1()).as_deref(), Some("4:4")); } #[test] From ef0b243cd3d750af3ca16a85643a5317ba6b7231 Mon Sep 17 00:00:00 2001 From: Cyan Date: Sun, 18 Oct 2026 17:11:37 +0800 Subject: [PATCH 24/32] Allow multi-column/row selection inside formulas with shift --- crates/ui-egui/src/editor.rs | 28 +++++++++ crates/ui-egui/src/grid.rs | 35 ++++++++--- crates/ui-egui/tests/formula_point_headers.rs | 62 ++++++++++++++++++- 3 files changed, 115 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index f79169fce..7f8b60d29 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -107,6 +107,23 @@ impl EditState { self.sync_caret = true; } + /// Adds a reference to the current point-mode union, keeping earlier references intact. + pub fn insert_ref_union(&mut self, r: &str, separator: char) { + let caret_b = self.byte_caret(); + if !self.point.is_some_and(|(_, end)| end == caret_b) { + self.insert_ref(r); + return; + } + let (Some(before), Some(after)) = (self.text.get(..caret_b), self.text.get(caret_b..)) else { return }; + let start = caret_b + separator.len_utf8(); + let end = start + r.len(); + self.text = format!("{before}{separator}{r}{after}"); + self.point = Some((start, end)); + self.caret = self.text.get(..end).map(|s| s.chars().count()).unwrap_or(0); + self.caret_to_end = false; + self.sync_caret = true; + } + /// Updates function autocomplete for the word before the caret. pub fn update_autocomplete(&mut self, names: &[String]) { self.autocomplete.clear(); @@ -349,4 +366,15 @@ mod tests { assert_eq!(refs[3].2.a1(), "7:8"); assert_eq!(refs[3].3.as_deref(), Some("Sheet2")); } + + #[test] + fn inserting_a_union_keeps_each_reference_separately_pointed() { + let mut edit = EditState::new(0, CellRef::new(0, 0), "=SUM(".into(), true, false); + edit.insert_ref("C:C"); + edit.insert_ref_union("E:E", ','); + assert_eq!(edit.text, "=SUM(C:C,E:E"); + assert_eq!(formula_refs(&edit.text).iter().map(|r| r.2.a1()).collect::>(), ["C:C", "E:E"]); + assert_eq!(edit.point, Some((9, 12))); + assert!(edit.can_point()); + } } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 90a39cb06..425ec6e93 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2020,7 +2020,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } let c = geo.col_at(sh, p.x); - if point_header(app, wb, sh, RangeRef::cols(c, c)) { + if point_header(app, wb, sh, RangeRef::cols(c, c), mods.shift, mods.ctrl) { return; } if resp.drag_started() @@ -2043,7 +2043,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } let r = geo.row_at(sh, p.y); - if point_header(app, wb, sh, RangeRef::rows(r, r)) { + if point_header(app, wb, sh, RangeRef::rows(r, r), mods.shift, mods.ctrl) { return; } let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) }; @@ -2334,12 +2334,33 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & keyboard(app, &ctx, resp, geo, sh); } -/// Point mode on a header: inserts the whole-column or whole-row reference instead of selecting. -fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef) -> bool { +/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting. +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 r = range.a1(); - let r = if ed.sheet != wb.active_sheet { format!("{}!{r}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { r }; - ed.insert_ref(&r); + let range = if extend { + ed.point + .and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point)) + .and_then(|(_, _, previous, _, _)| { + if range.is_full_cols() && previous.is_full_cols() { + Some(RangeRef::cols(previous.start.col.min(range.start.col), previous.end.col.max(range.end.col))) + } else if range.is_full_rows() && previous.is_full_rows() { + Some(RangeRef::rows(previous.start.row.min(range.start.row), previous.end.row.max(range.end.row))) + } else { + None + } + }) + .unwrap_or(range) + } 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 }; + if add { + ed.insert_ref_union(&reference, ed.locale.list_separator()); + } else { + ed.insert_ref(&reference); + } + ed.point_cell = Some(range.start); ed.request_focus = true; true } diff --git a/crates/ui-egui/tests/formula_point_headers.rs b/crates/ui-egui/tests/formula_point_headers.rs index 846fcb350..dd13bd9b6 100644 --- a/crates/ui-egui/tests/formula_point_headers.rs +++ b/crates/ui-egui/tests/formula_point_headers.rs @@ -35,13 +35,17 @@ fn header_center(h: &egui_kittest::Harness<'_, SheetApp>, rows: bool, index: u32 } fn click(h: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2) { + click_with_modifiers(h, pos, Modifiers::NONE); +} + +fn click_with_modifiers(h: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2, modifiers: Modifiers) { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); for pressed in [true, false] { - h.input_mut() - .events - .extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + h.input_mut().events.extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }]); h.step(); } h.run_steps(2); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); } #[test] @@ -77,3 +81,55 @@ fn a_second_header_click_replaces_the_pointed_reference() { click(&mut h, second); assert_eq!(h.state().editor.as_ref().map(|e| e.text.as_str()), Some("=SUM(E:E")); } + +#[test] +fn shift_selects_a_contiguous_column_range_while_editing_a_formula() { + let mut h = harness(); + let first = header_center(&h, false, 2); + click(&mut h, first); + let second = header_center(&h, false, 4); + click_with_modifiers(&mut h, second, Modifiers::SHIFT); + let app = h.state(); + let text = app.editor.as_ref().map_or("", |ed| ed.text.as_str()); + assert_eq!(text, "=SUM(C:E"); + assert_eq!(gridcraft_ui_egui::editor::formula_refs(text).iter().map(|r| r.2.a1()).collect::>(), ["C:E"]); +} + +#[test] +fn ctrl_selects_discontiguous_columns_while_editing_a_formula() { + let mut h = harness(); + let first = header_center(&h, false, 2); + click(&mut h, first); + let second = header_center(&h, false, 4); + click_with_modifiers(&mut h, second, Modifiers::CTRL); + let app = h.state(); + let text = app.editor.as_ref().map_or("", |ed| ed.text.as_str()); + assert_eq!(text, "=SUM(C:C,E:E"); + assert_eq!(gridcraft_ui_egui::editor::formula_refs(text).iter().map(|r| r.2.a1()).collect::>(), ["C:C", "E:E"]); +} + +#[test] +fn shift_selects_a_contiguous_row_range_while_editing_a_formula() { + let mut h = harness(); + let first = header_center(&h, true, 3); + click(&mut h, first); + let second = header_center(&h, true, 5); + click_with_modifiers(&mut h, second, Modifiers::SHIFT); + let app = h.state(); + let text = app.editor.as_ref().map_or("", |ed| ed.text.as_str()); + assert_eq!(text, "=SUM(4:6"); + assert_eq!(gridcraft_ui_egui::editor::formula_refs(text).iter().map(|r| r.2.a1()).collect::>(), ["4:6"]); +} + +#[test] +fn ctrl_selects_discontiguous_rows_while_editing_a_formula() { + let mut h = harness(); + let first = header_center(&h, true, 3); + click(&mut h, first); + let second = header_center(&h, true, 5); + click_with_modifiers(&mut h, second, Modifiers::CTRL); + let app = h.state(); + let text = app.editor.as_ref().map_or("", |ed| ed.text.as_str()); + assert_eq!(text, "=SUM(4:4,6:6"); + assert_eq!(gridcraft_ui_egui::editor::formula_refs(text).iter().map(|r| r.2.a1()).collect::>(), ["4:4", "6:6"]); +} From ede2b435d53894880c253d1f920bb4249e267af3 Mon Sep 17 00:00:00 2001 From: Cyan Date: Sun, 18 Oct 2026 17:23:57 +0800 Subject: [PATCH 25/32] Fxied CodeRabbit flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed: On macOS, Cmd-click sets mods.command, not mods.ctrl. Both calls therefore pass false for add, so point_header replaces the reference instead of adding a column or row union. Pass mods.command, as the adjacent selection path does. egui defines command as Cmd on macOS and Ctrl elsewhere. (docs.rs) & Fixed: Point mode remains active when the user changes sheets. The extension closure combines whole-column or whole-row references by coordinates only and discards the previous reference’s sheet. A prior Sheet2!C:C reference can therefore be combined with column E on Sheet3, then emitted as Sheet3!C:E. Compare the previous reference’s effective sheet with the active sheet before extending it. If they differ, use only the clicked range. --- crates/ui-egui/src/grid.rs | 12 +++-- crates/ui-egui/tests/formula_point_headers.rs | 48 +++++++++++++++++-- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 425ec6e93..54bd63050 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2020,7 +2020,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } let c = geo.col_at(sh, p.x); - if point_header(app, wb, sh, RangeRef::cols(c, c), mods.shift, mods.ctrl) { + if point_header(app, wb, sh, RangeRef::cols(c, c), mods.shift, mods.command) { return; } if resp.drag_started() @@ -2043,7 +2043,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } let r = geo.row_at(sh, p.y); - if point_header(app, wb, sh, RangeRef::rows(r, r), mods.shift, mods.ctrl) { + if point_header(app, wb, sh, RangeRef::rows(r, r), mods.shift, mods.command) { return; } let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) }; @@ -2337,10 +2337,16 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & /// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting. 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 range = if extend { ed.point .and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point)) - .and_then(|(_, _, previous, _, _)| { + .and_then(|(_, _, previous, previous_sheet, _)| { + let previous_sheet = previous_sheet.as_deref().map(|name| wb.sheet_index(name)).unwrap_or(Some(edit_sheet)); + if previous_sheet != Some(active_sheet) { + return None; + } if range.is_full_cols() && previous.is_full_cols() { Some(RangeRef::cols(previous.start.col.min(range.start.col), previous.end.col.max(range.end.col))) } else if range.is_full_rows() && previous.is_full_rows() { diff --git a/crates/ui-egui/tests/formula_point_headers.rs b/crates/ui-egui/tests/formula_point_headers.rs index dd13bd9b6..97bd790d2 100644 --- a/crates/ui-egui/tests/formula_point_headers.rs +++ b/crates/ui-egui/tests/formula_point_headers.rs @@ -96,12 +96,12 @@ fn shift_selects_a_contiguous_column_range_while_editing_a_formula() { } #[test] -fn ctrl_selects_discontiguous_columns_while_editing_a_formula() { +fn command_selects_discontiguous_columns_while_editing_a_formula() { let mut h = harness(); let first = header_center(&h, false, 2); click(&mut h, first); let second = header_center(&h, false, 4); - click_with_modifiers(&mut h, second, Modifiers::CTRL); + click_with_modifiers(&mut h, second, Modifiers::COMMAND); let app = h.state(); let text = app.editor.as_ref().map_or("", |ed| ed.text.as_str()); assert_eq!(text, "=SUM(C:C,E:E"); @@ -122,14 +122,54 @@ fn shift_selects_a_contiguous_row_range_while_editing_a_formula() { } #[test] -fn ctrl_selects_discontiguous_rows_while_editing_a_formula() { +fn command_selects_discontiguous_rows_while_editing_a_formula() { let mut h = harness(); let first = header_center(&h, true, 3); click(&mut h, first); let second = header_center(&h, true, 5); - click_with_modifiers(&mut h, second, Modifiers::CTRL); + click_with_modifiers(&mut h, second, Modifiers::COMMAND); let app = h.state(); let text = app.editor.as_ref().map_or("", |ed| ed.text.as_str()); assert_eq!(text, "=SUM(4:4,6:6"); assert_eq!(gridcraft_ui_egui::editor::formula_refs(text).iter().map(|r| r.2.a1()).collect::>(), ["4:4", "6:6"]); } + +#[test] +fn shift_does_not_extend_a_column_reference_across_sheets() { + let mut h = harness(); + h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap(); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + h.run_steps(2); + let first = header_center(&h, false, 2); + click(&mut h, first); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Other sheet"})).unwrap(); + h.run_steps(2); + let second = header_center(&h, false, 4); + click_with_modifiers(&mut h, second, Modifiers::SHIFT); + let text = h.state().editor.as_ref().map_or("", |ed| ed.text.as_str()); + assert_eq!(text, "=SUM('Other sheet'!E:E"); + assert_eq!( + gridcraft_ui_egui::editor::formula_refs(text).iter().map(|r| (r.2.a1(), r.3.clone())).collect::>(), + [("E:E".into(), Some("Other sheet".into())),] + ); +} + +#[test] +fn shift_does_not_extend_a_row_reference_across_sheets() { + let mut h = harness(); + h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap(); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + h.run_steps(2); + let first = header_center(&h, true, 3); + click(&mut h, first); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Other sheet"})).unwrap(); + h.run_steps(2); + let second = header_center(&h, true, 5); + click_with_modifiers(&mut h, second, Modifiers::SHIFT); + let text = h.state().editor.as_ref().map_or("", |ed| ed.text.as_str()); + assert_eq!(text, "=SUM('Other sheet'!6:6"); + assert_eq!( + gridcraft_ui_egui::editor::formula_refs(text).iter().map(|r| (r.2.a1(), r.3.clone())).collect::>(), + [("6:6".into(), Some("Other sheet".into())),] + ); +} From 0b4cc5618f5c5a651cb9aa643896bdb1c3da40d0 Mon Sep 17 00:00:00 2001 From: Eduardo Garcia Date: Thu, 15 Oct 2026 11:20:26 -0300 Subject: [PATCH 26/32] fix #1458: one Excel sheet-name quoting rule for formulas, pivot sources and ADDRESS Sheet names starting with '.' or reading as R1C1 (R, C, RC, R1C1...) were saved unquoted, so Excel could not open the file; PivotTable sources and ADDRESS left cell-like names (A1) bare. The quoting rule, unquoting and sheet splitting now live once in gridcraft_core::addr and replace the copies in the formula printer, pivot, ADDRESS, chart, xlsx, Solver, Go To and the Name Box (which now reads '' as '). Co-Authored-By: Claude Sonnet 5.5 --- crates/calc/src/eval.rs | 2 +- crates/chart/src/resolve.rs | 2 +- crates/core/src/addr.rs | 97 +++++++++++++++++++++++ crates/core/src/lib.rs | 2 +- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/mod.rs | 8 +- crates/engine/src/cmd/pivot.rs | 2 +- crates/engine/src/pivot.rs | 25 +----- crates/engine/tests/sheet_name_quoting.rs | 51 ++++++++++++ crates/formula/src/printer.rs | 17 +--- crates/functions/src/lookup.rs | 19 +++-- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/tests/interaction.rs | 13 +++ crates/xlsx/src/array_formula.rs | 2 +- crates/xlsx/src/pivot.rs | 5 +- 15 files changed, 187 insertions(+), 62 deletions(-) create mode 100644 crates/engine/tests/sheet_name_quoting.rs diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 0f9cfb03d..dec60b426 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -262,7 +262,7 @@ impl<'h> Evaluator<'h> { let wb = self.host.workbook(); // Sheet-qualified `Sheet1!Name`. let (scope_sheet, bare) = match n.split_once('!') { - Some((s, b)) => match wb.sheet_index(s.trim_matches('\'')) { + Some((s, b)) => match wb.sheet_index(&gridcraft_core::unquote_sheet(s)) { Some(i) => (i, b), None => return err(CellError::Name), }, diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index c007e3e90..13b7aa5c1 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -41,7 +41,7 @@ fn parse_ref(wb: &Workbook, sheet: usize, formula: &str) -> Option<(usize, Range let (sh, addr) = match f.rfind('!') { Some(i) => { let name = f.get(..i)?.trim(); - let name = name.strip_prefix('\'').and_then(|n| n.strip_suffix('\'')).map(|n| n.replace("''", "'")).unwrap_or_else(|| name.to_string()); + let name = gridcraft_core::unquote_sheet(name); (wb.sheet_index(&name)?, f.get(i + 1..)?) } None => (sheet, f), diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs index 2c087f22e..1ef9bc6b8 100644 --- a/crates/core/src/addr.rs +++ b/crates/core/src/addr.rs @@ -237,6 +237,59 @@ impl fmt::Display for RangeRef { } } +/// Whether `name` reads as an R1C1 reference: `R`, `C`, `RC`, `R1`, `C1`, `R1C`, `RC1`, `R1C1` +/// in any case. +fn is_r1c1_token(name: &str) -> bool { + let mut chars = name.chars().peekable(); + let mut letters = 0; + for letter in ['r', 'c'] { + if chars.peek().is_some_and(|c| c.to_ascii_lowercase() == letter) { + chars.next(); + letters += 1; + while chars.peek().is_some_and(|c| c.is_ascii_digit()) { + chars.next(); + } + } + } + letters > 0 && chars.next().is_none() +} + +/// Whether Excel quotes a sheet name in a reference (`'My Sheet'!A1`): it starts with a digit or +/// `.`, holds anything but letters (any script), digits, `_` and `.`, reads as an A1 cell within +/// the sheet or as an R1C1 reference, or is a boolean. +pub fn sheet_needs_quotes(name: &str) -> bool { + let Some(first) = name.chars().next() else { + return true; + }; + if first.is_ascii_digit() || first == '.' || name.chars().any(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) { + return true; + } + CellRef::parse(name).is_some() || is_r1c1_token(name) || name.eq_ignore_ascii_case("TRUE") || name.eq_ignore_ascii_case("FALSE") +} + +/// A sheet name as it is written in a reference: quoted, with `'` doubled, when Excel quotes it. +pub fn quote_sheet(name: &str) -> String { + if sheet_needs_quotes(name) { format!("'{}'", name.replace('\'', "''")) } else { name.to_string() } +} + +/// The inverse of [`quote_sheet`]: takes the quotes off a sheet name and reads `''` as `'`. +/// Text that isn't quoted is returned as it is. +pub fn unquote_sheet(text: &str) -> String { + match text.trim().strip_prefix('\'').and_then(|t| t.strip_suffix('\'')) { + Some(inner) => inner.replace("''", "'"), + None => text.trim().to_string(), + } +} + +/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`). Splits at the last `!`, so a quoted name may +/// hold one (`'a!b'!A1`). +pub fn split_sheet(reference: &str) -> (Option, &str) { + match reference.rsplit_once('!') { + Some((sheet, body)) => (Some(unquote_sheet(sheet)), body), + None => (None, reference), + } +} + #[cfg(test)] mod tests { use super::*; @@ -253,6 +306,50 @@ mod tests { assert_eq!((r.start.row, r.end.row), (0, 2)); } + #[test] + fn sheet_quoting_follows_excel() { + for quoted in [ + "A1", + "XFD1", + "AB1048576", + "R1", + "C1", + "RC1", + "R", + "C", + "RC", + "R1C", + "R1C1", + "r2c3", + "CR1", + ".x", + "TRUE", + "false", + "1abc", + "My Sheet", + "Bob's", + "a-b", + "", + ] { + assert!(sheet_needs_quotes(quoted), "{quoted:?} should be quoted"); + } + for bare in ["Sheet1", "XFE1", "AB1048577", "Ärger", "日本", "Σ", "Sheet.1", "_x", "A1A", "Data", "RR", "CRX"] { + assert!(!sheet_needs_quotes(bare), "{bare:?} should stay bare"); + } + } + + #[test] + fn quote_unquote_and_split_sheet() { + assert_eq!(quote_sheet("Bob's"), "'Bob''s'"); + assert_eq!(quote_sheet("Data"), "Data"); + assert_eq!(unquote_sheet("'Bob''s'"), "Bob's"); + assert_eq!(unquote_sheet("Data"), "Data"); + assert_eq!(unquote_sheet("'"), "'"); + assert_eq!(split_sheet("'a!b'!A1"), (Some("a!b".to_string()), "A1")); + assert_eq!(split_sheet("'Bob''s'!$A$1:$B$2"), (Some("Bob's".to_string()), "$A$1:$B$2")); + assert_eq!(split_sheet("A1"), (None, "A1")); + } + #[test] fn letters_roundtrip() { for (c, s) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA"), (701, "ZZ"), (702, "AAA"), (16383, "XFD")] { diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index d5adbc07e..833dd06e2 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -8,6 +8,6 @@ pub mod date; pub mod parse; pub mod value; -pub use addr::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, col_to_letters, letters_to_col}; +pub use addr::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, col_to_letters, letters_to_col, quote_sheet, sheet_needs_quotes, split_sheet, unquote_sheet}; pub use date::DateSystem; pub use value::{Array, CellError, Value, compare, compare_numbers, compare_text, number_to_text, sort_compare}; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index fe7f1fe8b..353953852 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -525,7 +525,7 @@ pub(super) fn go_to(s: &mut Session, p: &Json) -> Result { } else if let Some((si, ti)) = d.wb.table(reference) { // As in Excel, a table's name stands for its data, without the header and totals rows. let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.data_range().unwrap_or(t.range).a1()).unwrap_or_default(); - format!("'{}'!{}", d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(), t) + format!("{}!{}", gridcraft_core::quote_sheet(&d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default()), t) } else { return Err(EngineError::Other("Reference isn't valid.".into())); } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 50865a2b7..e18bae20b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -269,13 +269,7 @@ pub(crate) fn target_sheet(s: &Session, p: &Json) -> Result { Ok(d.wb.active_sheet) } -/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`). -pub(crate) fn split_sheet(r: &str) -> (Option, &str) { - match r.rsplit_once('!') { - Some((sh, body)) => (Some(sh.trim_matches('\'').replace("''", "'")), body), - None => (None, r), - } -} +pub(crate) use gridcraft_core::split_sheet; /// A single-cell parameter. Absent or null is `Ok(None)` (callers default it); a value that is not /// a cell reference, or that names a sheet other than the active one, is an error rather than a diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 06050f7e8..6205a510a 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -1392,7 +1392,7 @@ fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> { let (sheet, addr) = match t.rsplit_once('!') { Some((sh, a)) => { let sh = sh.trim(); - let sh = sh.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sh.to_string()); + let sh = gridcraft_core::unquote_sheet(sh); (d.wb.sheet_index(&sh), a) } None => (Some(d.wb.active_sheet), t), diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index 6146bd4f9..eaa8ab4db 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -9,7 +9,8 @@ use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use gridcraft_core::date::datetime_from_serial; -use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value}; +pub use gridcraft_core::quote_sheet; +use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, split_sheet}; use gridcraft_model::*; use crate::pivot_calc::CalcSet; @@ -47,26 +48,6 @@ impl Source { } } -/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`). -fn split_sheet(r: &str) -> (Option, &str) { - match r.rsplit_once('!') { - Some((sh, body)) => (Some(sh.trim_matches('\'').replace("''", "'")), body), - None => (None, r), - } -} - -/// Quotes a sheet name for a reference when needed. -pub fn quote_sheet(name: &str) -> String { - if !name.is_empty() - && name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') - && !name.chars().next().is_some_and(|c| c.is_ascii_digit()) - { - name.to_string() - } else { - format!("'{}'", name.replace('\'', "''")) - } -} - /// Absolute reference text for a range on a sheet: `Sheet1!$A$1:$E$20`. pub fn range_text(sheet_name: &str, r: RangeRef) -> String { let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1); @@ -1511,7 +1492,7 @@ pub fn parse_chart_link(link: &str) -> Option<(String, String)> { }; let (sheet, name) = t.rsplit_once('!')?; let sheet = sheet.trim(); - let sheet = sheet.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sheet.to_string()); + let sheet = gridcraft_core::unquote_sheet(sheet); let name = name.trim(); if sheet.is_empty() || name.is_empty() { return None; diff --git a/crates/engine/tests/sheet_name_quoting.rs b/crates/engine/tests/sheet_name_quoting.rs new file mode 100644 index 000000000..c7ba317bb --- /dev/null +++ b/crates/engine/tests/sheet_name_quoting.rs @@ -0,0 +1,51 @@ +//! #1458: sheet names are quoted in references the way Excel quotes them, in formulas, saved +//! files, PivotTable sources and ADDRESS. + +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use serde_json::{Value as Json, json}; + +fn run(s: &mut Session, command: &str, params: Json) -> Json { + s.execute(command, params).unwrap() +} + +fn formula(s: &Session, cell: &str) -> String { + s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(cell).unwrap()).unwrap().formula.as_ref().unwrap().text.clone() +} + +#[test] +fn formulas_keep_the_quotes_excel_needs_through_a_save() { + let mut s = Session::new(); + s.new_workbook(); + for name in [".x", "R1C1", "A1", "Bob's"] { + run(&mut s, "home.insertSheet", json!({"name": name})); + } + run(&mut s, "sheet.activate", json!({"sheet": "Sheet1"})); + let input = "='.x'!A1+'R1C1'!A1+'A1'!B1+'Bob''s'!A1"; + run(&mut s, "cell.set", json!({"cell": "A1", "input": input})); + assert_eq!(formula(&s, "A1"), input.trim_start_matches('=')); + + let saved = run(&mut s, "file.saveBytes", json!({"format": "xlsx"})); + let mut reopened = Session::new(); + run(&mut reopened, "file.open", json!({"name": "q.xlsx", "base64": saved["base64"]})); + run(&mut reopened, "sheet.activate", json!({"sheet": "Sheet1"})); + assert_eq!(formula(&reopened, "A1"), input.trim_start_matches('=')); +} + +#[test] +fn address_and_pivot_source_quote_a_sheet_named_like_a_cell() { + let mut s = Session::new(); + s.new_workbook(); + run(&mut s, "home.insertSheet", json!({"name": "A1"})); + run(&mut s, "sheet.activate", json!({"sheet": "A1"})); + for (cell, input) in [("A1", "K"), ("B1", "V"), ("A2", "a"), ("B2", "1"), ("A3", "b"), ("B3", "2")] { + run(&mut s, "cell.set", json!({"cell": cell, "input": input})); + } + run(&mut s, "cell.set", json!({"cell": "D1", "input": "=ADDRESS(1,1,1,TRUE,\"A1\")"})); + let d1 = s.doc().unwrap().wb.active().unwrap().value(CellRef::parse("D1").unwrap()); + assert_eq!(d1, Value::from("'A1'!$A$1")); + + run(&mut s, "selection.set", json!({"range": "A2"})); + let pivot = run(&mut s, "insert.pivotTable", json!({"rows": ["K"], "values": ["V"]})); + assert_eq!(pivot["source"], json!("'A1'!$A$1:$B$3")); +} diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index 3975a7c61..055b0f145 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -6,22 +6,7 @@ use gridcraft_core::{CellRef, col_to_letters, number_to_text}; use crate::ast::*; -/// Whether a sheet name needs quotes in a reference. -pub fn needs_quotes(name: &str) -> bool { - if name.is_empty() { - return true; - } - let first = name.chars().next().unwrap_or('_'); - if first.is_ascii_digit() || name.chars().any(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) { - return true; - } - // Looks like a cell ref (`A1`, `R1C1`) or a boolean. - gridcraft_core::CellRef::parse(name).is_some() || name.eq_ignore_ascii_case("TRUE") || name.eq_ignore_ascii_case("FALSE") -} - -pub fn quote_sheet(name: &str) -> String { - if needs_quotes(name) { format!("'{}'", name.replace('\'', "''")) } else { name.to_string() } -} +pub use gridcraft_core::{quote_sheet, sheet_needs_quotes as needs_quotes}; fn sheet_prefix(s: &SheetSel) -> String { match s { diff --git a/crates/functions/src/lookup.rs b/crates/functions/src/lookup.rs index 56739c933..f9c0b6f44 100644 --- a/crates/functions/src/lookup.rs +++ b/crates/functions/src/lookup.rs @@ -413,15 +413,15 @@ fn transpose(args: &[Arg], _c: &mut dyn Ctx) -> R { } } -/// Sheet text for ADDRESS, quoted when it needs to be. A `[Book]Sheet` prefix is judged by its +/// Sheet text for ADDRESS, quoted when Excel quotes it. A `[Book]Sheet` prefix is judged by its /// parts (Microsoft's ADDRESS examples: `"[Book1]Sheet1"` stays bare, `"EXCEL SHEET"` becomes -/// `'EXCEL SHEET'`). +/// `'EXCEL SHEET'`). Unlike in a formula, `TRUE` and `FALSE` stay bare. fn quote_sheet(s: &str) -> String { - let word = |p: &str| p.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.'); - let bare_sheet = |p: &str| !p.is_empty() && word(p) && !p.chars().next().is_some_and(|c| c.is_ascii_digit()); + let bare = |p: &str| p.eq_ignore_ascii_case("TRUE") || p.eq_ignore_ascii_case("FALSE") || !gridcraft_core::sheet_needs_quotes(p); + let word = |p: &str| !p.is_empty() && p.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.'); let plain = match s.strip_prefix('[').and_then(|r| r.split_once(']')) { - Some((book, sheet)) => !book.is_empty() && word(book) && bare_sheet(sheet), - None => bare_sheet(s), + Some((book, sheet)) => word(book) && bare(sheet), + None => bare(s), }; if plain { s.to_string() } else { format!("'{}'", s.replace('\'', "''")) } } @@ -791,6 +791,13 @@ mod tests { for (r, c, abs, a1, sheet, want) in cases { is_text(ev("ADDRESS", vec![n(r), n(c), n(abs), b(a1), t(sheet)]), want); } + // Sheet names that read as a cell or an R1C1 reference, or start with a dot, are quoted; + // off-sheet cells and booleans are not (#1458). + for (sheet, want) in + [("A1", "'A1'!$A$1"), ("R1C1", "'R1C1'!$A$1"), ("RC", "'RC'!$A$1"), (".x", "'.x'!$A$1"), ("XFE1", "XFE1!$A$1"), ("TRUE", "TRUE!$A$1")] + { + is_text(ev("ADDRESS", vec![n(1.0), n(1.0), n(1.0), b(true), t(sheet)]), want); + } is_text(ev("ADDRESS", vec![n(1048576.0), n(16384.0)]), "$XFD$1048576"); is_err(ev("ADDRESS", vec![n(0.0), n(1.0)]), CellError::Value); is_err(ev("ADDRESS", vec![n(1.0), n(16385.0)]), CellError::Value); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f549aec2b..890ad6f62 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1264,7 +1264,7 @@ impl SheetApp { for n in &d.wb.names { let target = n.formula.replace('$', ""); let (sheet, body) = match target.rsplit_once('!') { - Some((s, b)) => (Some(s.trim_matches('\'').to_string()), b.to_string()), + Some((s, b)) => (Some(gridcraft_engine::core::unquote_sheet(s)), b.to_string()), None => (None, target.clone()), }; if sheet.as_deref().is_none_or(|s| s.eq_ignore_ascii_case(&sh.name)) && RangeRef::parse(&body) == Some(cur) { diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 02084ffb8..e316f4830 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -227,6 +227,19 @@ fn name_box_enter_goes_to_the_typed_cell_not_the_one_below() { assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(1, 0)); } +#[test] +fn name_box_shows_a_name_defined_on_a_sheet_with_an_apostrophe() { + // #1458: the quotes of `'Bob''s'!$A$1:$B$2` were taken off without reading `''` as `'`. + let mut h = harness(blank()); + let session = &mut h.state_mut().session; + session.run("home.insertSheet", json!({"name": "Bob's"})).unwrap(); + session.run("sheet.activate", json!({"sheet": "Bob's"})).unwrap(); + session.run("formulas.defineName", json!({"name": "Box", "refersTo": "='Bob''s'!$A$1:$B$2"})).unwrap(); + session.run("selection.set", json!({"range": "A1:B2"})).unwrap(); + h.run_steps(2); + assert_eq!(h.state().name_box_text(), "Box"); +} + #[test] fn typing_enters_values_and_moves_down() { let mut h = harness(blank()); diff --git a/crates/xlsx/src/array_formula.rs b/crates/xlsx/src/array_formula.rs index caa5e8e9f..81f0aaea6 100644 --- a/crates/xlsx/src/array_formula.rs +++ b/crates/xlsx/src/array_formula.rs @@ -53,7 +53,7 @@ impl Scan<'_> { return *shape; } let (scope, bare) = match n.rsplit_once('!') { - Some((s, name)) => (self.wb.sheet_index(&s.trim_matches('\'').replace("''", "'")).unwrap_or(sheet), name), + Some((s, name)) => (self.wb.sheet_index(&gridcraft_core::unquote_sheet(s)).unwrap_or(sheet), name), None => (sheet, n.as_str()), }; if let Some(def) = self.wb.name(bare, scope) { diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index 743a746e7..de0b82238 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -378,10 +378,7 @@ fn resolve_source(wb: &Workbook, text: &str, default_sheet: usize, depth: u32) - let xml = if valid { format!("", esc_attr(&tb.name)) } else { range_source_xml(&sh.name, r) }; return Some((si, r, xml)); } - let (sheet, body) = match t.rsplit_once('!') { - Some((sh, body)) => (Some(sh.trim_matches('\'').replace("''", "'")), body), - None => (None, t), - }; + let (sheet, body) = gridcraft_core::split_sheet(t); if let Some(r) = RangeRef::parse(body) { let si = match &sheet { Some(n) => wb.sheet_index(n)?, From 0ca1554642e15326b89e505605883ac2dc8a03b7 Mon Sep 17 00:00:00 2001 From: Eduardo Garcia Date: Thu, 15 Oct 2026 11:33:02 -0300 Subject: [PATCH 27/32] docs: sheet-name quoting rows in function and file-format parity (#1458) Co-Authored-By: Claude Sonnet 5.5 --- docs/file-format-parity.md | 3 ++- docs/function-parity.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5a7588986..56cef2cc3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (.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) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (XLSX formulas and PivotTable sources quote sheet names like Excel, so `'.x'!A1` no longer saves as `.x!A1` (#1458); .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) · **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** @@ -91,6 +91,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | 2026-10-18 | trivial | `file.open {csvAsText: true}` keeps every CSV/TSV/TXT field as the text the file holds, so identifiers (GTINs, SKUs, ZIP codes) keep their digits and leading zeros (#1808; a preference and a CLI flag are separate work) | | 2026-10-18 | trivial | XLSX PivotTables: `rowGrandTotals` is the Grand Total row and `colGrandTotals` the Grand Total column. Both were read and written the other way round, which a GridCraft round trip hid, so an Excel file with only one grand total opened showing the other | | 2026-10-19 | minor | HTML (Save as Web Page and clipboard) marks right-to-left sheets and right-to-left cell text with `dir` | +| 2026-10-15 | minor | XLSX: formulas and PivotTable sources quote sheet names like Excel (a leading `.`, R1C1-like and cell-like names), so `'.x'!A1` no longer saves as `.x!A1`, which Excel could not open (#1458) | | 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped | | 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) | | 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) | diff --git a/docs/function-parity.md b/docs/function-parity.md index 2343e0de9..8688f405c 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-15 · **Last updated:** 2026-10-15 · **Change:** minor (MEDIAN and AGGREGATE median avoid intermediate overflow) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-19 · **Change:** minor (ADDRESS quotes sheet names like Excel (#1458); MEDIAN and AGGREGATE median avoid intermediate overflow) · **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). @@ -115,6 +115,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| | 2026-10-15 | minor | MEDIAN and AGGREGATE median use an overflow-safe midpoint (#1579); regressions cover finite extremes, tiny values and dependent recalculation | +| 2026-10-15 | minor | ADDRESS quotes a sheet name the way Excel does: names that read as an A1 cell (`A1`) or an R1C1 reference (`R1C1`, `RC`) and names starting with `.` get quotes; `TRUE` / `FALSE` stay bare (#1458) | | 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) | | 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) | From 52ab0e6bfbe93803733659b91f4d5cef039e169a Mon Sep 17 00:00:00 2001 From: Eduardo Garcia Date: Mon, 19 Oct 2026 20:55:52 -0300 Subject: [PATCH 28/32] #1458: formula round-trip test for Excel's sheet-name quoting Co-Authored-By: Claude Sonnet 5.5 --- crates/formula/src/tests.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index d23eba0e2..d398da979 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -401,3 +401,15 @@ fn formulas_longer_than_excel_allows_are_refused() { assert!(text.len() > crate::MAX_FORMULA_LEN); assert!(parse(&text).is_ok()); } + +#[test] +fn sheet_names_excel_quotes_stay_quoted() { + // Excel quotes R1C1-looking names, a leading `.`, cells within the sheet and booleans (#1458). + for name in ["R", "C", "RC", "R1C", "RC1", "R1C1", "r2c3", "R1", "C1", "A1", "XFD1", "AB1048576", ".x", "TRUE", "false"] { + assert_eq!(rt(&format!("'{name}'!A1")), format!("'{name}'!A1"), "{name}"); + } + // Names Excel leaves bare. + for name in ["XFE1", "AB1048577", "Sheet.1", "_x", "A1A", "\u{3a3}"] { + assert_eq!(rt(&format!("'{name}'!A1")), format!("{name}!A1"), "{name}"); + } +} From bc58b2773a2f50cc3b954e36b3146755b28a3698 Mon Sep 17 00:00:00 2001 From: quantmind-br Date: Tue, 20 Oct 2026 08:25:18 -0300 Subject: [PATCH 29/32] Document the functions this PR touches --- crates/ui-egui/src/dialogs.rs | 2 ++ crates/ui-egui/src/formula_locale.rs | 3 +++ crates/ui-egui/src/i18n.rs | 2 ++ crates/ui-egui/src/ribbon.rs | 2 ++ 4 files changed, 9 insertions(+) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 093e88cd8..d027df9fd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1745,6 +1745,8 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: } } +/// Insert Function: searches names (as the formula language spells them) and descriptions, shows +/// each hit's category and description, and starts editing `=NAME(` (or appends to the formula). fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { let language = app.ui.language; ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/formula_locale.rs b/crates/ui-egui/src/formula_locale.rs index 11b551b10..c34e3cf86 100644 --- a/crates/ui-egui/src/formula_locale.rs +++ b/crates/ui-egui/src/formula_locale.rs @@ -160,10 +160,13 @@ pub(crate) fn display_template(text: &str, locale: FormulaLocale, wb: &Workbook, mod tests { use super::*; + /// The hint for `name`, empty when there is none. fn sig(name: &str, locale: FormulaLocale, language: Language) -> String { signature(name, locale, language).unwrap_or_default() } + /// Hints keep the formula syntax's name and separators and translate the argument names + /// only when the interface language has them all. #[test] fn function_help_follows_the_interface_language_and_the_formula_syntax() { use FormulaLocale::{En, Es}; diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 016441ced..fcc59bf3d 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -158,6 +158,7 @@ impl Language { /// English text and its translation, sorted by the English. type HelpTable = &'static [(&'static str, &'static str)]; +/// The translation of `english` in a help table (binary search, so the table must stay sorted). fn lookup(table: HelpTable, english: &str) -> Option<&'static str> { let i = table.binary_search_by(|(key, _)| (*key).cmp(english)).ok()?; table.get(i).map(|(_, translated)| *translated) @@ -623,6 +624,7 @@ const JAPANESE: &[(&str, &str)] = &[ mod tests { use super::*; + /// Help tables are sorted, unique, and keyed by text the function catalog still has. #[test] fn function_help_is_sorted_and_keyed_by_current_catalog_text() { // Lookups binary-search the tables. A key that no longer matches the catalog (an English diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index b25ce4b2e..f0b74089a 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1749,6 +1749,8 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { } } +/// The Formulas tab: Insert Function, AutoSum, the function-category menus (names in the formula +/// language, descriptions in the interface language), defined names, auditing and calculation. fn formulas(app: &mut SheetApp, ui: &mut Ui) { let __k = big_button(ui, Icon::Function, "Insert\nFunction", "Insert Function (⇧F3)", false); control_keytip(app, ui, &__k, "dialog:insertFunction"); From 3a01ce689e031f30da08ce78bde1fad9eaba1922 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:37:43 +0000 Subject: [PATCH 30/32] =?UTF-8?q?Integration:=20i18n=20drop=20duplicate=20?= =?UTF-8?q?Unprotect=20Sheet=E2=80=A6=20entries=20(added=20by=20#1373=20an?= =?UTF-8?q?d=20#1754)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/i18n/cs.rs | 1 - crates/ui-egui/src/i18n/fr.rs | 1 - crates/ui-egui/src/i18n/ko.rs | 1 - crates/ui-egui/src/i18n/pt.rs | 1 - crates/ui-egui/src/i18n/ru.rs | 1 - crates/ui-egui/src/i18n/sr.rs | 1 - crates/ui-egui/src/i18n/zh.rs | 1 - 7 files changed, 7 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index abdb14a79..e2fba7bf2 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -542,7 +542,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Tab Color" => "Barva záložky", "No Color" => "Bez barvy", "Unhide…" => "Zobrazit…", - "Unprotect Sheet…" => "Zrušit ochranu listu…", "Select All Sheets" => "Vybrat všechny listy", "AutoSave" => "Automatické ukládání", "AutoSave: save after every change (workbooks saved to a file)" => "Automatické ukládání: ukládat po každé změně (sešity uložené do souboru)", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index e43c7f00b..ed3458122 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -419,7 +419,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "No Color" => "Sans couleur", "Hide" => "Masquer", "Unhide…" => "Afficher…", - "Unprotect Sheet…" => "Ôter la protection de la feuille…", "Select All Sheets" => "Sélectionner toutes les feuilles", "AutoSave" => "Enregistrement automatique", "AutoSave: save after every change (workbooks saved to a file)" => "Enregistrement automatique : enregistrer après chaque modification (classeurs enregistrés dans un fichier)", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index aa1a0a601..e53afad02 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -419,7 +419,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "No Color" => "색 없음", "Hide" => "숨기기", "Unhide…" => "숨기기 취소…", - "Unprotect Sheet…" => "시트 보호 해제…", "Select All Sheets" => "모든 시트 선택", "AutoSave" => "자동 저장", "AutoSave: save after every change (workbooks saved to a file)" => "자동 저장: 변경할 때마다 저장 (파일로 저장된 통합 문서)", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index f29e25461..097c74210 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -963,7 +963,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Tab Color" => "Cor da guia", "No Color" => "Sem cor", "Unhide…" => "Reexibir…", - "Unprotect Sheet…" => "Desproteger planilha…", "Select All Sheets" => "Selecionar todas as planilhas", "AutoSave: save after every change (workbooks saved to a file)" => "Salvamento automático: salvar após cada alteração (pastas de trabalho salvas em um arquivo)", "All sheets" => "Todas as planilhas", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 44ce52548..18267bafa 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -419,7 +419,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "No Color" => "Без цвета", "Hide" => "Скрыть", "Unhide…" => "Показать…", - "Unprotect Sheet…" => "Снять защиту листа…", "Select All Sheets" => "Выделить все листы", "AutoSave" => "Автосохранение", "AutoSave: save after every change (workbooks saved to a file)" => "Автосохранение: сохранять после каждого изменения (книги, сохранённые в файл)", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index aadcfb981..2cfba7794 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -417,7 +417,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "No Color" => "Без боје", "Hide" => "Сакриј", "Unhide…" => "Прикажи…", - "Unprotect Sheet…" => "Уклони заштиту листа…", "Select All Sheets" => "Изабери све листове", "AutoSave" => "Аутоматско чување", "AutoSave: save after every change (workbooks saved to a file)" => "Аутоматско чување: сачувај после сваке измене (свеске сачуване у датотеку)", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 8c7ccbbd4..092579505 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -411,7 +411,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "No Color" => "无颜色", "Hide" => "隐藏", "Unhide…" => "取消隐藏…", - "Unprotect Sheet…" => "取消工作表保护…", "Select All Sheets" => "选择所有工作表", "AutoSave" => "自动保存", "AutoSave: save after every change (workbooks saved to a file)" => "自动保存:每次更改后保存(已保存到文件的工作簿)", From a9a8fe7d205abcdb29f7db2073ab8259dd95a4f1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:37:48 +0000 Subject: [PATCH 31/32] Integration: #1754 translate the sheet-tab Delete button; Spanish Hide and Autoguardado; test that the sheet-tab and AutoSave labels translate in es, de, fr, ko, ru, zh, sr --- crates/ui-egui/src/i18n.rs | 30 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/es.rs | 5 +++-- crates/ui-egui/src/tabs.rs | 2 +- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index fcc59bf3d..9b93707b3 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -733,6 +733,36 @@ mod tests { } } + #[test] + fn sheet_tab_menu_and_autosave_labels_are_translated() { + let labels = [ + "Insert Sheet", + "Delete", + "Rename", + "Move or Copy…", + "Duplicate", + "Tab Color", + "No Color", + "Hide", + "Unhide…", + "Protect Sheet…", + "Unprotect Sheet…", + "Select All Sheets", + "AutoSave", + "AutoSave: save after every change (workbooks saved to a file)", + "Previous sheet", + "Next sheet", + "All sheets", + "New sheet (⇧F11)", + ]; + for language in [Language::Es, Language::De, Language::Fr, Language::Ko, Language::Ru, Language::Zh, Language::Sr] { + for label in labels { + assert_ne!(language.tr(label), label, "{} lacks a translation of {label:?}", language.code()); + } + } + assert_eq!(Language::Es.tr("AutoSave"), "Autoguardado"); + } + #[test] fn translations_never_show_the_context_prefix() { // `tr` returns a found translation as it is, so a catalog that keeps `context|` in its diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index b8f3fe7a0..d711bf851 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -447,10 +447,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Duplicate" => "Duplicar", "Tab Color" => "Color de etiqueta", "No Color" => "Sin color", + "Hide" => "Ocultar", "Unhide…" => "Mostrar…", "Select All Sheets" => "Seleccionar todas las hojas", - "AutoSave" => "Autoguardar", - "AutoSave: save after every change (workbooks saved to a file)" => "Autoguardar: guardar después de cada cambio (libros guardados en un archivo)", + "AutoSave" => "Autoguardado", + "AutoSave: save after every change (workbooks saved to a file)" => "Autoguardado: guardar después de cada cambio (libros guardados en un archivo)", "Previous sheet" => "Hoja anterior", "Next sheet" => "Hoja siguiente", "All sheets" => "Todas las hojas", diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 37699ffe0..3f770102e 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -173,7 +173,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { if ui.button(app.ui.language.tr("Insert Sheet")).clicked() { app.run_or_alert("home.insertSheet", json!({"before": i})); } - if ui.button("Delete").clicked() { + 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() { From 55aeed6f1a3c822597586c017300379f9b00e3df Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:48:11 +0000 Subject: [PATCH 32/32] Integration: cargo fmt --- crates/ui-egui/src/i18n/de.rs | 4 +++- crates/ui-egui/src/i18n/es.rs | 4 +++- crates/ui-egui/src/i18n/fr.rs | 4 +++- crates/ui-egui/src/i18n/pt.rs | 4 +++- crates/ui-egui/src/i18n/ru.rs | 4 +++- crates/ui-egui/src/i18n/sr.rs | 4 +++- crates/ui-egui/src/tabs.rs | 9 ++++++++- 7 files changed, 26 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 6179b3f8d..284175707 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -433,7 +433,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Unhide…" => "Einblenden…", "Select All Sheets" => "Alle Blätter auswählen", "AutoSave" => "AutoSpeichern", - "AutoSave: save after every change (workbooks saved to a file)" => "AutoSpeichern: nach jeder Änderung speichern (Arbeitsmappen, die in einer Datei gespeichert sind)", + "AutoSave: save after every change (workbooks saved to a file)" => { + "AutoSpeichern: nach jeder Änderung speichern (Arbeitsmappen, die in einer Datei gespeichert sind)" + } "Previous sheet" => "Vorheriges Blatt", "Next sheet" => "Nächstes Blatt", "All sheets" => "Alle Blätter", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index d711bf851..892827337 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -451,7 +451,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Unhide…" => "Mostrar…", "Select All Sheets" => "Seleccionar todas las hojas", "AutoSave" => "Autoguardado", - "AutoSave: save after every change (workbooks saved to a file)" => "Autoguardado: guardar después de cada cambio (libros guardados en un archivo)", + "AutoSave: save after every change (workbooks saved to a file)" => { + "Autoguardado: guardar después de cada cambio (libros guardados en un archivo)" + } "Previous sheet" => "Hoja anterior", "Next sheet" => "Hoja siguiente", "All sheets" => "Todas las hojas", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index ed3458122..c1ec8c5f3 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -421,7 +421,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Unhide…" => "Afficher…", "Select All Sheets" => "Sélectionner toutes les feuilles", "AutoSave" => "Enregistrement automatique", - "AutoSave: save after every change (workbooks saved to a file)" => "Enregistrement automatique : enregistrer après chaque modification (classeurs enregistrés dans un fichier)", + "AutoSave: save after every change (workbooks saved to a file)" => { + "Enregistrement automatique : enregistrer après chaque modification (classeurs enregistrés dans un fichier)" + } "Previous sheet" => "Feuille précédente", "Next sheet" => "Feuille suivante", "All sheets" => "Toutes les feuilles", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 097c74210..5fa479be7 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -964,7 +964,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "No Color" => "Sem cor", "Unhide…" => "Reexibir…", "Select All Sheets" => "Selecionar todas as planilhas", - "AutoSave: save after every change (workbooks saved to a file)" => "Salvamento automático: salvar após cada alteração (pastas de trabalho salvas em um arquivo)", + "AutoSave: save after every change (workbooks saved to a file)" => { + "Salvamento automático: salvar após cada alteração (pastas de trabalho salvas em um arquivo)" + } "All sheets" => "Todas as planilhas", _ => return None, }) diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 18267bafa..e2077b0a5 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -421,7 +421,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Unhide…" => "Показать…", "Select All Sheets" => "Выделить все листы", "AutoSave" => "Автосохранение", - "AutoSave: save after every change (workbooks saved to a file)" => "Автосохранение: сохранять после каждого изменения (книги, сохранённые в файл)", + "AutoSave: save after every change (workbooks saved to a file)" => { + "Автосохранение: сохранять после каждого изменения (книги, сохранённые в файл)" + } "Previous sheet" => "Предыдущий лист", "Next sheet" => "Следующий лист", "All sheets" => "Все листы", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index 2cfba7794..3c2a72232 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -419,7 +419,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Unhide…" => "Прикажи…", "Select All Sheets" => "Изабери све листове", "AutoSave" => "Аутоматско чување", - "AutoSave: save after every change (workbooks saved to a file)" => "Аутоматско чување: сачувај после сваке измене (свеске сачуване у датотеку)", + "AutoSave: save after every change (workbooks saved to a file)" => { + "Аутоматско чување: сачувај после сваке измене (свеске сачуване у датотеку)" + } "Previous sheet" => "Претходни лист", "Next sheet" => "Следећи лист", "All sheets" => "Сви листови", diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 3f770102e..e79e5e69a 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -202,7 +202,14 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { } 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() { + 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() {