From 5f748d55942c992f7befdf35607562e779145759 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:49:44 +0000 Subject: [PATCH 01/39] Keep the in-cell editor at the cell's height for one line Fixes #1755 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 3 +- crates/ui-egui/tests/in_cell_editor_size.rs | 87 +++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/in_cell_editor_size.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 90a39cb06..4979e42f3 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2710,7 +2710,8 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x; let lines = ed.text.lines().count().max(1) as f32 + if ed.text.ends_with('\n') { 1.0 } else { 0.0 }; let w = (text_w + 14.0).max(rect.width()).min(geo.cells.right() - rect.left()); - let h = (size * 1.3 * lines + 4.0).max(rect.height()); + // Excel keeps the cell's height for one line and only grows for extra lines. + let h = if lines > 1.0 { (size * 1.3 * lines + 4.0).max(rect.height()) } else { rect.height() }; // Grows to the left on a right-to-left sheet. let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h))); let fill = match st.fill.pattern { diff --git a/crates/ui-egui/tests/in_cell_editor_size.rs b/crates/ui-egui/tests/in_cell_editor_size.rs new file mode 100644 index 000000000..4d82cd711 --- /dev/null +++ b/crates/ui-egui/tests/in_cell_editor_size.rs @@ -0,0 +1,87 @@ +//! The in-cell editor covers the edited cell exactly, as in Excel, at every zoom (#1755). + +use egui::{Color32, Event, Modifiers, PointerButton, Rect, Shape}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +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(); + 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(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn cell_rect(h: &Harness, cell: &str) -> Rect { + 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()) +} + +fn click_cell(h: &mut Harness, cell: &str) { + let pos = cell_rect(h, cell).center(); + h.input_mut().events.extend([ + Event::PointerMoved(pos), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ]); + h.run_steps(2); +} + +fn type_text(h: &mut Harness, text: &str) { + h.input_mut().events.push(Event::Text(text.into())); + h.run_steps(3); +} + +/// The white editor background painted from the cell's top-left corner. +fn editor_rect(h: &Harness, cell: Rect) -> Rect { + h.output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::Rect(r) if r.fill == Color32::WHITE && (r.rect.min - cell.min).length() < 0.01 => Some(r.rect), + _ => None, + }) + .next_back() + .expect("in-cell editor background") +} + +fn assert_close(a: Rect, b: Rect, what: &str) { + assert!((a.min - b.min).length() < 0.01 && (a.max - b.max).length() < 0.01, "{what}: editor {a:?} must match cell {b:?}"); +} + +#[test] +fn editor_matches_the_cell_at_every_zoom() { + for zoom in [50u16, 100, 200, 400] { + let mut h = harness(); + h.state_mut().run("view.zoom", json!({"percent": zoom})).unwrap(); + h.run_steps(3); + click_cell(&mut h, "B2"); + type_text(&mut h, "abc"); + assert!(h.state().editor.is_some()); + let cell = cell_rect(&h, "B2"); + assert_close(editor_rect(&h, cell), cell, &format!("zoom {zoom}")); + } +} + +#[test] +fn one_long_line_widens_the_editor_but_keeps_the_row_height() { + let mut h = harness(); + click_cell(&mut h, "B2"); + type_text(&mut h, "a much longer line of text than the cell can hold"); + let cell = cell_rect(&h, "B2"); + let ed = editor_rect(&h, cell); + assert!(ed.width() > cell.width() + 1.0, "long text widens the editor: {ed:?} vs {cell:?}"); + assert!((ed.height() - cell.height()).abs() < 0.01, "one line keeps the row height: {ed:?} vs {cell:?}"); +} From 5bafacff6979b1f52a5a7dbb4415add196fdca63 Mon Sep 17 00:00:00 2001 From: MH Date: Mon, 19 Oct 2026 12:59:24 +0300 Subject: [PATCH 02/39] Grid: row and column heading labels grow with the zoom At high zoom the heading labels stopped at 16 points (the outline levels at 14) while the cells kept growing, so at 300-400% the letters and numbers looked tiny in their tall headings. Excel scales them with the zoom. The labels now scale without a cap and keep their readable floor at low zoom (heading_font_size). Signed-off-by: MH --- crates/ui-egui/src/grid.rs | 17 ++++++++++++++++- crates/ui-egui/src/outline.rs | 3 ++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 90a39cb06..3c87f88a4 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1701,7 +1701,7 @@ pub(crate) fn paint_headers( t: &Tokens, quads: &[((u32, u32), (u32, u32), Rect)], ) { - let font = theme::ui_font((11.5 * (geo.z / DISPLAY_SCALE).max(0.75)).min(16.0)); + let font = theme::ui_font(heading_font_size(geo.z)); // The headers' own area: inside the outline bands. let r = Rect::from_min_max(pos2(geo.head_left(), geo.head_top()), geo.rect.max); let top = Rect::from_min_max(pos2(geo.cells.left(), r.top()), pos2(r.right(), geo.cells.top())); @@ -3336,8 +3336,23 @@ mod selection_fill_tests { } } +/// Row and column heading labels grow with the zoom as the cells do, as in Excel (at 400% the +/// labels are four times their 100% size), and never get smaller than a readable 8.5 points. +pub(crate) fn heading_font_size(zoom: f32) -> f32 { + (11.5 * (zoom / DISPLAY_SCALE)).max(8.5) +} + #[cfg(test)] mod tests { + + #[test] + fn heading_labels_scale_with_the_zoom_without_a_cap() { + use super::{DISPLAY_SCALE, heading_font_size}; + let at = |percent: f32| heading_font_size(percent / 100.0 * DISPLAY_SCALE); + assert!((at(400.0) / at(100.0) - 4.0).abs() < 1e-4, "four times as large at 400%"); + assert!(at(200.0) > at(100.0) && at(400.0) > at(200.0)); + assert_eq!(at(10.0), 8.5, "never below a readable size"); + } use super::*; #[test] diff --git a/crates/ui-egui/src/outline.rs b/crates/ui-egui/src/outline.rs index 62e2cc36d..61c40811c 100644 --- a/crates/ui-egui/src/outline.rs +++ b/crates/ui-egui/src/outline.rs @@ -261,7 +261,8 @@ pub fn paint(p: &Painter, geo: &Geo, sh: &Sheet, t: &Tokens) { pc.line_segment(tick, ink); } } - let font = theme::ui_font((10.0 * (geo.z / DISPLAY_SCALE).max(0.75)).min(14.0)); + // The outline levels grow with the zoom like the headings beside them. + let font = theme::ui_font((10.0 * (geo.z / DISPLAY_SCALE)).max(7.5)); for b in &l.buttons { let clip = match b.action { Action::Toggle { rows: true, .. } => row_clip, From 31abf075b74b542aca0a668993a502b98a982c3c Mon Sep 17 00:00:00 2001 From: Lord-Taw <149683190+Lord-Taw@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:33:07 -0300 Subject: [PATCH 03/39] Translate and standardizes UI error feedback by locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary This change centralizes the translation of user-facing error messages in the UI layer while preserving the original error payloads for machine consumption and AI-agent integrations. ### What changed - Added a centralized error-translation path in `i18n.rs`. - The UI now translates only the final display text, instead of altering the underlying engine or protocol error payload. - If a translated string is not available, the original message is kept as a fallback, avoiding broken or overly-simplified output. - The translation logic is applied at the presentation layer, not at the source of the error. ### Updated call sites - Error and alert rendering in `widgets.rs` - Dialog error presentation in `dialogs.rs` - Print and feedback failures in `printing.rs` - Top-level UI error handling in `lib.rs` ### Design intent - Keep the raw engine errors intact for scripts, control channels, and AI/agent integrations. - Present a clearer, localized message only to end users. - Avoid modifying dictionary files or adding new translation entries to the global catalog unnecessarily. - Keep the behavior consistent with the project’s accessibility and internationalization goals. ### Validation - The focused i18n test passed successfully. - The relevant UI test suite passed successfully. - The repository-required CI gate was run and completed without failures according to the latest verification output. --- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/i18n.rs | 10 ++++++++++ crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/printing.rs | 6 +++--- crates/ui-egui/src/widgets.rs | 3 +++ 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fb899c702..a1ecdcb09 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2612,7 +2612,7 @@ fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: & fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { let r = d.result.clone().unwrap_or(Json::Null); if let Some(e) = r.get("error").and_then(Json::as_str) { - ui.label(crate::clean_error(e)); + ui.label(app.ui.language.tr_error(e)); return; } let issues = r["issues"].as_array().cloned().unwrap_or_default(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 061698df9..67f673dc1 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -132,6 +132,13 @@ impl Language { }; translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label)) } + + pub(crate) fn tr_error(self, message: &str) -> String { + let message = crate::clean_error(message); + let translated = self.tr(&message); + let fallback = message.rsplit_once('|').map_or(message.as_str(), |(_, label)| label); + if translated == fallback { message } else { translated.to_owned() } + } } /// Deserializes a saved language leniently: an unknown or malformed value (a language removed in a @@ -617,6 +624,9 @@ mod tests { assert_eq!(Language::Ko.tr("Data"), "데이터"); assert_eq!(Language::Ru.tr("Data"), "Данные"); assert_eq!(Language::PtBr.tr("Data"), "Dados"); + assert_eq!(Language::PtBr.tr_error("Select a chart first."), "Selecione um gráfico primeiro."); + assert_eq!(Language::PtBr.tr_error("Sheet|Unknown failure"), "Sheet|Unknown failure"); + assert_eq!(Language::PtBr.tr_error("command `chart`: Select a chart first."), "Selecione um gráfico primeiro."); assert_eq!(Language::Es.tr("Data"), "Datos"); assert_eq!(Language::Es.tr("Freeze Panes"), "Inmovilizar paneles"); assert_eq!(Language::Es.tr("Sheet1!A1"), "Sheet1!A1"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f96cdac3c..f0f720442 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -980,7 +980,7 @@ impl SheetApp { else { return; }; - let text = printing::feedback(&message, |label| self.ui.language.tr(label).to_owned()); + let text = printing::feedback(&message, self.ui.language, |label| self.ui.language.tr(label).to_owned()); if matches!(message, printing::Feedback::Submitted(_)) { self.toast = Some((text, now_ms())); } else { diff --git a/crates/ui-egui/src/printing.rs b/crates/ui-egui/src/printing.rs index 7c34120bb..7193678c2 100644 --- a/crates/ui-egui/src/printing.rs +++ b/crates/ui-egui/src/printing.rs @@ -17,7 +17,7 @@ pub fn show(state: &mut State, ui: &mut egui::Ui, discover: &Discover, translate }; state.show(ui, egui::Id::new("gridcraft_print_settings"), discover, &labels); if let Some(message) = &state.message { - ui.label(feedback(message, translate)); + ui.label(feedback(message, crate::i18n::current(ui.ctx()), translate)); } } @@ -84,7 +84,7 @@ pub fn settings(state: &mut State, params: &serde_json::Value, discover: Option< })) } -pub fn feedback(message: &Feedback, translate: impl Fn(&str) -> String) -> String { +pub fn feedback(message: &Feedback, language: crate::i18n::Language, translate: impl Fn(&str) -> String) -> String { match message { Feedback::FindingPrinters => translate("Finding printers…"), Feedback::NoPrinters => translate("No printers found. Configure a system printer or export a PDF."), @@ -92,7 +92,7 @@ pub fn feedback(message: &Feedback, translate: impl Fn(&str) -> String) -> Strin Feedback::Submitting => translate("Submitting print job…"), Feedback::Submitted(receipt) if receipt.trim().is_empty() => translate("Print job submitted."), Feedback::Submitted(receipt) => translate("Print job submitted: {receipt}").replace("{receipt}", receipt), - Feedback::Failed(error) => translate("Print failed: {error}").replace("{error}", error), + Feedback::Failed(error) => translate("Print failed: {error}").replace("{error}", &language.tr_error(error)), Feedback::WorkerStopped => translate("Print failed: the printing worker stopped."), } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e24105b49..6de95d1af 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -272,6 +272,8 @@ pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) -> pub fn message_box(app: &mut SheetApp, ctx: &egui::Context) { let Some((title, msg)) = app.message.clone() else { return }; + let title = app.ui.language.tr(&title).to_owned(); + let msg = app.ui.language.tr_error(&msg); let mut close = false; egui::Modal::new(egui::Id::new("message_box")).show(ctx, |ui| { ui.set_width(380.0); @@ -302,6 +304,7 @@ pub fn toast(app: &mut SheetApp, ctx: &egui::Context) { app.toast = None; return; } + let msg = app.ui.language.tr_error(&msg); let Some(rect) = app.grid.rect else { return }; egui::Area::new(egui::Id::new("toast")).fixed_pos(pos2(rect.center().x - 160.0, rect.bottom() - 48.0)).order(egui::Order::Tooltip).show( ctx, From 02ceedee173b6b1e6c063e8e4a7f5ec0924e2d83 Mon Sep 17 00:00:00 2001 From: Camilo Agudelo Date: Mon, 19 Oct 2026 10:12:17 -0500 Subject: [PATCH 04/39] In-cell editor: a Wrap Text cell edits wrapped at the column width The in-cell editor ignored Wrap Text: it widened to the text's one-line width, up to the grid's right edge, so a long wrapped cell became one unmanageable line while edited. With Wrap Text on it now keeps the column's width, wraps, and grows down to show every line, as Excel's does. The multiline field is one row tall at least, not four, so a short edit doesn't reach over the rows below. --- crates/ui-egui/src/formula_bar.rs | 8 ++- crates/ui-egui/src/grid.rs | 16 ++++-- crates/ui-egui/tests/in_cell_editor_wrap.rs | 59 +++++++++++++++++++++ 3 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/tests/in_cell_editor_wrap.rs diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 7f1ad87fc..848ba5458 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -279,7 +279,13 @@ 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 edit = if multiline || ed.text.contains('\n') { TextEdit::multiline(&mut ed.text) } else { TextEdit::singleline(&mut ed.text) }; + // The in-cell editor is as tall as its lines (a multiline field reserves four rows). + let in_cell = id == egui::Id::new("gridcraft.cell_editor"); + let edit = if multiline || ed.text.contains('\n') { + TextEdit::multiline(&mut ed.text).desired_rows(if in_cell { 1 } else { 4 }) + } else { + TextEdit::singleline(&mut ed.text) + }; let mut output = edit .id(id) .frame(egui::Frame::NONE) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 90a39cb06..6cd49691c 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2707,10 +2707,18 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, let st = wb.styles.get(sh.style_id(cell)).clone(); let size = st.font.size * 96.0 / 72.0 * geo.z; let font = FontId::new(size, theme::cell_family(&st.font.name, st.font.bold, st.font.italic)); - let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x; let lines = ed.text.lines().count().max(1) as f32 + if ed.text.ends_with('\n') { 1.0 } else { 0.0 }; - let w = (text_w + 14.0).max(rect.width()).min(geo.cells.right() - rect.left()); - let h = (size * 1.3 * lines + 4.0).max(rect.height()); + // Wrap Text: the editor keeps the column's width and wraps, growing down as Excel's does. + // Otherwise it widens to the text's one-line width. + let wrap = st.align.wrap; + let (w, h) = if wrap { + // The field's own margins (4 px a side) and the 2 px inset leave this much for the text. + let wrapped = ui.painter().layout(ed.text.clone(), font.clone(), Color32::BLACK, (rect.width() - 12.0).max(1.0)); + (rect.width(), (wrapped.size().y.max(size * 1.3 * lines) + 4.0).max(rect.height())) + } else { + let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x; + ((text_w + 14.0).max(rect.width()).min(geo.cells.right() - rect.left()), (size * 1.3 * lines + 4.0).max(rect.height())) + }; // Grows to the left on a right-to-left sheet. let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h))); let fill = match st.fill.pattern { @@ -2729,7 +2737,7 @@ 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); - crate::formula_bar::editor_widget(app, &mut child, id, font, false, erect.width() - 4.0); + crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0); } /// Test the popup itself, not whether the pointer is over any part of the app. diff --git a/crates/ui-egui/tests/in_cell_editor_wrap.rs b/crates/ui-egui/tests/in_cell_editor_wrap.rs new file mode 100644 index 000000000..7ebbf79ea --- /dev/null +++ b/crates/ui-egui/tests/in_cell_editor_wrap.rs @@ -0,0 +1,59 @@ +//! The in-cell editor of a Wrap Text cell keeps the column's width and wraps, growing down, as +//! Excel's does; it used to widen to one long line. +use egui::{Context, Id, RawInput, Rect, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +const TEXT: &str = "Since the 1960s, NASA has left hardware across the solar system, on the Moon, on Mars, and in deep space."; + +/// Runs a few frames of the app in a 1200 × 800 window. +fn frames(ctx: &Context, app: &mut SheetApp) { + for _ in 0..4 { + 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(); + } +} + +/// The editor's text field rect while A1 (narrow, wrap on or off) is edited with [`TEXT`]. +fn editor_rect(wrap: bool) -> Rect { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + session.execute("home.columnWidth", json!({"cols": "A:A", "chars": 12})).unwrap(); + session.execute("home.wrapText", json!({"range": "A1", "on": wrap})).unwrap(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some(TEXT.into()), false); + frames(&ctx, &mut app); + ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect +} + +#[test] +fn a_wrap_text_cell_edits_wrapped_at_the_column_width() { + let wrapped = editor_rect(true); + let one_line = editor_rect(false); + assert!(one_line.width() > 400.0, "without Wrap Text the editor widens to the text: {one_line:?}"); + assert!(wrapped.width() < 120.0, "with Wrap Text the editor keeps the column's width: {wrapped:?}"); + assert!(wrapped.height() > 4.0 * one_line.height(), "and grows down to show every line: {wrapped:?} vs {one_line:?}"); +} + +/// A short Wrap Text edit stays one line tall: the field doesn't reserve rows over the cells below. +#[test] +fn a_short_wrap_text_edit_stays_one_line_tall() { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + session.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some("short".into()), false); + frames(&ctx, &mut app); + let field = ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect; + assert!(field.height() < 30.0, "a one-line edit doesn't reach over the rows below: {field:?}"); +} From 8f239e3b2f03fa26d11af1601c1e4023366f6244 Mon Sep 17 00:00:00 2001 From: Lord-Taw <149683190+Lord-Taw@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:41:11 -0300 Subject: [PATCH 05/39] Update i18n.rs coderabbit --- crates/ui-egui/src/i18n.rs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 67f673dc1..de8dda059 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -133,13 +133,22 @@ impl Language { translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label)) } - pub(crate) fn tr_error(self, message: &str) -> String { - let message = crate::clean_error(message); - let translated = self.tr(&message); - let fallback = message.rsplit_once('|').map_or(message.as_str(), |(_, label)| label); - if translated == fallback { message } else { translated.to_owned() } + pub(crate) fn tr_error(self, message: &str) -> String { + let message = if message.starts_with("command `") { + crate::clean_error(message) + } else { + message.to_owned() + }; + + let translated = self.tr(&message); + let fallback = message.rsplit_once('|').map_or(message.as_str(), |(_, label)| label); + + if translated == fallback { + message + } else { + translated.to_owned() } -} +}} /// Deserializes a saved language leniently: an unknown or malformed value (a language removed in a /// later version, a hand-edited `ui.json`) reads as English instead of failing the whole `UiState`, @@ -627,6 +636,7 @@ mod tests { assert_eq!(Language::PtBr.tr_error("Select a chart first."), "Selecione um gráfico primeiro."); assert_eq!(Language::PtBr.tr_error("Sheet|Unknown failure"), "Sheet|Unknown failure"); assert_eq!(Language::PtBr.tr_error("command `chart`: Select a chart first."), "Selecione um gráfico primeiro."); + assert_eq!(Language::PtBr.tr_error("Printer Office: permission denied"),"Printer Office: permission denied"); assert_eq!(Language::Es.tr("Data"), "Datos"); assert_eq!(Language::Es.tr("Freeze Panes"), "Inmovilizar paneles"); assert_eq!(Language::Es.tr("Sheet1!A1"), "Sheet1!A1"); From 5c4dbbbc4ac3252c0cac3f378f778eb7e9f22e44 Mon Sep 17 00:00:00 2001 From: Lord-Taw <149683190+Lord-Taw@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:54:24 -0300 Subject: [PATCH 06/39] Atualizar o i18n.rs --- crates/ui-egui/src/i18n.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index de8dda059..f347efb19 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -148,7 +148,8 @@ impl Language { } else { translated.to_owned() } -}} + } +} /// Deserializes a saved language leniently: an unknown or malformed value (a language removed in a /// later version, a hand-edited `ui.json`) reads as English instead of failing the whole `UiState`, From 71acb0c50bb07a9306535c85add05f3baac82ea0 Mon Sep 17 00:00:00 2001 From: Lord-Taw <149683190+Lord-Taw@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:56:10 -0300 Subject: [PATCH 07/39] Atualizar o i18n.rs CoderabbitAI --- crates/ui-egui/src/i18n.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index f347efb19..6e9b1bfd0 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -133,7 +133,7 @@ impl Language { translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label)) } - pub(crate) fn tr_error(self, message: &str) -> String { + pub(crate) fn tr_error(self, message: &str) -> String { let message = if message.starts_with("command `") { crate::clean_error(message) } else { From 752477a2315c09f65ae5322521c507501b42c849 Mon Sep 17 00:00:00 2001 From: bfenty <1398236+bfenty@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:13:44 +0000 Subject: [PATCH 08/39] feat(ui): Ctrl+[ / Ctrl+] select precedent and dependent cells (#1865) Adds formulas.selectPrecedents / formulas.selectDependents engine commands and binds them to Ctrl+[ and Ctrl+] (Ctrl+Shift walks every level), matching Excel's shortcut parity. Direct-level traces keep whole precedent ranges; cross-sheet precedents jump to the owning sheet; an empty trace leaves the selection untouched with an actionable message. --- =560 | 0 crates/engine/src/cmd/formulas.rs | 71 ++++++++++++++++ crates/ui-egui/src/keymap.rs | 5 ++ crates/ui-egui/tests/trace_navigation.rs | 100 +++++++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 =560 create mode 100644 crates/ui-egui/tests/trace_navigation.rs diff --git a/=560 b/=560 new file mode 100644 index 000000000..e69de29bb diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 40df72df0..0607901ff 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -49,6 +49,8 @@ pub fn specs() -> Vec { ), cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents), cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents), + cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents), + cmd!(noundo "formulas.selectDependents", "Select Dependents", ["Formulas", "Formula Auditing"], Some("Ctrl+]"), "{cell?, all?: true = every level (Ctrl+Shift+])}", has_doc, select_dependents), cmd!(noundo "formulas.removeArrows", "Remove Arrows", ["Formulas", "Formula Auditing"], None, "{}", has_doc, |_, _| ok()), cmd!("formulas.showFormulas", "Show Formulas", ["Formulas", "Formula Auditing"], Some("Ctrl+`"), "{on?}", has_doc, show_formulas), cmd!(query "formulas.errorChecking", "Error Checking", ["Formulas", "Formula Auditing"], None, "{} → cells with errors", has_doc, error_checking), @@ -479,6 +481,75 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result { Ok(Json::Array(v)) } +/// Excel's Ctrl+[ / Ctrl+]: select the precedent (or dependent) cells. When they all live on +/// another sheet, jump there. `all` walks every level (Ctrl+Shift+[ / Ctrl+Shift+]). +fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result { + use std::collections::BTreeSet; + const CAP: usize = 4096; + let d = s.doc()?; + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); + let sheet = d.wb.active_sheet; + let all = bool_param(p, "all").unwrap_or(false); + // (sheet, a1) pairs to select; direct level keeps whole precedent ranges, transitive cells. + let mut found: BTreeSet<(usize, String)> = BTreeSet::new(); + let mut seen: BTreeSet<(usize, CellRef)> = BTreeSet::new(); + let mut frontier = vec![(sheet, at)]; + seen.insert((sheet, at)); + loop { + let Some((ks, kc)) = frontier.pop() else { break }; + if precedents { + for a in d.calc.graph.precedents_of((ks, kc)) { + if all { + for c in a.range.iter().take(CAP) { + if seen.insert((a.sheet, c)) { + found.insert((a.sheet, c.a1())); + frontier.push((a.sheet, c)); + } + } + } else { + found.insert((a.sheet, a.range.a1())); + } + } + } else { + let mut deps = Vec::new(); + d.calc.graph.dependents(ks, kc, &mut deps); + for k2 in deps { + if all { + if seen.insert(k2) { + found.insert((k2.0, k2.1.a1())); + frontier.push(k2); + } + } else { + found.insert((k2.0, k2.1.a1())); + } + } + } + if !all { + break; + } + } + found.remove(&(sheet, at.a1())); + let name = if precedents { "precedent" } else { "dependent" }; + let Some(si) = found.iter().find(|(si, _)| *si == sheet).or_else(|| found.first()).map(|(si, _)| *si) else { + return Err(bad("cell", format!("No {name} cells."))); + }; + let list: Vec = found.iter().filter(|(s, _)| *s == si).map(|(_, a)| a.clone()).collect(); + let mut params = json!({ "range": list.join(",") }); + if si != sheet { + params["sheet"] = json!(si); + } + drop(d); + s.execute("selection.set", params) +} + +fn select_precedents(s: &mut Session, p: &Json) -> Result { + select_trace_cells(s, p, true) +} + +fn select_dependents(s: &mut Session, p: &Json) -> Result { + select_trace_cells(s, p, false) +} + fn show_formulas(s: &mut Session, p: &Json) -> Result { let sheet = s.doc()?.wb.active_sheet; let cur = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.show_formulas); diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 032db1a2d..a6497db8b 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -84,6 +84,11 @@ pub fn lookup(key: Key, physical: Option, m: Modifiers, mac: bool) -> Optio Semicolon if ctrl && s => Shortcut::Time, Quote if ctrl && s => Shortcut::ValueAbove, Quote if ctrl => Shortcut::FormulaAbove, + // Ctrl+[ / Ctrl+] (Excel): select precedent / dependent cells; Shift walks every level. + OpenBracket if ctrl && !s => run("formulas.selectPrecedents"), + OpenBracket if ctrl => Shortcut::Run("formulas.selectPrecedents", json!({"all": true})), + CloseBracket if ctrl && !s => run("formulas.selectDependents"), + CloseBracket if ctrl => Shortcut::Run("formulas.selectDependents", json!({"all": true})), // Windows Ctrl+; is in `ribbon::shortcut`; Excel for Mac uses Control+; and Command+;. Semicolon if mac && ctrl => Shortcut::Date, Semicolon if mac && cmd && !s => Shortcut::Time, diff --git a/crates/ui-egui/tests/trace_navigation.rs b/crates/ui-egui/tests/trace_navigation.rs new file mode 100644 index 000000000..f891a01c8 --- /dev/null +++ b/crates/ui-egui/tests/trace_navigation.rs @@ -0,0 +1,100 @@ +//! Ctrl+[ / Ctrl+] select precedent and dependent cells (#1865). + +use egui::{Event, Key, Modifiers}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(session: Session) -> Harness { + 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 active(h: &Harness) -> String { + let d = h.state().session.active().unwrap(); + d.selection.active.a1() +} + +fn ranges(h: &Harness) -> Vec { + let d = h.state().session.active().unwrap(); + d.selection.ranges.iter().map(|r| r.a1()).collect() +} + +fn key(h: &mut Harness, k: Key, m: Modifiers) { + 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 }); + h.run_steps(2); +} + +/// A1 and B1 feed C1; C1 feeds D1. +fn chain() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3]]})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=A1+B1"})).unwrap(); + s.execute("cell.set", json!({"cell": "D1", "input": "=C1*2"})).unwrap(); + s +} + +#[test] +fn ctrl_bracket_selects_precedents_and_dependents() { + let mut h = harness(chain()); + h.state_mut().session.execute("selection.set", json!({"range": "C1"})).unwrap(); + key(&mut h, Key::OpenBracket, Modifiers::CTRL); + let mut r = ranges(&h); + r.sort(); + assert_eq!(r, vec!["A1".to_string(), "B1".to_string()], "Ctrl+[ selects the precedent cells"); + h.state_mut().session.execute("selection.set", json!({"range": "C1"})).unwrap(); + key(&mut h, Key::CloseBracket, Modifiers::CTRL); + assert_eq!(active(&h), "D1", "Ctrl+] lands on the dependent cell"); +} + +#[test] +fn ctrl_shift_bracket_walks_every_level() { + let mut h = harness(chain()); + h.state_mut().session.execute("selection.set", json!({"range": "D1"})).unwrap(); + key(&mut h, Key::OpenBracket, Modifiers::CTRL | Modifiers::SHIFT); + let r = ranges(&h); + assert!( + r.contains(&"A1".to_string()) && r.contains(&"B1".to_string()) && r.contains(&"C1".to_string()), + "Ctrl+Shift+[ walks back through C1 to A1:B1, got {r:?}" + ); +} + +#[test] +fn no_precedents_leaves_selection_untouched() { + let mut h = harness(chain()); + h.state_mut().session.execute("selection.set", json!({"range": "A1"})).unwrap(); + key(&mut h, Key::OpenBracket, Modifiers::CTRL); + assert_eq!(active(&h), "A1", "a plain entry cell has no precedents to select"); +} + +#[test] +fn cross_sheet_precedents_jump_to_the_other_sheet() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.insertSheet", json!({"name": "Src"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + s.execute("cell.set", json!({"cell": "Src!B2", "input": "42"})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "=Src!B2*2"})).unwrap(); + // Back on the first sheet, Ctrl+[ from A1 jumps to Src!B2. + let d = s.active().unwrap(); + let first = d.wb.active_sheet; + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + let mut h = harness(s); + key(&mut h, Key::OpenBracket, Modifiers::CTRL); + let d = h.state().session.active().unwrap(); + assert_ne!(d.wb.active_sheet, first, "selection jumped to the precedent's sheet"); + assert_eq!(d.selection.active, CellRef { row: 1, col: 1 }, "B2 on the Src sheet"); +} From 3df5e05f0f8844b64afef0298d31308e03149530 Mon Sep 17 00:00:00 2001 From: Camilo Agudelo Date: Mon, 19 Oct 2026 11:40:32 -0500 Subject: [PATCH 09/39] In-cell editor: measure wrapped text at the field's own wrap width (#1970) With Frame::NONE the text field takes no text margin off its width, so it wraps at the editor's width less the 2 px inset a side. Measuring 8 px narrower counted rows the field doesn't have and made the editor reach over the cells below. A test pins the field's wrap width. Review feedback on #1974. --- crates/ui-egui/src/grid.rs | 5 ++-- crates/ui-egui/tests/in_cell_editor_wrap.rs | 27 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 6cd49691c..85aaa54b3 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2712,8 +2712,9 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, // Otherwise it widens to the text's one-line width. let wrap = st.align.wrap; let (w, h) = if wrap { - // The field's own margins (4 px a side) and the 2 px inset leave this much for the text. - let wrapped = ui.painter().layout(ed.text.clone(), font.clone(), Color32::BLACK, (rect.width() - 12.0).max(1.0)); + // The field wraps at the width it is given (the editor's less its 2 px inset a side): its + // frame is `Frame::NONE`, so no text margin is taken off. + let wrapped = ui.painter().layout(ed.text.clone(), font.clone(), Color32::BLACK, (rect.width() - 4.0).max(1.0)); (rect.width(), (wrapped.size().y.max(size * 1.3 * lines) + 4.0).max(rect.height())) } else { let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x; diff --git a/crates/ui-egui/tests/in_cell_editor_wrap.rs b/crates/ui-egui/tests/in_cell_editor_wrap.rs index 7ebbf79ea..a60d88fd8 100644 --- a/crates/ui-egui/tests/in_cell_editor_wrap.rs +++ b/crates/ui-egui/tests/in_cell_editor_wrap.rs @@ -57,3 +57,30 @@ fn a_short_wrap_text_edit_stays_one_line_tall() { let field = ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect; assert!(field.height() < 30.0, "a one-line edit doesn't reach over the rows below: {field:?}"); } + +/// The editor is sized from the text wrapped where the field wraps it: the editor's width less its +/// 2 px inset a side, with no text margin (the field has no frame). Measured narrower, the editor +/// would count rows the field doesn't have and reach over the cells below. +#[test] +fn the_editor_measures_the_text_at_the_fields_wrap_width() { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + session.execute("home.columnWidth", json!({"cols": "A:A", "chars": 12})).unwrap(); + session.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some(TEXT.into()), false); + frames(&ctx, &mut app); + let field = ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect; + let sheet = app.session.active().unwrap().wb.active().unwrap().clone(); + let st = app.session.active().unwrap().wb.styles.get(sheet.style_id(gridcraft_engine::cell(0, 0))).clone(); + // The grid draws at its zoom: the cell is the field plus the 2 px inset a side, the font scales. + let z = (field.width() + 4.0) / sheet.col_width(0); + let font = egui::FontId::new(st.font.size * 96.0 / 72.0 * z, gridcraft_ui_egui::theme::cell_family(&st.font.name, st.font.bold, st.font.italic)); + let at = + |w: f32| ctx.fonts_mut(|f| f.layout_job(egui::text::LayoutJob::simple(TEXT.into(), font.clone(), egui::Color32::PLACEHOLDER, w)).size().y); + let (own, with_margin) = (at(field.width()), at(field.width() - 8.0)); + assert!(own != with_margin, "the test text must wrap differently 8 px narrower"); + assert!((field.height() - own).abs() < 1.0, "the field wraps at its whole width, no text margin: {} vs {own}", field.height()); +} From d19627fc269ec982badaebb1493fb9bfe4a4bf3e Mon Sep 17 00:00:00 2001 From: bfenty <1398236+bfenty@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:54:45 +0000 Subject: [PATCH 10/39] fix(engine): walk stored cells in transitive precedent selection Bound the all-level walk to the sheet sparse cell store instead of iterating every position of each precedent area: formulas late in a wide area are no longer skipped, blank positions are never selected, and only formula cells extend the frontier. Adds a regression test (=SUM(A1:Z1) with a formula at Z1) and silences two clippy lints in the same function. Co-Authored-By: Claude --- crates/engine/src/cmd/formulas.rs | 20 +++++++++++++------- crates/ui-egui/tests/trace_navigation.rs | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 0607901ff..c1287f988 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -1,6 +1,6 @@ //! Formulas tab: AutoSum, Insert Function, names, auditing, calculation. -use gridcraft_core::{CellRef, RangeRef}; +use gridcraft_core::{CellRef, RangeRef, Value}; use gridcraft_model::{CalcMode, DefinedName, Formula}; use serde_json::{Value as Json, json}; @@ -485,7 +485,6 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result { /// another sheet, jump there. `all` walks every level (Ctrl+Shift+[ / Ctrl+Shift+]). fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result { use std::collections::BTreeSet; - const CAP: usize = 4096; let d = s.doc()?; let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let sheet = d.wb.active_sheet; @@ -495,15 +494,23 @@ fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result = BTreeSet::new(); let mut frontier = vec![(sheet, at)]; seen.insert((sheet, at)); - loop { - let Some((ks, kc)) = frontier.pop() else { break }; + while let Some((ks, kc)) = frontier.pop() { if precedents { for a in d.calc.graph.precedents_of((ks, kc)) { if all { - for c in a.range.iter().take(CAP) { + // Walk the sheet's stored cells inside the precedent area: blanks are + // never selected and only formula cells extend the frontier, so a + // formula late in a large range is not skipped. + let Some(sh) = d.wb.sheet(a.sheet) else { continue }; + for (c, cell) in sh.cells.iter() { + if !a.range.contains(c) || matches!(cell.value, Value::Empty) { + continue; + } if seen.insert((a.sheet, c)) { found.insert((a.sheet, c.a1())); - frontier.push((a.sheet, c)); + if cell.formula.is_some() { + frontier.push((a.sheet, c)); + } } } } else { @@ -538,7 +545,6 @@ fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result Date: Mon, 19 Oct 2026 12:02:48 -0500 Subject: [PATCH 11/39] In-cell editor: test that an unwrapped multiline edit shows every line (#1970) desired_rows(1) only sets the field's minimum height; it grows with its text. Pins that an Alt+Enter edit in a cell without Wrap Text shows all of its lines and reserves no more. Review feedback on #1974. --- crates/ui-egui/tests/in_cell_editor_wrap.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/ui-egui/tests/in_cell_editor_wrap.rs b/crates/ui-egui/tests/in_cell_editor_wrap.rs index a60d88fd8..76fd9a749 100644 --- a/crates/ui-egui/tests/in_cell_editor_wrap.rs +++ b/crates/ui-egui/tests/in_cell_editor_wrap.rs @@ -84,3 +84,23 @@ fn the_editor_measures_the_text_at_the_fields_wrap_width() { assert!(own != with_margin, "the test text must wrap differently 8 px narrower"); assert!((field.height() - own).abs() < 1.0, "the field wraps at its whole width, no text margin: {} vs {own}", field.height()); } + +/// An edit with line breaks (Alt+Enter) in a cell without Wrap Text shows every line: the field's +/// one row is a minimum, it grows with the text and never scrolls. +#[test] +fn an_unwrapped_multiline_edit_shows_every_line() { + let field = |text: &str| { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some(text.into()), false); + frames(&ctx, &mut app); + ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect + }; + let one = field("one").height(); + let three = field("one\ntwo\nthree").height(); + assert!(three > 2.5 * one, "three lines are shown: {three} vs one line {one}"); + assert!(three < 3.5 * one, "and no more rows are reserved: {three} vs one line {one}"); +} From e34b0aa345dacf9bb2b063b0c4704f6ae7ea0e05 Mon Sep 17 00:00:00 2001 From: bfenty <1398236+bfenty@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:07:18 +0000 Subject: [PATCH 12/39] fix(ui): persist the AutoSave preference across restarts Closes #1901 Co-Authored-By: Claude --- crates/ui-egui/src/backstage.rs | 4 ++-- crates/ui-egui/src/grid.rs | 10 --------- crates/ui-egui/src/lib.rs | 12 ++++++++++- crates/ui-egui/src/ribbon.rs | 4 ++-- crates/ui-egui/tests/autosave_persist.rs | 26 ++++++++++++++++++++++++ 5 files changed, 41 insertions(+), 15 deletions(-) create mode 100644 crates/ui-egui/tests/autosave_persist.rs diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 922baabc5..9a61d5b73 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -969,9 +969,9 @@ fn options(app: &mut SheetApp, ui: &mut Ui) { } ui.add_space(18.0); section(ui, lang, "Options|Save"); - let mut autosave = app.grid.autosave(); + let mut autosave = app.ui.autosave; if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() { - app.grid.toggle_autosave(); + app.toggle_autosave(); } let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return }; ui.add_space(18.0); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 106bc6684..f10d83430 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -119,7 +119,6 @@ pub struct GridState { pub glide: crate::selection_glide::SelectionGlide, pub last_click: Option<(CellRef, f64)>, pub hover_cell: Option, - pub autosave: bool, pub last_autosave: f64, pub last_border: String, pub last_fill: String, @@ -179,15 +178,6 @@ impl WheelZoom { } } -impl GridState { - pub fn autosave(&self) -> bool { - self.autosave - } - pub fn toggle_autosave(&mut self) { - self.autosave = !self.autosave; - } -} - /// Geometry of the grid for one frame (handles zoom and frozen panes). #[derive(Clone)] pub struct Geo { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f549aec2b..977e4b26b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -60,6 +60,9 @@ pub struct UiState { /// Enable AutoComplete for cell values (File ▸ Options ▸ Editing): typed text is completed /// from the one matching entry in the same column. pub autocomplete: bool, + /// AutoSave (the title-bar switch): save saved-to-file .xlsx workbooks shortly after every + /// change. A preference like the rest of this struct — it stays on across restarts (#1901). + pub autosave: bool, pub recent: Vec, /// One-shot keytip request for a ribbon control: set when a keytip action resolves to /// [`keytips::Action::Control`], consumed (and cleared) by the control's render site. @@ -103,6 +106,7 @@ impl Default for UiState { status_bar: true, animations: true, autocomplete: true, + autosave: false, recent: vec![], keytip_invoke: None, language: i18n::Language::En, @@ -1128,6 +1132,12 @@ impl SheetApp { } } + /// Flip AutoSave (title-bar switch / Options ▸ Save). Persisted with the other UI + /// preferences, so it stays as the user left it across restarts (#1901). + pub fn toggle_autosave(&mut self) { + self.ui.autosave = !self.ui.autosave; + } + /// Lays out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); @@ -1190,7 +1200,7 @@ impl SheetApp { } self.perf.last = now; // AutoSave: save workbooks that have a file shortly after they change. - if self.grid.autosave + if self.ui.autosave && self.editor.is_none() && self.text_box_editor.is_none() && now - self.grid.last_autosave > 2000.0 diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index a0ab5a339..ebe8d66a1 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -53,14 +53,14 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut 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)); - let on = app.grid.autosave(); + let on = app.ui.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() { - app.grid.toggle_autosave(); + app.toggle_autosave(); } ui.add_space(6.0); let __k = icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, ch)); diff --git a/crates/ui-egui/tests/autosave_persist.rs b/crates/ui-egui/tests/autosave_persist.rs new file mode 100644 index 000000000..50f3d3b05 --- /dev/null +++ b/crates/ui-egui/tests/autosave_persist.rs @@ -0,0 +1,26 @@ +//! #1901: AutoSave is a preference — it must survive a restart like the other UI settings. + +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; + +#[test] +fn autosave_survives_a_restart() { + let mut app = SheetApp::new(Session::new(), Default::default()); + assert!(!app.ui.autosave, "off by default"); + app.toggle_autosave(); + assert!(app.ui.autosave, "the toggle flips the persisted flag"); + // What the desktop app writes to ui.json, and reads back on the next launch. + let saved = serde_json::to_string(&app.ui).unwrap(); + let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&saved).unwrap(); + assert!(restored.autosave, "a saved ui.json restores AutoSave on (#1901)"); + app.toggle_autosave(); + assert!(!app.ui.autosave, "toggling back persists off too"); +} + +#[test] +fn an_old_ui_json_without_autosave_still_loads() { + // Files saved by earlier versions have no autosave key; serde(default) reads them as off. + let restored: gridcraft_ui_egui::UiState = serde_json::from_str(r#"{"dark": true}"#).unwrap(); + assert!(!restored.autosave); + assert!(restored.dark, "the rest of the old file still applies"); +} From ac371be50de68e0544b9d667b74ba30ed32144c9 Mon Sep 17 00:00:00 2001 From: Maciej Malinowski <231036771+webglobecontact-ui@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:15:00 +0200 Subject: [PATCH 13/39] calc: LET and LAMBDA names ignore case in any script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Excel matches LET and LAMBDA names case-insensitively in every script, so =LET(dług,100,DŁUG*2) is 200. The evaluator compared them ASCII-only and returned #NAME?. The three lookups (name evaluation, ISOMITTED, a LET variable shadowing a function name) now fold Unicode case. --- crates/calc/src/eval.rs | 11 ++++++++--- crates/calc/src/tests.rs | 13 +++++++++++++ docs/function-parity.md | 3 ++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 0f9cfb03d..da15977fd 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -256,7 +256,7 @@ impl<'h> Evaluator<'h> { } fn name(&mut self, n: &str) -> Ev { - if let Some((_, v)) = self.env.iter().rev().find(|(k, _)| k.eq_ignore_ascii_case(n)) { + if let Some((_, v)) = self.env.iter().rev().find(|(k, _)| same_name(k, n)) { return v.clone(); } let wb = self.host.workbook(); @@ -772,7 +772,7 @@ impl<'h> Evaluator<'h> { Ev::L(Arc::new(Lambda { params, body: body.clone(), env: self.env.clone() })) } "ISOMITTED" => { - let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| k.eq_ignore_ascii_case(n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty)))); + let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| same_name(k, n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty)))); Ev::V(Value::Bool(r)) } "MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args), @@ -975,7 +975,7 @@ impl<'h> Evaluator<'h> { /// that name. fn function_by_name(&self, e: &Expr) -> Option { let Expr::Name(n) = e else { return None }; - if self.env.iter().any(|(k, _)| k.eq_ignore_ascii_case(n)) || self.host.workbook().name(n, self.sheet).is_some() { + if self.env.iter().any(|(k, _)| same_name(k, n)) || self.host.workbook().name(n, self.sheet).is_some() { return None; } let u = n.to_ascii_uppercase(); @@ -1868,5 +1868,10 @@ pub fn is_known_function(n: &str) -> bool { is_special(&n.to_ascii_uppercase()) || gridcraft_functions::lookup(n).is_some() } +/// LET and LAMBDA names compare case-insensitively in any script, as in Excel (`dług` = `DŁUG`). +fn same_name(a: &str, b: &str) -> bool { + a.chars().flat_map(char::to_uppercase).eq(b.chars().flat_map(char::to_uppercase)) +} + #[allow(dead_code)] fn unused(_: RefKind) {} diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 97f7a8350..2910e96a1 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -590,6 +590,19 @@ fn let_lambda() { assert_eq!(t.num("A4"), 14.0); } +#[test] +fn let_lambda_names_ignore_case_in_any_script() { + let mut t = T::new(); + t.set("A1", "=LET(dług,100,DŁUG*2)"); + assert_eq!(t.num("A1"), 200.0); + t.set("A3", "=LAMBDA(ćma,ĆMA+1)(1)"); + assert_eq!(t.num("A3"), 2.0); + t.set("A4", "=LET(ŻAR,LAMBDA(x,x*3),żar(2))"); + assert_eq!(t.num("A4"), 6.0); + t.set("A5", "=LET(λ,LAMBDA(x,x+1),SUM(MAP({1,2},Λ)))"); + assert_eq!(t.num("A5"), 5.0); +} + #[test] fn functions_given_by_name() { let mut t = T::new(); diff --git a/docs/function-parity.md b/docs/function-parity.md index 2343e0de9..10588e94d 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 (LET and LAMBDA names ignore case in any script) · **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). @@ -114,6 +114,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | LET and LAMBDA names compare case-insensitively in any script, as in Excel: `=LET(dług,100,DŁUG*2)` is 200 (was #NAME?); also LET-bound lambdas called or passed by name (`żar(2)`, `MAP(…,Λ)`) | | 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-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) | From e9bcb8dfbba14fc1494a6911fe9dac7e6b9a5ed4 Mon Sep 17 00:00:00 2001 From: Maciej Malinowski <231036771+webglobecontact-ui@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:46:10 +0200 Subject: [PATCH 14/39] calc: one shared same_name for LET/LAMBDA names, with case folding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gridcraft_formula::same_name lower-cases then upper-cases each character, so ß matches ẞ (to_uppercase alone maps ß to SS). - Formula-bar input uses it too, so a binding such as ſi is not translated to IF while the evaluator binds it. - Tests: ISOMITTED with a case variant, ß/ẞ, and the Spanish input case. --- crates/calc/src/eval.rs | 11 +++-------- crates/calc/src/tests.rs | 5 +++++ crates/formula/src/input.rs | 2 +- crates/formula/src/lib.rs | 8 ++++++++ crates/formula/tests/spanish_input.rs | 9 +++++++++ docs/function-parity.md | 2 +- 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index da15977fd..c0868ae0b 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -256,7 +256,7 @@ impl<'h> Evaluator<'h> { } fn name(&mut self, n: &str) -> Ev { - if let Some((_, v)) = self.env.iter().rev().find(|(k, _)| same_name(k, n)) { + if let Some((_, v)) = self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)) { return v.clone(); } let wb = self.host.workbook(); @@ -772,7 +772,7 @@ impl<'h> Evaluator<'h> { Ev::L(Arc::new(Lambda { params, body: body.clone(), env: self.env.clone() })) } "ISOMITTED" => { - let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| same_name(k, n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty)))); + let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty)))); Ev::V(Value::Bool(r)) } "MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args), @@ -975,7 +975,7 @@ impl<'h> Evaluator<'h> { /// that name. fn function_by_name(&self, e: &Expr) -> Option { let Expr::Name(n) = e else { return None }; - if self.env.iter().any(|(k, _)| same_name(k, n)) || self.host.workbook().name(n, self.sheet).is_some() { + if self.env.iter().any(|(k, _)| gridcraft_formula::same_name(k, n)) || self.host.workbook().name(n, self.sheet).is_some() { return None; } let u = n.to_ascii_uppercase(); @@ -1868,10 +1868,5 @@ pub fn is_known_function(n: &str) -> bool { is_special(&n.to_ascii_uppercase()) || gridcraft_functions::lookup(n).is_some() } -/// LET and LAMBDA names compare case-insensitively in any script, as in Excel (`dług` = `DŁUG`). -fn same_name(a: &str, b: &str) -> bool { - a.chars().flat_map(char::to_uppercase).eq(b.chars().flat_map(char::to_uppercase)) -} - #[allow(dead_code)] fn unused(_: RefKind) {} diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 2910e96a1..19b50ca3d 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -595,12 +595,17 @@ fn let_lambda_names_ignore_case_in_any_script() { let mut t = T::new(); t.set("A1", "=LET(dług,100,DŁUG*2)"); assert_eq!(t.num("A1"), 200.0); + t.set("A2", "=LAMBDA(λ,ISOMITTED(Λ))()"); + assert_eq!(t.get("A2"), Value::Bool(true)); t.set("A3", "=LAMBDA(ćma,ĆMA+1)(1)"); assert_eq!(t.num("A3"), 2.0); t.set("A4", "=LET(ŻAR,LAMBDA(x,x*3),żar(2))"); assert_eq!(t.num("A4"), 6.0); t.set("A5", "=LET(λ,LAMBDA(x,x+1),SUM(MAP({1,2},Λ)))"); assert_eq!(t.num("A5"), 5.0); + // `ß` upper-cases to `SS`, yet `ẞ` is its capital. + t.set("A6", "=LET(ß,100,ẞ*2)"); + assert_eq!(t.num("A6"), 200.0); } #[test] diff --git a/crates/formula/src/input.rs b/crates/formula/src/input.rs index 8568023ed..5c54bb7de 100644 --- a/crates/formula/src/input.rs +++ b/crates/formula/src/input.rs @@ -128,7 +128,7 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut if depth > parser::MAX_DEPTH { return Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 }); } - let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| n.eq_ignore_ascii_case(name)) || known_name(name); + let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| crate::same_name(n, name)) || known_name(name); match expr { Expr::Bool(value) if display => { let alias = if *value { "VERDADERO" } else { "FALSO" }; diff --git a/crates/formula/src/lib.rs b/crates/formula/src/lib.rs index 88d09a8af..5303c73c0 100644 --- a/crates/formula/src/lib.rs +++ b/crates/formula/src/lib.rs @@ -15,5 +15,13 @@ pub use input::{FormulaLocale, parse_input, print_input}; pub use parser::{MAX_FORMULA_LEN, ParseError, normalize_function_name, parse}; pub use printer::{print, print_r1c1, quote_sheet}; +/// Whether two LET/LAMBDA names are the same: case-insensitive in any script, as in Excel +/// (`dług` = `DŁUG`, `ß` = `ẞ`). Each character is lower-cased, then upper-cased, so letters whose +/// upper case is not one character (`ß` -> `SS`) still match their capital. +pub fn same_name(a: &str, b: &str) -> bool { + let fold = |s: &str| s.chars().flat_map(char::to_lowercase).flat_map(char::to_uppercase).collect::(); + a == b || fold(a) == fold(b) +} + #[cfg(test)] mod tests; diff --git a/crates/formula/tests/spanish_input.rs b/crates/formula/tests/spanish_input.rs index fee688a86..5e5677665 100644 --- a/crates/formula/tests/spanish_input.rs +++ b/crates/formula/tests/spanish_input.rs @@ -98,3 +98,12 @@ fn spanish_display_round_trips_text_containing_separators() { assert_eq!(shown, r#"SI(A1="a,b;c";CONCAT("1.5";",";";");SUMA(0,25;A1:A3 B2))"#); assert_eq!(print(&parse_input(&shown, es, |_| false).unwrap()), print(&expr)); } + +#[test] +fn local_names_match_aliases_in_any_case_like_the_evaluator() { + // `ſi` (long s) is `SI` case-insensitively, so the call is the LAMBDA, not IF. + assert_eq!(input("LET(ſi;LAMBDA(x;x*2);SI(3))"), "LET(ſi,LAMBDA(x,x*2),SI(3))"); + assert!(gridcraft_formula::same_name("ß", "ẞ")); + assert!(gridcraft_formula::same_name("dług", "DŁUG")); + assert!(!gridcraft_formula::same_name("a", "b")); +} diff --git a/docs/function-parity.md b/docs/function-parity.md index 10588e94d..a8e223356 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -114,7 +114,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| -| 2026-10-19 | minor | LET and LAMBDA names compare case-insensitively in any script, as in Excel: `=LET(dług,100,DŁUG*2)` is 200 (was #NAME?); also LET-bound lambdas called or passed by name (`żar(2)`, `MAP(…,Λ)`) | +| 2026-10-19 | minor | LET and LAMBDA names compare case-insensitively in any script, as in Excel: `=LET(dług,100,DŁUG*2)` is 200 (was #NAME?); also LET-bound lambdas called or passed by name (`żar(2)`, `MAP(…,Λ)`), `ISOMITTED`, and formula-bar alias translation (one shared `gridcraft_formula::same_name`, which also matches `ß` with `ẞ`) | | 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-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) | From 871c4e6c8f9082fa616ab620bd39b24348802374 Mon Sep 17 00:00:00 2001 From: bfenty <1398236+bfenty@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:47:07 +0000 Subject: [PATCH 15/39] perf(engine): scan only the precedent area's stored cells iter_range walks the sparse store's bands for the area's row range only, so a chain of N formulas no longer rescans every stored cell of the sheet per area. Co-Authored-By: Claude --- crates/engine/src/cmd/formulas.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index c1287f988..b9fd86a59 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -498,12 +498,13 @@ fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result Date: Mon, 19 Oct 2026 21:49:10 +0200 Subject: [PATCH 16/39] ui: language command docs and error list every language app.language.set's engine.commands docs and its unknown-language error were written by hand and had fallen behind: the docs missed es, fr and sr, the error missed de, fr and sr. Both are now built from Language::ALL (Language::code_list), and tests check every language is listed in each. --- crates/ui-egui/src/control.rs | 18 +++++++++++++++++- crates/ui-egui/src/i18n.rs | 10 ++++++++++ crates/ui-egui/src/lib.rs | 2 +- docs/control-protocol.md | 5 +++-- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2ea5cfae0..c8016f34b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -175,6 +175,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - } "engine.commands" => { let mut v: Vec = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); + let language_params = format!("{{language: {}}} (alias: code)", crate::i18n::Language::code_list("|")); for (id, label, params) in [ ("ui.ribbonTab", "Ribbon Tab", ""), ("view.collapseRibbon", "Collapse Ribbon", ""), @@ -195,7 +196,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - "Submit Print Job", "existing file.print sheet/pagination params → {printing,bytes,pending}; submits without opening a dialog", ), - ("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"|\"de\"|\"cs\"} (alias: code)"), + ("app.language.set", "Interface Language", language_params.as_str()), ("app.language.english", "Interface Language: English", ""), ("app.language.japanese", "Interface Language: Japanese", ""), ] { @@ -530,4 +531,19 @@ mod tests { assert!(!app.printing.busy(), "discovery must not submit a job"); assert!(app.backstage.is_none(), "discovery must not open settings"); } + + #[test] + fn language_command_docs_list_every_language() { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, crate::Services::default()); + let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search":"language"})) else { + panic!("command discovery must return immediately"); + }; + let commands = reply["result"].as_array().unwrap(); + let docs = commands.iter().find(|c| c["id"] == "app.language.set").unwrap()["params"].as_str().unwrap(); + for l in crate::i18n::Language::ALL { + assert!(docs.contains(&format!("\"{}\"", l.code())), "app.language.set docs miss {:?}: {docs}", l.code()); + } + } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 98919f8a4..7edd086bd 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -78,6 +78,12 @@ impl Language { Self::from_tag(code) } + /// Every code `app.language.set` takes, quoted and joined (`"en"|"es"|…`), for command docs + /// and errors. + pub fn code_list(separator: &str) -> String { + Self::ALL.iter().map(|l| format!("\"{}\"", l.code())).collect::>().join(separator) + } + fn from_tag(tag: &str) -> Option { match tag.trim().split(['-', '_']).next()?.to_ascii_lowercase().as_str() { "en" => Some(Self::En), @@ -761,6 +767,10 @@ mod tests { app.run("app.language.set", serde_json::json!({"code": "ja"})).unwrap(); assert_eq!(app.ui.language, Language::Ja, "`code` is an alias for `language`"); assert!(app.run("app.language.set", serde_json::json!({})).is_err(), "no language is an error, not a dialog"); + let unknown = app.run("app.language.set", serde_json::json!({"language": "xx"})).unwrap_err().to_string(); + for l in Language::ALL { + assert!(unknown.contains(&format!("\"{}\"", l.code())), "the error lists {:?}: {unknown}", l.code()); + } app.run("app.language.english", serde_json::json!({})).unwrap(); assert_eq!(app.ui.language, Language::En); for l in Language::ALL { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f549aec2b..e912d15ce 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -654,7 +654,7 @@ impl SheetApp { self.ui.language = l; Ok(json!({"language": l})) } - None => Err(format!("unknown language {code:?} (use \"en\", \"es\", \"zh\", \"ja\", \"ko\", \"ru\", \"pt\" or \"cs\")")), + None => Err(format!("unknown language {code:?} (use one of {})", i18n::Language::code_list(", "))), } } "app.language.english" => { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e82ae7299..af73ca515 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -56,8 +56,9 @@ the View ▸ Animations checkbox, saved with the other UI preferences and on by `{command: "app.language.set", params: {language: "ja"}}` switches the interface language (`code` is accepted as an alias for `language`); `app.language.english` and -`app.language.japanese` are shortcuts. Only interface chrome is translated, never command ids or -document text. The choice is saved in `ui.json`; with nothing saved the desktop app follows the +`app.language.japanese` are shortcuts. `engine.commands` lists every code it takes (`en`, `es`, +`zh`, `ja`, `ko`, `ru`, `pt`, `de`, `cs`, `fr`, `sr`), as does the error for an unknown one. Only +interface chrome is translated, never command ids or document text. The choice is saved in `ui.json`; with nothing saved the desktop app follows the system language. `{command: "file.open", params: {path: "Secret.xlsx", password: "…"}}` opens a password-protected From a74e82ac939b181982b4f81639b25bc05608daf2 Mon Sep 17 00:00:00 2001 From: Maciej Malinowski <231036771+webglobecontact-ui@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:27:21 +0200 Subject: [PATCH 17/39] =?UTF-8?q?formula:=20same=5Fname=20folds=20case=20w?= =?UTF-8?q?ithout=20merging=20=C4=B1=20and=20i?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lower-casing then upper-casing made dotless ı equal to i (both become I), which Unicode's default case folding keeps apart; LET(i,1,ı,2,i) could return the ı binding. same_name now lower-cases and adds the folds lower case misses (ß/ẞ -> ss, ſ -> s, ς -> σ). Tests cover ı/i and the Greek final sigma. --- crates/calc/src/tests.rs | 3 +++ crates/formula/src/lib.rs | 17 ++++++++++++++--- crates/formula/tests/spanish_input.rs | 5 +++++ docs/function-parity.md | 2 +- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 19b50ca3d..0f853e2c7 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -606,6 +606,9 @@ fn let_lambda_names_ignore_case_in_any_script() { // `ß` upper-cases to `SS`, yet `ẞ` is its capital. t.set("A6", "=LET(ß,100,ẞ*2)"); assert_eq!(t.num("A6"), 200.0); + // Dotless ı is a different name from i. + t.set("A7", "=LET(i,1,ı,2,i)"); + assert_eq!(t.num("A7"), 1.0); } #[test] diff --git a/crates/formula/src/lib.rs b/crates/formula/src/lib.rs index 5303c73c0..77f4e5899 100644 --- a/crates/formula/src/lib.rs +++ b/crates/formula/src/lib.rs @@ -16,10 +16,21 @@ pub use parser::{MAX_FORMULA_LEN, ParseError, normalize_function_name, parse}; pub use printer::{print, print_r1c1, quote_sheet}; /// Whether two LET/LAMBDA names are the same: case-insensitive in any script, as in Excel -/// (`dług` = `DŁUG`, `ß` = `ẞ`). Each character is lower-cased, then upper-cased, so letters whose -/// upper case is not one character (`ß` -> `SS`) still match their capital. +/// (`dług` = `DŁUG`, `ß` = `ẞ`). Close to Unicode's default case folding: lower case, plus the +/// folds lower-casing misses for letters a name may hold (`ß`/`ẞ` -> `ss`, `ſ` -> `s`, `ς` -> `σ`). +/// Dotless `ı` stays distinct from `i`, as outside Turkic languages. pub fn same_name(a: &str, b: &str) -> bool { - let fold = |s: &str| s.chars().flat_map(char::to_lowercase).flat_map(char::to_uppercase).collect::(); + let fold = |s: &str| -> String { + s.chars() + .flat_map(char::to_lowercase) + .flat_map(|c| match c { + 'ß' => ['s', 's'].into_iter().take(2), + 'ſ' => ['s', 's'].into_iter().take(1), + 'ς' => ['σ', 'σ'].into_iter().take(1), + c => [c, c].into_iter().take(1), + }) + .collect() + }; a == b || fold(a) == fold(b) } diff --git a/crates/formula/tests/spanish_input.rs b/crates/formula/tests/spanish_input.rs index 5e5677665..d04f7da20 100644 --- a/crates/formula/tests/spanish_input.rs +++ b/crates/formula/tests/spanish_input.rs @@ -105,5 +105,10 @@ fn local_names_match_aliases_in_any_case_like_the_evaluator() { assert_eq!(input("LET(ſi;LAMBDA(x;x*2);SI(3))"), "LET(ſi,LAMBDA(x,x*2),SI(3))"); assert!(gridcraft_formula::same_name("ß", "ẞ")); assert!(gridcraft_formula::same_name("dług", "DŁUG")); + assert!(gridcraft_formula::same_name("ſi", "SI")); + assert!(gridcraft_formula::same_name("σοφός", "ΣΟΦΌΣ")); + // Dotless ı is its own letter outside Turkic languages. + assert!(!gridcraft_formula::same_name("ı", "i")); + assert!(!gridcraft_formula::same_name("ı", "I")); assert!(!gridcraft_formula::same_name("a", "b")); } diff --git a/docs/function-parity.md b/docs/function-parity.md index a8e223356..6c841a2be 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -114,7 +114,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| -| 2026-10-19 | minor | LET and LAMBDA names compare case-insensitively in any script, as in Excel: `=LET(dług,100,DŁUG*2)` is 200 (was #NAME?); also LET-bound lambdas called or passed by name (`żar(2)`, `MAP(…,Λ)`), `ISOMITTED`, and formula-bar alias translation (one shared `gridcraft_formula::same_name`, which also matches `ß` with `ẞ`) | +| 2026-10-19 | minor | LET and LAMBDA names compare case-insensitively in any script, as in Excel: `=LET(dług,100,DŁUG*2)` is 200 (was #NAME?); also LET-bound lambdas called or passed by name (`żar(2)`, `MAP(…,Λ)`), `ISOMITTED`, and formula-bar alias translation (one shared `gridcraft_formula::same_name`, which matches `ß` with `ẞ` and keeps dotless `ı` apart from `i`) | | 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-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) | From 026239458a146592c5b85f3d136f200e9e5f0409 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:27:34 +0200 Subject: [PATCH 18/39] Formula editing: arrow pointing restarts from the edited cell after typing (#1949) --- crates/ui-egui/src/formula_bar.rs | 2 ++ crates/ui-egui/tests/selection.rs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 7f1ad87fc..3a5d3afee 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -319,6 +319,8 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ed.completion = crate::editor::column_completion(sh, ed.cell, &ed.text); } ed.point = None; + // Typing ends the pointing: the next arrow starts again from the edited cell, as in Excel. + ed.point_cell = None; let names: Vec = function_names() .iter() .map(|name| { diff --git a/crates/ui-egui/tests/selection.rs b/crates/ui-egui/tests/selection.rs index faebebf00..297870808 100644 --- a/crates/ui-egui/tests/selection.rs +++ b/crates/ui-egui/tests/selection.rs @@ -159,3 +159,20 @@ fn formula_point_drag_keeps_the_sheet_qualifier() { h.state_mut().session.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); assert_eq!(cells(&h), before); } + +#[test] +fn formula_arrow_pointing_restarts_from_the_edited_cell_after_typing() { + let mut h = harness("B4"); + h.state_mut().begin_edit(Some("=".into()), false); + h.run_steps(2); + h.key_press(egui::Key::ArrowUp); + h.run_steps(1); + h.key_press(egui::Key::ArrowUp); + h.run_steps(1); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=B2"); + frame(&mut h, Event::Text("+".into())); + h.run_steps(1); + h.key_press(egui::Key::ArrowUp); + h.run_steps(1); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=B2+B3"); +} From 2aeb21d4d3494e85af0caf0e417cf01201367c52 Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Wed, 14 Oct 2026 20:20:27 -0500 Subject: [PATCH 19/39] Formulas: flat operator chains as long as Excel allows (#916) along: =1+1+... with 4,096 operands (8,192 characters, exactly Excel's limit, which Excel accepts and calculates) overflowed the stack when entered, because the derived Clone of Expr recursed once per operand down the chain's left spine. Saving hit the same in the XLSX writer. - Expr: Clone, PartialEq, walk and map follow the left spine in a loop and recurse only into right operands and real nesting. - XLSX: _xlpm. prefixing and the implicit-intersection scan follow the spine too. The scan's depth limit of 64 wrote any chain of more than about 64 operands as a dynamic-array formula; such sums are plain formulas again (ranges in the chain still get array evaluation). - Evaluate Formula borrows sub-expressions instead of cloning each operator's subtree (quadratic memory on a long chain). - Spanish formula entry and display no longer refuse chains of more than 256 operands; the left spine does not count as nesting. Tests run on a 1 MiB stack (the Windows main thread) with Excel's observed results: formula (clone/eq/walk/map/adjust at 4,096), calc (401 and 4,096 operands, -, *, /, ^, &, %, comparisons, 1,500 refs), xlsx (long chains written as scalar formulas), engine (cell.set, fill, copy/paste, insert rows/columns, undo/redo, Evaluate Formula, XLSX save and reopen), Spanish entry. Each fails without the fix. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/calc/src/tests.rs | 35 ++++++ crates/engine/src/cmd/formulas.rs | 8 +- crates/engine/tests/long-operator-chains.rs | 87 ++++++++++++++ crates/formula/src/ast.rs | 124 +++++++++++++++++--- crates/formula/src/input.rs | 19 ++- crates/formula/src/tests.rs | 34 ++++++ crates/formula/tests/spanish_input.rs | 31 ++++- crates/xlsx/src/array_formula.rs | 30 ++++- crates/xlsx/src/fmla.rs | 16 ++- crates/xlsx/src/tests/array_export.rs | 25 ++++ docs/function-parity.md | 4 +- 12 files changed, 375 insertions(+), 42 deletions(-) create mode 100644 crates/engine/tests/long-operator-chains.rs diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..2ec3b5a99 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 (long flat operator chains up to Excel's formula limit) · **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 | Long flat operator chains (`=A1+A2+…`) up to Excel's 8,192-character limit (4,096 operands): entering, filling, inserting rows, undo, Evaluate Formula and XLSX save no longer overflow the stack; chains past 64 operands are saved as plain formulas, not dynamic arrays; Spanish formula entry accepts chains past 256 operands (#916) | | 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 | Progress log: long flat operator chains (#916) | | 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/calc/src/tests.rs b/crates/calc/src/tests.rs index 97f7a8350..5f843f66e 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -702,6 +702,41 @@ fn long_operator_chains() { assert_eq!(t.num("B2"), 5.0); } +#[test] +fn flat_chains_as_long_as_excel_allows() { + // Values observed in Excel (#916): a flat chain has no operand limit below the 8,192-character + // formula limit, which `=1+1+…` with 4,096 operands meets exactly. On a 1 MiB stack, as on + // the Windows main thread. + std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(|| { + let chain = |op: &str, item: &str, n: usize| format!("={}", vec![item; n].join(op)); + let mut t = T::new(); + for r in 1..=1500 { + t.set(&format!("A{r}"), "1"); + } + t.set("B1", &chain("+", "1", 401)); + t.set("B2", &chain("+", "1", 4096)); + t.set("B3", &chain("-", "1", 2000)); + t.set("B4", &chain("*", "1", 4096)); + t.set("B5", &chain("/", "2", 4096)); + t.set("B6", &chain("^", "1", 4096)); + t.set("B7", &chain("+", "1%", 2000)); + t.set("B8", "=1=1=1"); + t.set("B9", &format!("={}", (1..=1500).map(|r| format!("A{r}")).collect::>().join("+"))); + t.set("B10", &chain("&", "1", 2000)); + for (at, want) in [("B1", 401.0), ("B2", 4096.0), ("B3", -1998.0), ("B4", 1.0), ("B5", 0.0), ("B6", 1.0), ("B9", 1500.0)] { + assert_eq!(t.num(at), want, "{at}"); + } + assert!((t.num("B7") - 20.0).abs() < 1e-9); + assert_eq!(t.get("B8"), Value::Bool(false)); + assert_eq!(t.get("B10"), Value::text("1".repeat(2000))); + }) + .expect("spawn") + .join() + .expect("long chains must not overflow the stack"); +} + #[test] fn subtotal_skips_hidden() { let mut t = T::new(); diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 454c545d0..eb8b1a1ab 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -528,13 +528,15 @@ fn evaluate_formula(s: &mut Session, p: &Json) -> Result { let mut steps = Vec::new(); let mut nodes = Vec::new(); expr.walk(&mut |e| { + // Borrowed, not cloned: copying every operator of a long chain A1+A2+… copies the + // rest of the chain each time (quadratic memory). if matches!(e, gridcraft_formula::Expr::Call(..) | gridcraft_formula::Expr::Binary(..) | gridcraft_formula::Expr::Ref(_)) { - nodes.push(e.clone()); + nodes.push(e); } }); for e in nodes.into_iter().rev().take(200) { - let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, &e); - steps.push(json!({"expression": gridcraft_formula::print(&e), "value": crate::cmd::inspect::value_json(&v)})); + let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, e); + steps.push(json!({"expression": gridcraft_formula::print(e), "value": crate::cmd::inspect::value_json(&v)})); } let result = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, &expr); Ok(json!({"formula": format!("={text}"), "steps": steps, "result": crate::cmd::inspect::value_json(&result)})) diff --git a/crates/engine/tests/long-operator-chains.rs b/crates/engine/tests/long-operator-chains.rs new file mode 100644 index 000000000..97c95ce38 --- /dev/null +++ b/crates/engine/tests/long-operator-chains.rs @@ -0,0 +1,87 @@ +//! Flat operator chains as long as Excel allows (#916). `=1+1+…` with 4,096 operands is +//! exactly 8,192 characters, Excel's formula limit, and builds a tree 4,095 levels deep on its +//! left. Entering, editing, saving and reopening such a formula must neither overflow the stack +//! nor fall back to an error: Excel computes it. + +use gridcraft_core::{CellRef, Value}; +use gridcraft_engine::{DocState, Session, io}; +use serde_json::json; + +/// Excel's longest flat chain of literals: 4,096 operands, 8,192 characters with the `=`. +fn literal_chain(op: &str) -> String { + format!("={}", vec!["1"; 4096].join(op)) +} + +/// `=A1+A2+…+A1500`: 7,893 characters, inside Excel's limit. +fn reference_chain() -> String { + format!("={}", (1..=1500).map(|r| format!("A{r}")).collect::>().join("+")) +} + +fn value(session: &Session, cell: &str) -> Value { + let at = CellRef::parse(cell).expect("cell"); + session.doc().expect("doc").wb.sheet(0).expect("sheet").value(at) +} + +/// Runs `f` on a 1 MiB stack, the size of the Windows main thread the desktop app runs on. +fn on_small_stack(f: impl FnOnce() + Send + 'static) { + std::thread::Builder::new().stack_size(1 << 20).spawn(f).expect("spawn").join().expect("long chains must not overflow the stack"); +} + +#[test] +fn longest_flat_chains_survive_editing_saving_and_reopening() { + on_small_stack(|| { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1:A1500", "values": vec![[1]; 1500]})).expect("values"); + session.execute("cell.set", json!({"cell": "C1", "input": literal_chain("+")})).expect("plus chain"); + session.execute("cell.set", json!({"cell": "C2", "input": literal_chain("-")})).expect("minus chain"); + session.execute("cell.set", json!({"cell": "C3", "input": literal_chain("&")})).expect("concat chain"); + session.execute("cell.set", json!({"cell": "C4", "input": reference_chain()})).expect("reference chain"); + assert_eq!(value(&session, "C1"), Value::Number(4096.0)); + assert_eq!(value(&session, "C2"), Value::Number(-4094.0)); + assert_eq!(value(&session, "C3"), Value::text("1".repeat(4096))); + assert_eq!(value(&session, "C4"), Value::Number(1500.0)); + + // Precedents of a long reference chain still drive recalculation. + session.execute("cell.set", json!({"cell": "A750", "input": "101"})).expect("edit precedent"); + assert_eq!(value(&session, "C4"), Value::Number(1600.0)); + + // Evaluate Formula walks every operator of the chain. + let steps = session.execute("formulas.evaluateFormula", json!({"cell": "C4"})).expect("evaluate formula"); + assert_eq!(steps["steps"].as_array().map(Vec::len), Some(200)); + assert_eq!(steps["result"], json!(1600.0)); + assert_eq!(session.execute("formulas.evaluate", json!({"formula": literal_chain("+")})).expect("evaluate"), json!(4096.0)); + + // Copy and paste move every reference (Excel: the pasted C4 sums A2:A1501). + session.execute("edit.copy", json!({"range": "C4"})).expect("copy"); + session.execute("edit.paste", json!({"at": "C5"})).expect("paste"); + assert_eq!(value(&session, "C5"), Value::Number(1599.0)); + session.execute("edit.undo", json!({})).expect("undo paste"); + + // Fill and structural edits rewrite every reference in the chain (Excel: an inserted + // row above shifts A1:A1500 to A2:A1501; the sum is unchanged). + session.execute("edit.fillRight", json!({"range": "C1:D4"})).expect("fill right"); + assert_eq!(value(&session, "D1"), Value::Number(4096.0)); + assert_eq!(value(&session, "D4"), Value::Number(0.0)); + session.execute("home.insertRows", json!({"rows": "1:1"})).expect("insert row"); + assert_eq!(value(&session, "C5"), Value::Number(1600.0)); + session.execute("home.insertColumns", json!({"cols": "A:A"})).expect("insert column"); + assert_eq!(value(&session, "D5"), Value::Number(1600.0)); + session.execute("edit.undo", json!({})).expect("undo"); + session.execute("edit.undo", json!({})).expect("undo"); + assert_eq!(value(&session, "C4"), Value::Number(1600.0)); + session.execute("edit.redo", json!({})).expect("redo"); + assert_eq!(value(&session, "C5"), Value::Number(1600.0)); + + // XLSX round trip keeps the formulas and their values. + let wb = &session.doc().expect("doc").wb; + let bytes = io::save_bytes(wb, "chains.xlsx").expect("save"); + let (reopened, _) = io::open_bytes("chains.xlsx", &bytes).expect("open"); + let mut session = Session::new(); + session.add_document(DocState::new(reopened, None, "chains".into())); + session.execute("formulas.calculateNow", json!({})).expect("recalc"); + assert_eq!(value(&session, "C2"), Value::Number(4096.0)); + assert_eq!(value(&session, "C3"), Value::Number(-4094.0)); + assert_eq!(value(&session, "C5"), Value::Number(1600.0)); + }); +} diff --git a/crates/formula/src/ast.rs b/crates/formula/src/ast.rs index 9b04140cc..7302b4d04 100644 --- a/crates/formula/src/ast.rs +++ b/crates/formula/src/ast.rs @@ -182,7 +182,13 @@ impl BinOp { } } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +/// A formula's syntax tree. +/// +/// A flat operator chain such as `=A1+A2+…` nests one [`Expr::Binary`] per operator on its +/// left, so a formula as long as Excel allows (8,192 characters) is about 4,000 levels deep. +/// `Clone`, `PartialEq`, [`Expr::walk`] and [`Expr::map`] therefore follow that left spine in a +/// loop and recurse only into right-hand operands and real nesting, which the parser bounds. +#[derive(Debug, Serialize, Deserialize)] pub enum Expr { Number(f64), Text(Arc), @@ -206,36 +212,52 @@ pub enum Expr { } impl Expr { - /// Visits every node depth-first. + /// Visits every node depth-first, parents before children, left to right. pub fn walk<'a>(&'a self, f: &mut dyn FnMut(&'a Expr)) { - f(self); - match self { - Expr::Array(rows) => rows.iter().flatten().for_each(|e| e.walk(f)), - Expr::Unary(_, e) | Expr::Paren(e) => e.walk(f), - Expr::Binary(_, a, b) => { - a.walk(f); - b.walk(f); + // An explicit stack: a long operator chain must not recurse once per operand. + let mut pending = vec![self]; + while let Some(e) = pending.pop() { + f(e); + match e { + Expr::Array(rows) => pending.extend(rows.iter().flatten().rev()), + Expr::Unary(_, e) | Expr::Paren(e) => pending.push(e), + Expr::Binary(_, a, b) => { + pending.push(b); + pending.push(a); + } + Expr::Call(_, args) => pending.extend(args.iter().rev()), + Expr::Invoke(c, args) => { + pending.extend(args.iter().rev()); + pending.push(c); + } + _ => {} } - Expr::Call(_, args) => args.iter().for_each(|e| e.walk(f)), - Expr::Invoke(c, args) => { - c.walk(f); - args.iter().for_each(|e| e.walk(f)); - } - _ => {} } } - /// Rewrites every node bottom-up. + /// Rewrites every node bottom-up, children left to right before their parent. pub fn map(self, f: &mut dyn FnMut(Expr) -> Expr) -> Expr { - let e = match self { + // Unwind the left spine of a long operator chain into a list, then rebuild it from the + // innermost operand outwards, in the order a recursive rewrite would visit the nodes. + let mut spine = Vec::new(); + let mut node = self; + while let Expr::Binary(op, a, b) = node { + spine.push((op, b)); + node = *a; + } + let e = match node { Expr::Array(rows) => Expr::Array(rows.into_iter().map(|r| r.into_iter().map(|e| e.map(f)).collect()).collect()), Expr::Unary(op, e) => Expr::Unary(op, Box::new(e.map(f))), Expr::Paren(e) => Expr::Paren(Box::new(e.map(f))), - Expr::Binary(op, a, b) => Expr::Binary(op, Box::new(a.map(f)), Box::new(b.map(f))), Expr::Call(n, args) => Expr::Call(n, args.into_iter().map(|e| e.map(f)).collect()), Expr::Invoke(c, args) => Expr::Invoke(Box::new(c.map(f)), args.into_iter().map(|e| e.map(f)).collect()), other => other, }; - f(e) + let mut acc = f(e); + while let Some((op, b)) = spine.pop() { + let b = b.map(f); + acc = f(Expr::Binary(op, Box::new(acc), Box::new(b))); + } + acc } /// All references in the formula. pub fn references(&self) -> Vec<&Reference> { @@ -259,6 +281,70 @@ impl Expr { } } +impl Clone for Expr { + fn clone(&self) -> Self { + // Copy the left spine of an operator chain in a loop (see [`Expr`]). + let mut spine = Vec::new(); + let mut node = self; + while let Expr::Binary(op, a, b) = node { + spine.push((*op, b)); + node = a; + } + let mut acc = match node { + Expr::Number(n) => Expr::Number(*n), + Expr::Text(t) => Expr::Text(t.clone()), + Expr::Bool(b) => Expr::Bool(*b), + Expr::Error(e) => Expr::Error(*e), + Expr::Array(rows) => Expr::Array(rows.clone()), + Expr::Ref(r) => Expr::Ref(r.clone()), + Expr::Name(n) => Expr::Name(n.clone()), + Expr::Struct(s) => Expr::Struct(s.clone()), + Expr::Unary(op, e) => Expr::Unary(*op, e.clone()), + Expr::Binary(op, a, b) => Expr::Binary(*op, a.clone(), b.clone()), + Expr::Call(n, args) => Expr::Call(n.clone(), args.clone()), + Expr::Invoke(c, args) => Expr::Invoke(c.clone(), args.clone()), + Expr::Missing => Expr::Missing, + Expr::Paren(e) => Expr::Paren(e.clone()), + }; + while let Some((op, b)) = spine.pop() { + acc = Expr::Binary(op, Box::new(acc), b.clone()); + } + acc + } +} + +impl PartialEq for Expr { + fn eq(&self, other: &Self) -> bool { + // Compare the left spines of operator chains in a loop (see [`Expr`]). + let (mut x, mut y) = (self, other); + loop { + match (x, y) { + (Expr::Binary(o1, a1, b1), Expr::Binary(o2, a2, b2)) => { + if o1 != o2 || b1 != b2 { + return false; + } + x = a1; + y = a2; + } + (Expr::Number(a), Expr::Number(b)) => return a == b, + (Expr::Text(a), Expr::Text(b)) => return a == b, + (Expr::Bool(a), Expr::Bool(b)) => return a == b, + (Expr::Error(a), Expr::Error(b)) => return a == b, + (Expr::Array(a), Expr::Array(b)) => return a == b, + (Expr::Ref(a), Expr::Ref(b)) => return a == b, + (Expr::Name(a), Expr::Name(b)) => return a == b, + (Expr::Struct(a), Expr::Struct(b)) => return a == b, + (Expr::Unary(o1, a), Expr::Unary(o2, b)) => return o1 == o2 && a == b, + (Expr::Call(n1, a), Expr::Call(n2, b)) => return n1 == n2 && a == b, + (Expr::Invoke(c1, a), Expr::Invoke(c2, b)) => return c1 == c2 && a == b, + (Expr::Missing, Expr::Missing) => return true, + (Expr::Paren(a), Expr::Paren(b)) => return a == b, + _ => return false, + } + } + } +} + pub(crate) fn clamp_anchor(row: i64, col: i64) -> Option<(u32, u32)> { if (0..MAX_ROWS as i64).contains(&row) && (0..MAX_COLS as i64).contains(&col) { Some((row as u32, col as u32)) } else { None } } diff --git a/crates/formula/src/input.rs b/crates/formula/src/input.rs index 8568023ed..7c0fe8b53 100644 --- a/crates/formula/src/input.rs +++ b/crates/formula/src/input.rs @@ -63,8 +63,9 @@ pub fn parse_input(src: &str, locale: FormulaLocale, known_name: impl Fn(&str) - /// Prints canonical expressions for editing. Name collisions retain the canonical spelling /// so displaying then committing a formula never changes a defined-name reference. pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str) -> bool) -> Result { - // Check before cloning/printing: a flat operator chain can produce a deep AST even - // though the parser itself did not recurse deeply. + // Check before cloning/printing. A flat operator chain such as A1+A2+… nests on its left + // without nesting in the text: cloning, printing and `normalize` follow that left spine in + // a loop, so it does not count (Excel accepts 4,096 operands, #916). Other nesting does. let mut pending = vec![(expr, 0usize)]; while let Some((node, depth)) = pending.pop() { if depth > parser::MAX_DEPTH { @@ -73,7 +74,7 @@ pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str) match node { Expr::Unary(_, child) | Expr::Paren(child) => pending.push((child, depth + 1)), Expr::Binary(_, left, right) => { - pending.push((left, depth + 1)); + pending.push((left, depth)); pending.push((right, depth + 1)); } Expr::Call(_, args) => pending.extend(args.iter().map(|e| (e, depth + 1))), @@ -185,9 +186,15 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut } } Expr::Unary(_, child) | Expr::Paren(child) => normalize(child, known_name, bindings, depth + 1, display)?, - Expr::Binary(_, left, right) => { - normalize(left, known_name, bindings, depth + 1, display)?; - normalize(right, known_name, bindings, depth + 1, display)?; + Expr::Binary(..) => { + // Follow the left spine of a flat chain (A1+A2+…) in a loop: it is not nesting. + // Each call leaves `bindings` as it found them, so the order does not matter. + let mut node = expr; + while let Expr::Binary(_, left, right) = node { + normalize(right, known_name, bindings, depth + 1, display)?; + node = left; + } + normalize(node, known_name, bindings, depth + 1, display)?; } Expr::Array(rows) if display => { for row in rows { diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index d23eba0e2..185ffdb23 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -401,3 +401,37 @@ fn formulas_longer_than_excel_allows_are_refused() { assert!(text.len() > crate::MAX_FORMULA_LEN); assert!(parse(&text).is_ok()); } + +#[test] +fn longest_flat_chains_clone_compare_walk_and_adjust_without_deep_recursion() { + // `=1+1+…` with 4,096 operands is 8,192 characters, Excel's formula limit, and nests 4,095 + // levels on its left. On a 1 MiB stack (the Windows main thread) the derived, recursive + // Clone overflowed when the engine stored such a formula (#916). + let handle = std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(|| { + let plus = vec!["1"; 4096].join("+"); + let e = parse(&plus).expect("parse"); + let copy = e.clone(); + assert!(copy == e); + let other = parse(&format!("{}-1", vec!["1"; 4095].join("+"))).expect("parse"); + assert!(other != e); + let mut nodes = 0; + e.walk(&mut |_| nodes += 1); + assert_eq!(nodes, 2 * 4096 - 1); + assert_eq!(print(©.map(&mut |x| x)), plus); + // A chain of references: walk finds them in order, adjust rewrites every one. + let refs = (1..=1000).map(|r| format!("A{r}")).collect::>().join("+"); + let e = parse(&refs).expect("parse"); + let found: Vec = e.references().iter().map(|r| printer::reference_a1(r)).collect(); + assert_eq!(found.first().map(String::as_str), Some("A1")); + assert_eq!(found.last().map(String::as_str), Some("A1000")); + assert_eq!(found.len(), 1000); + let ins = Edit::Insert { axis: Axis::Rows, at: 0, count: 1 }; + let moved = print(&adjust(e.clone(), "S", "S", &ins)); + assert_eq!(moved, (2..=1001).map(|r| format!("A{r}")).collect::>().join("+")); + assert_eq!(print(&shift_relative(e, 0, 1)), (1..=1000).map(|r| format!("B{r}")).collect::>().join("+")); + }) + .expect("spawn"); + handle.join().expect("long chains must not overflow the stack"); +} diff --git a/crates/formula/tests/spanish_input.rs b/crates/formula/tests/spanish_input.rs index fee688a86..8c6053c43 100644 --- a/crates/formula/tests/spanish_input.rs +++ b/crates/formula/tests/spanish_input.rs @@ -1,4 +1,4 @@ -use gridcraft_formula::{FormulaLocale, parse, parse_input, print, print_input}; +use gridcraft_formula::{Expr, FormulaLocale, parse, parse_input, print, print_input}; fn input(src: &str) -> String { print(&parse_input(src, FormulaLocale::Es, |_| false).unwrap()) @@ -45,10 +45,31 @@ fn spanish_input_preserves_reference_and_array_syntax() { assert_eq!(print(&parse("SUMA(1,2)+FALSO").unwrap()), "SUMA(1,2)+FALSO"); assert!(parse("SUM(1;2)").is_err()); assert!(parse("{VERDADERO}").is_err()); - // Pratt parsing can produce a deep left-hand AST without deep parser recursion. - let long_chain = "FALSO+".repeat(300) + "VERDADERO"; - assert!(parse_input(&long_chain, FormulaLocale::Es, |_| false).is_err()); - assert!(print_input(&parse(&long_chain).unwrap(), FormulaLocale::Es, |_| false).is_err()); + // Real nesting past the parser's limit is still refused. + let nested = "(".repeat(300) + "1" + &")".repeat(300); + assert!(parse_input(&nested, FormulaLocale::Es, |_| false).is_err()); + let deep = (0..300).fold(Expr::Number(1.0), |e, _| Expr::Paren(Box::new(e))); + assert!(print_input(&deep, FormulaLocale::Es, |_| false).is_err()); +} + +#[test] +fn spanish_input_accepts_flat_chains_as_long_as_excel() { + // A flat chain nests on its left without nesting in the text. Excel accepts 4,096 + // operands (8,192 characters); Spanish entry refused more than 256 (#916). On a 1 MiB + // stack, as on the Windows main thread. + std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(|| { + let long_chain = "FALSO+".repeat(1360) + "VERDADERO"; + let english = "FALSE+".repeat(1360) + "TRUE"; + assert_eq!(input(&long_chain), english); + assert_eq!(print_input(&parse(&english).unwrap(), FormulaLocale::Es, |_| false).unwrap(), long_chain); + let sums = vec!["SUMA(1;2)"; 800].join("+"); + assert_eq!(input(&sums), vec!["SUM(1,2)"; 800].join("+")); + }) + .unwrap() + .join() + .unwrap(); } #[test] diff --git a/crates/xlsx/src/array_formula.rs b/crates/xlsx/src/array_formula.rs index caa5e8e9f..bafe3ac08 100644 --- a/crates/xlsx/src/array_formula.rs +++ b/crates/xlsx/src/array_formula.rs @@ -22,7 +22,9 @@ impl Shape { pub(crate) fn needs_array(wb: &Workbook, sheet: usize, at: CellRef, formula: &Formula) -> bool { let Some(expr) = formula.expr_arc() else { return false }; - let mut scan = Scan { wb, at, remaining: 4096, locals: Vec::new() }; + // The budget covers every node of a formula as long as Excel allows (8,192 characters make + // at most about 8,200 nodes), so only names that expand a lot fall back to array evaluation. + let mut scan = Scan { wb, at, remaining: 16_384, locals: Vec::new() }; let shape = scan.expr(&expr, sheet, false, 0); // An explicit `@` is written as `_xlfn.SINGLE`, which only dynamic-array formulas carry. let mut at = false; @@ -89,11 +91,27 @@ impl Scan<'_> { } } Expr::Binary(op, a, b) => { - let a = self.expr(a, sheet, array_context, depth + 1); - let b = self.expr(b, sheet, array_context, depth + 1); - let reference = matches!(op, BinOp::Range | BinOp::Union | BinOp::Intersect); - let array = reference || a.array || b.array; - Shape { array, affected: a.affected || b.affected || (!reference && array && !array_context) } + // A flat chain such as A1+A2+… nests one level per operator on its left: follow + // it in a loop, so a long chain is neither deep recursion nor past the depth + // limit (which would write a plain sum as an array formula). + let mut spine = vec![(*op, b.as_ref())]; + let mut left = a.as_ref(); + while let Expr::Binary(op, a, b) = left { + if self.remaining == 0 { + return Shape { array: true, affected: true }; + } + self.remaining -= 1; + spine.push((*op, b.as_ref())); + left = a.as_ref(); + } + let mut acc = self.expr(left, sheet, array_context, depth + 1); + while let Some((op, b)) = spine.pop() { + let b = self.expr(b, sheet, array_context, depth + 1); + let reference = matches!(op, BinOp::Range | BinOp::Union | BinOp::Intersect); + let array = reference || acc.array || b.array; + acc = Shape { array, affected: acc.affected || b.affected || (!reference && array && !array_context) }; + } + acc } Expr::Call(name, args) => self.call(name, args, sheet, array_context, depth + 1), Expr::Invoke(_, _) => Shape { array: true, affected: false }, diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index ef263873e..bef43a5aa 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -30,7 +30,21 @@ fn prefix_params(e: Expr, scope: &[String]) -> Expr { Expr::Name(n) if scope.iter().any(|s| s.eq_ignore_ascii_case(&n)) => Expr::Name(format!("{PARAM_PREFIX}{n}")), Expr::Unary(op, x) => Expr::Unary(op, sub(x, scope)), Expr::Paren(x) => Expr::Paren(sub(x, scope)), - Expr::Binary(op, a, b) => Expr::Binary(op, sub(a, scope), sub(b, scope)), + Expr::Binary(op, a, b) => { + // A long chain such as A1+A2+… nests on its left: follow it in a loop, not one + // level of recursion per operand. + let mut spine = vec![(op, b)]; + let mut left = *a; + while let Expr::Binary(op, a, b) = left { + spine.push((op, b)); + left = *a; + } + let mut acc = prefix_params(left, scope); + while let Some((op, b)) = spine.pop() { + acc = Expr::Binary(op, Box::new(acc), sub(b, scope)); + } + acc + } Expr::Invoke(c, args) => Expr::Invoke(sub(c, scope), args.into_iter().map(|a| prefix_params(a, scope)).collect()), Expr::Call(name, args) if name == "LET" || name == "LAMBDA" => { let last = args.len().saturating_sub(1); diff --git a/crates/xlsx/src/tests/array_export.rs b/crates/xlsx/src/tests/array_export.rs index b90b98668..0dcc9d81c 100644 --- a/crates/xlsx/src/tests/array_export.rs +++ b/crates/xlsx/src/tests/array_export.rs @@ -409,3 +409,28 @@ fn implicit_intersection_metadata_distinguishes_table_rows_and_columns() { &[("Data[@Input]*2", false), ("SUM(Data[Input])", false), ("SUM(Data[Input]*2)", true), ("SQRT(Data[Input])", true)], ); } + +#[test] +fn long_flat_chains_are_scalar_formulas() { + // `=A1+A2+…` nests one level per operator. Past the scan's depth limit of 64, a plain sum + // of 66 cells was written as a dynamic-array formula; Excel writes it as a plain formula. + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + let refs = (1..=1500).map(|r| format!("A{r}")).collect::>().join("+"); + let mixed = (1..=1000).map(|r| format!("A{r}*2")).collect::>().join("-"); + let literals = vec!["1"; 4096].join("&"); + let ranges = format!("{refs}+A1:A3"); + sh.cells.set(at("B1"), formula(&refs, 0.0)); + sh.cells.set(at("B2"), formula(&mixed, 0.0)); + sh.cells.set(at("B3"), formula(&literals, 0.0)); + sh.cells.set(at("B4"), formula(&ranges, 0.0)); + let bytes = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&bytes).unwrap(); + let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap(); + for address in ["B1", "B2", "B3"] { + assert_scalar(&sheet, address); + } + // A range anywhere in the chain still needs array evaluation. + assert_dynamic(&sheet, "B4", "B4"); + assert_eq!(cell(&sheet, "B1").child("f").unwrap().text, refs); +} diff --git a/docs/function-parity.md b/docs/function-parity.md index 2343e0de9..babf85630 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-15 · **Change:** minor (flat operator chains as long as Excel allows: 4,096 operands no longer overflow the stack, Spanish entry accepts them, files keep them as plain formulas); previously minor (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). @@ -84,6 +84,7 @@ PIVOTBY, REGEXTEST…) are all present. | 15-significant-digit comparison semantics | **missing** (#57: `=0.1+0.2=0.3` is FALSE) | | External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update | | Localized function names and `;` separators | **missing** (#45); en-US only | +| Long flat operator chains (`=A1+A2+…`, `-`, `*`, `/`, `^`, `&`, comparisons) | done: no operand limit below Excel's 8,192-character formula limit (Excel, observed: `=1+1+…` with 4,096 operands is exactly 8,192 characters with the `=` and calculates; one more character is refused). Evaluated (#484), printed (#628), stored, copied, adjusted, saved and reopened without recursing once per operand, on a 1 MiB stack (#916); written to XLSX as plain formulas, not dynamic arrays; Spanish entry accepts them | | Error values | the seven classic errors plus `#SPILL!`, `#CALC!`, `#GETTING_DATA` done; `#FIELD!`, `#BLOCKED!`, `#CONNECT!`, `#BUSY!`, `#UNKNOWN!`, `#PYTHON!` missing (minor: they come from data types, cloud and Python features) | ## Depth (estimated) @@ -114,6 +115,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Long flat operator chains (#916): 4,096 operands (Excel's 8,192-character limit) no longer overflow the stack when entered, filled, adjusted or saved; chains past 64 operands are no longer saved as dynamic-array formulas; Spanish entry no longer refuses chains past 256 operands | | 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-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) | From b3e0a7e8d372d9b7107de01572b22d7e11aff5b7 Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Mon, 19 Oct 2026 16:06:47 -0500 Subject: [PATCH 20/39] Formulas: count the leftmost operand of a chain at the same depth The left end of a flat operator chain is not nesting, so (((...1)))+1 is accepted whenever (((...1))) is, in Spanish entry and display. Co-Authored-By: Claude Sonnet 5.5 --- crates/formula/src/input.rs | 2 +- crates/formula/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/formula/src/input.rs b/crates/formula/src/input.rs index 7c0fe8b53..38a87cd11 100644 --- a/crates/formula/src/input.rs +++ b/crates/formula/src/input.rs @@ -194,7 +194,7 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut normalize(right, known_name, bindings, depth + 1, display)?; node = left; } - normalize(node, known_name, bindings, depth + 1, display)?; + normalize(node, known_name, bindings, depth, display)?; } Expr::Array(rows) if display => { for row in rows { diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index 185ffdb23..4177fd055 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -435,3 +435,21 @@ fn longest_flat_chains_clone_compare_walk_and_adjust_without_deep_recursion() { .expect("spawn"); handle.join().expect("long chains must not overflow the stack"); } + +#[test] +fn spanish_input_counts_left_operand_of_a_chain_at_the_same_depth() { + // The leftmost operand of a flat chain is not nested, so `(((…1)))+1` is accepted whenever + // `(((…1)))` is, in both the pre-check and `normalize`. + let wrap = |n: usize| { + let mut e = Expr::Number(1.0); + for _ in 0..n { + e = Expr::Paren(Box::new(e)); + } + e + }; + let es = |e: &Expr| crate::print_input(e, FormulaLocale::Es, |_| false); + let deepest = (0..400).take_while(|&n| es(&wrap(n)).is_ok()).last().expect("depth 0 is accepted"); + assert!(deepest > 1 && deepest < 400); + let chain = Expr::Binary(BinOp::Add, Box::new(wrap(deepest)), Box::new(Expr::Number(1.0))); + assert!(es(&chain).is_ok()); +} From 4ce8dc55ecebd4e50cd31379e0be891a65bc9fa9 Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Mon, 19 Oct 2026 18:59:35 -0500 Subject: [PATCH 21/39] Docs: date the long-chains revision entry 2026-10-19 Co-Authored-By: Claude Sonnet 5.5 --- docs/function-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/function-parity.md b/docs/function-parity.md index babf85630..c5108dcfd 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 (flat operator chains as long as Excel allows: 4,096 operands no longer overflow the stack, Spanish entry accepts them, files keep them as plain formulas); previously 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 (flat operator chains as long as Excel allows: 4,096 operands no longer overflow the stack, Spanish entry accepts them, files keep them as plain formulas); previously minor (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,7 +115,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| -| 2026-10-14 | minor | Long flat operator chains (#916): 4,096 operands (Excel's 8,192-character limit) no longer overflow the stack when entered, filled, adjusted or saved; chains past 64 operands are no longer saved as dynamic-array formulas; Spanish entry no longer refuses chains past 256 operands | +| 2026-10-19 | minor | Long flat operator chains (#916): 4,096 operands (Excel's 8,192-character limit) no longer overflow the stack when entered, filled, adjusted or saved; chains past 64 operands are no longer saved as dynamic-array formulas; Spanish entry no longer refuses chains past 256 operands | | 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-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) | From edd6e0cbe88e485146bee89595e7ac74f3d92a2c Mon Sep 17 00:00:00 2001 From: Lord-Taw <149683190+Lord-Taw@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:27:15 -0300 Subject: [PATCH 22/39] Atualizar o i18n.rs --- crates/ui-egui/src/i18n.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6e9b1bfd0..75b484dba 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -133,7 +133,7 @@ impl Language { translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label)) } - pub(crate) fn tr_error(self, message: &str) -> String { +pub(crate) fn tr_error(self, message: &str) -> String { let message = if message.starts_with("command `") { crate::clean_error(message) } else { @@ -141,14 +141,15 @@ impl Language { }; let translated = self.tr(&message); - let fallback = message.rsplit_once('|').map_or(message.as_str(), |(_, label)| label); + let fallback = message + .rsplit_once('|') + .map_or(message.as_str(), |(_, label)| label); if translated == fallback { message } else { translated.to_owned() } - } } /// Deserializes a saved language leniently: an unknown or malformed value (a language removed in a From 199e5f8e05260ead5887b20a862172f7e112f7d4 Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Mon, 19 Oct 2026 20:56:26 -0500 Subject: [PATCH 23/39] Formulas: test the order walk and map visit nodes; date ROADMAP like the parity doc Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 6 +++--- crates/formula/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2ec3b5a99..e993ae660 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-14 · **Change:** minor (long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (long flat operator chains up to Excel's formula limit) · **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,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 | Long flat operator chains (`=A1+A2+…`) up to Excel's 8,192-character limit (4,096 operands): entering, filling, inserting rows, undo, Evaluate Formula and XLSX save no longer overflow the stack; chains past 64 operands are saved as plain formulas, not dynamic arrays; Spanish formula entry accepts chains past 256 operands (#916) | +| 2026-10-19 | Long flat operator chains (`=A1+A2+…`) up to Excel's 8,192-character limit (4,096 operands): entering, filling, inserting rows, undo, Evaluate Formula and XLSX save no longer overflow the stack; chains past 64 operands are saved as plain formulas, not dynamic arrays; Spanish formula entry accepts chains past 256 operands (#916) | | 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) | @@ -194,7 +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 | Progress log: long flat operator chains (#916) | +| 2026-10-19 | minor | Progress log: long flat operator chains (#916) | | 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/formula/src/tests.rs b/crates/formula/src/tests.rs index 4177fd055..8c7d85ea7 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -453,3 +453,20 @@ fn spanish_input_counts_left_operand_of_a_chain_at_the_same_depth() { let chain = Expr::Binary(BinOp::Add, Box::new(wrap(deepest)), Box::new(Expr::Number(1.0))); assert!(es(&chain).is_ok()); } + +#[test] +fn walk_is_preorder_and_map_is_postorder_left_to_right() { + // `(A1+B1)+SUM(C1,D1)-E1`: a chain with a call on its right. walk visits parents before + // children, map rewrites children (left to right) before their parent. + let e = parse("(A1+B1)+SUM(C1,D1)-E1").unwrap(); + let mut walked = Vec::new(); + e.walk(&mut |n| walked.push(print(n))); + assert_eq!(walked, ["(A1+B1)+SUM(C1,D1)-E1", "(A1+B1)+SUM(C1,D1)", "(A1+B1)", "A1+B1", "A1", "B1", "SUM(C1,D1)", "C1", "D1", "E1"]); + let mut mapped = Vec::new(); + let out = e.map(&mut |n| { + mapped.push(print(&n)); + n + }); + assert_eq!(mapped, ["A1", "B1", "A1+B1", "(A1+B1)", "C1", "D1", "SUM(C1,D1)", "(A1+B1)+SUM(C1,D1)", "E1", "(A1+B1)+SUM(C1,D1)-E1"]); + assert_eq!(print(&out), "(A1+B1)+SUM(C1,D1)-E1"); +} From 479d1b4b666ca609947cda80cb21c179d27a659d Mon Sep 17 00:00:00 2001 From: Lord-Taw <149683190+Lord-Taw@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:20:02 -0300 Subject: [PATCH 24/39] Update i18n.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ´cargo fmt --check´ OK --- crates/ui-egui/src/i18n.rs | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 75b484dba..6ca33e5a3 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -133,22 +133,13 @@ impl Language { translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label)) } -pub(crate) fn tr_error(self, message: &str) -> String { - let message = if message.starts_with("command `") { - crate::clean_error(message) - } else { - message.to_owned() - }; + pub(crate) fn tr_error(self, message: &str) -> String { + let message = if message.starts_with("command `") { crate::clean_error(message) } else { message.to_owned() }; - let translated = self.tr(&message); - let fallback = message - .rsplit_once('|') - .map_or(message.as_str(), |(_, label)| label); + let translated = self.tr(&message); + let fallback = message.rsplit_once('|').map_or(message.as_str(), |(_, label)| label); - if translated == fallback { - message - } else { - translated.to_owned() + if translated == fallback { message } else { translated.to_owned() } } } From cfb0e186ed15814f9cc9bf174b2f97abee955dfa Mon Sep 17 00:00:00 2001 From: kezmdor <15539254+kezmdor@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:44:09 +0200 Subject: [PATCH 25/39] Formula entry: an incomplete formula's error describes what was typed (fixes #2030) `=1+` reported "unexpected `)`", although no `)` was typed: entry closes missing parentheses by appending up to eight `)`, and when every retry failed it reported the last retry's error. It now reports the error of the formula as typed ("unexpected end of formula"). `gridcraft_formula::parse_closing_parens` holds the convenience and takes the parse function, so cell entry and the UI's Spanish formula path share it instead of each having its own loop. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 10 +--------- crates/engine/src/tests.rs | 15 +++++++++++++++ crates/formula/src/lib.rs | 2 +- crates/formula/src/parser.rs | 21 +++++++++++++++++++++ crates/formula/src/tests.rs | 13 +++++++++++++ crates/ui-egui/src/formula_locale.rs | 10 +--------- 6 files changed, 52 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index fe7f1fe8b..d1283bd2d 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -139,15 +139,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri if is_formula { let body = input.strip_prefix('=').unwrap_or(input); // Excel closes missing parentheses for you. - let mut text = body.to_string(); - let mut parsed = gridcraft_formula::parse(&text); - for _ in 0..8 { - if parsed.is_ok() { - break; - } - text.push(')'); - parsed = gridcraft_formula::parse(&text); - } + let parsed = gridcraft_formula::parse_closing_parens(body, gridcraft_formula::parse); let expr = parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))?; let expr = wb.name_call_case(expr); check_arg_counts(&expr)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2ac290654..637b4a9dc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -241,6 +241,21 @@ fn formulas_with_the_wrong_argument_count_are_refused_on_entry() { assert_eq!(v(&s, "C1"), Value::text("=DATEDIF(1,2)")); } +#[test] +fn an_incomplete_formula_is_refused_with_an_error_about_what_was_typed() { + // #2030: `=1+` said "unexpected `)`", although no `)` was typed: entry closes missing + // parentheses and reported the error of its last retry. + let mut s = s(); + for (input, msg) in [("=1+", "unexpected end of formula"), ("=SUM(1,+", "unexpected end of formula")] { + let e = s.execute("cell.set", json!({"cell": "A1", "input": input})).unwrap_err().to_string(); + assert!(e.contains("problem with this formula") && e.contains(msg), "{input}: {e}"); + assert!(!e.contains('`'), "{input}: {e}"); + } + // Missing closing parentheses are still added. + s.execute("cell.set", json!({"cell": "A1", "input": "=SUM(1,(2+3"})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(6.0)); +} + #[test] fn undo_redo() { let mut s = s(); diff --git a/crates/formula/src/lib.rs b/crates/formula/src/lib.rs index 88d09a8af..caf105d19 100644 --- a/crates/formula/src/lib.rs +++ b/crates/formula/src/lib.rs @@ -12,7 +12,7 @@ pub mod printer; pub use ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, TrimMode, UnOp}; pub use input::{FormulaLocale, parse_input, print_input}; -pub use parser::{MAX_FORMULA_LEN, ParseError, normalize_function_name, parse}; +pub use parser::{MAX_FORMULA_LEN, ParseError, normalize_function_name, parse, parse_closing_parens}; pub use printer::{print, print_r1c1, quote_sheet}; #[cfg(test)] diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 555717be9..a100d8486 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -28,6 +28,27 @@ pub fn parse(src: &str) -> Result { parse_syntax(src, crate::FormulaLocale::En) } +/// Most closing parentheses [`parse_closing_parens`] adds. +const MAX_CLOSED_PARENS: usize = 8; + +/// Parses formula text as typed into a cell with `parse`, closing up to eight missing `)` at the +/// end as Excel does (`SUM(1,2` enters `SUM(1,2)`). When no number of `)` makes it parse, the +/// error describes the text as typed: `1+` is "unexpected end of formula", not "unexpected `)`". +pub fn parse_closing_parens(src: &str, parse: impl Fn(&str) -> Result) -> Result { + let typed = match parse(src) { + Ok(parsed) => return Ok(parsed), + Err(e) => e, + }; + let mut text = src.to_string(); + for _ in 0..MAX_CLOSED_PARENS { + text.push(')'); + if let Ok(parsed) = parse(&text) { + return Ok(parsed); + } + } + Err(typed) +} + pub(crate) fn parse_syntax(src: &str, locale: crate::FormulaLocale) -> Result { let body = src.strip_prefix('=').unwrap_or(src); if body.len() > MAX_FORMULA_LEN && body.chars().count() > MAX_FORMULA_LEN { diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index d23eba0e2..f01a15ab6 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -401,3 +401,16 @@ fn formulas_longer_than_excel_allows_are_refused() { assert!(text.len() > crate::MAX_FORMULA_LEN); assert!(parse(&text).is_ok()); } + +#[test] +fn missing_closing_parentheses_are_closed_and_errors_describe_the_typed_text() { + // #2030: `1+` reported "unexpected `)`", the error of the last retry with `)` appended. + let entry = |src: &str| parse_closing_parens(src, parse); + assert_eq!(entry("SUM(1,2").map(|e| print(&e)), Ok("SUM(1,2)".to_string())); + assert_eq!(entry("((1+2").map(|e| print(&e)), Ok("((1+2))".to_string())); + assert_eq!(entry("1+2").map(|e| print(&e)), Ok("1+2".to_string())); + for src in ["1+", "--", "SUM(1,+", "1+*2", &format!("{}1", "(".repeat(9))] { + assert_eq!(entry(src).unwrap_err(), parse(src).unwrap_err(), "{src}"); + } + assert_eq!(entry("1+").unwrap_err().msg, "unexpected end of formula"); +} diff --git a/crates/ui-egui/src/formula_locale.rs b/crates/ui-egui/src/formula_locale.rs index 6e833b95d..aa0d60188 100644 --- a/crates/ui-egui/src/formula_locale.rs +++ b/crates/ui-egui/src/formula_locale.rs @@ -27,15 +27,7 @@ pub(crate) fn canonical( { let known = |name: &str| wb.name(name, sheet).is_some() || wb.table(name).is_some(); // Match the engine's existing convenience for missing closing parentheses. - let mut body = body.to_string(); - let mut expr = formula::parse_input(&body, locale, known); - for _ in 0..8 { - if expr.is_ok() { - break; - } - body.push(')'); - expr = formula::parse_input(&body, locale, known); - } + let expr = formula::parse_closing_parens(body, |text| formula::parse_input(text, locale, known)); return Ok(format!("={}", formula::print(&expr?))); } // Decimal-comma number entry follows the same preference, without rewriting text or dates. From ed48389c46942e2b3783c9ab73eee81a8c276f25 Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Tue, 20 Oct 2026 08:38:12 -0500 Subject: [PATCH 26/39] Formulas: compare right operands only after the left spine of a chain matches Co-Authored-By: Claude Sonnet 5.5 --- crates/formula/src/ast.rs | 41 ++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/crates/formula/src/ast.rs b/crates/formula/src/ast.rs index 7302b4d04..94756c09f 100644 --- a/crates/formula/src/ast.rs +++ b/crates/formula/src/ast.rs @@ -315,33 +315,38 @@ impl Clone for Expr { impl PartialEq for Expr { fn eq(&self, other: &Self) -> bool { - // Compare the left spines of operator chains in a loop (see [`Expr`]). + // Compare the left spines of operator chains in a loop (see [`Expr`]). The right-hand + // operands are compared only once the whole spine matches, so a mismatch on the left + // returns before any (possibly deep) right operand is walked. let (mut x, mut y) = (self, other); - loop { + let mut right_operands = Vec::new(); + let equal = loop { match (x, y) { (Expr::Binary(o1, a1, b1), Expr::Binary(o2, a2, b2)) => { - if o1 != o2 || b1 != b2 { + if o1 != o2 { return false; } + right_operands.push((b1, b2)); x = a1; y = a2; } - (Expr::Number(a), Expr::Number(b)) => return a == b, - (Expr::Text(a), Expr::Text(b)) => return a == b, - (Expr::Bool(a), Expr::Bool(b)) => return a == b, - (Expr::Error(a), Expr::Error(b)) => return a == b, - (Expr::Array(a), Expr::Array(b)) => return a == b, - (Expr::Ref(a), Expr::Ref(b)) => return a == b, - (Expr::Name(a), Expr::Name(b)) => return a == b, - (Expr::Struct(a), Expr::Struct(b)) => return a == b, - (Expr::Unary(o1, a), Expr::Unary(o2, b)) => return o1 == o2 && a == b, - (Expr::Call(n1, a), Expr::Call(n2, b)) => return n1 == n2 && a == b, - (Expr::Invoke(c1, a), Expr::Invoke(c2, b)) => return c1 == c2 && a == b, - (Expr::Missing, Expr::Missing) => return true, - (Expr::Paren(a), Expr::Paren(b)) => return a == b, - _ => return false, + (Expr::Number(a), Expr::Number(b)) => break a == b, + (Expr::Text(a), Expr::Text(b)) => break a == b, + (Expr::Bool(a), Expr::Bool(b)) => break a == b, + (Expr::Error(a), Expr::Error(b)) => break a == b, + (Expr::Array(a), Expr::Array(b)) => break a == b, + (Expr::Ref(a), Expr::Ref(b)) => break a == b, + (Expr::Name(a), Expr::Name(b)) => break a == b, + (Expr::Struct(a), Expr::Struct(b)) => break a == b, + (Expr::Unary(o1, a), Expr::Unary(o2, b)) => break o1 == o2 && a == b, + (Expr::Call(n1, a), Expr::Call(n2, b)) => break n1 == n2 && a == b, + (Expr::Invoke(c1, a), Expr::Invoke(c2, b)) => break c1 == c2 && a == b, + (Expr::Missing, Expr::Missing) => break true, + (Expr::Paren(a), Expr::Paren(b)) => break a == b, + _ => break false, } - } + }; + equal && right_operands.into_iter().rev().all(|(b1, b2)| b1 == b2) } } From 83a41dcd68111d8a8ae16230fa4e4abd8fe55d88 Mon Sep 17 00:00:00 2001 From: dantebertuzzi Date: Tue, 20 Oct 2026 18:56:04 -0300 Subject: [PATCH 27/39] Right-click on a chart opens the chart's menu, not the cell's (fixes #2067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid's right-click handler never hit-tested objects, so right-clicking a chart opened the menu of the cell underneath it. And since any button's drag start grabbed an object, a right press that moved a little started dragging the chart and no menu opened at all. Right-clicking a chart, picture, shape or slicer now selects it and opens its own menu (Delete; for charts also Select Data… and Format Chart Area…). Only the primary button moves objects. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 68 ++++++++++++++++++++++++++-- crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/pt.rs | 2 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/tests/chart_resize.rs | 48 ++++++++++++++++++++ 5 files changed, 116 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 106bc6684..51c653e22 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 right-clicked chart, picture, shape or slicer: where to open its menu, its kind and id. + pub object_menu: Option<(Pos2, &'static str, u32)>, pub marching_phase: f32, /// The selection outline on its way to a new selection. pub glide: crate::selection_glide::SelectionGlide, @@ -790,6 +792,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); + object_menu(app, ui); 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() { @@ -1940,10 +1943,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } return; } - // Objects (charts, pictures, shapes, slicers) take clicks first. - if resp.drag_started() || primary_clicked { + // Objects (charts, pictures, shapes, slicers) take clicks first. Only the primary button moves + // them: a right-click that wobbles into a drag still opens the object's menu (#2067). + let primary_drag = resp.drag_started_by(egui::PointerButton::Primary); + if primary_drag || primary_clicked { // A drag is recognised only after the pointer has moved, so grab the object where the press began. - let at = if resp.drag_started() { pointer.press_origin().or(pos) } else { pos }; + let at = if primary_drag { pointer.press_origin().or(pos) } else { pos }; if let Some(p) = at && let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p) { @@ -1958,7 +1963,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & if kind == "chart" && resp.double_clicked() { app.grid.pane = Some("formatChart".into()); } - if resp.drag_started() { + if primary_drag { let a = object_anchor(sh, kind, id); let resize = (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS; if let Some(a) = a { @@ -1966,10 +1971,21 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } } return; - } else if primary_clicked || resp.drag_started() { + } else { app.selected_chart = None; } } + // A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's + // under it; a right press that wobbles into a drag counts too (#2067). + let secondary_drag = resp.drag_started_by(egui::PointerButton::Secondary); + if (resp.secondary_clicked() || secondary_drag) + && let Some(p) = if secondary_drag { pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) } else { pos } + && let Some((kind, id, _)) = crate::chartview::hit(app, geo, sh, p) + { + app.selected_chart = Some(id); + app.grid.object_menu = Some((geo.mirror_pos(p), kind, id)); + return; + } // A double-click is also a click: handle header AutoFit before resize setup returns. if resp.double_clicked() @@ -2970,6 +2986,48 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { } } +/// The right-click menu of a chart, picture, shape or slicer. +fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) { + let Some((at, kind, id)) = app.grid.object_menu else { return }; + let mut close = false; + let resp = egui::Area::new(egui::Id::new("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(200.0); + let mut items = vec![("Delete", "object.delete")]; + if kind == "chart" { + items.extend([("-", ""), ("Select Data…", "ui:selectData"), ("Format Chart Area…", "ui:formatChart")]); + } + let lang = crate::i18n::current(ui.ctx()); + for (label, action) in items { + if label == "-" { + ui.separator(); + continue; + } + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(190.0, 20.0))).clicked() { + close = true; + match action { + "ui:selectData" => app.open_dialog("selectData", json!({"chart": id})), + "ui:formatChart" => app.grid.pane = Some("formatChart".into()), + _ => { + app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); + app.selected_chart = None; + } + } + } + } + }); + }); + if close + || ui.input(|i| i.key_pressed(egui::Key::Escape)) + || (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))) + { + app.grid.object_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/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 371ea7981..ce60ab2d8 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -61,6 +61,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Insert" => "Einfügen", "Insert Cells…" => "Zellen einfügen…", "Delete" => "Löschen", + "Select Data…" => "Daten auswählen…", + "Format Chart Area…" => "Diagrammbereich formatieren…", "Delete Cells…" => "Zellen löschen…", "Insert Sheet Rows" => "Blattzeilen einfügen", "Insert Sheet Columns" => "Blattspalten einfügen", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index fb9236cdb..82c405a2a 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -426,6 +426,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select a chart first." => "Selecione um gráfico primeiro.", "Text Box" => "Caixa de Texto", "Select Data" => "Selecionar Dados", + "Select Data…" => "Selecionar Dados…", + "Format Chart Area…" => "Formatar Área do Gráfico…", "Row Height" => "Altura da Linha", "Column Width" => "Largura da Coluna", "Standard Width" => "Largura Padrão", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f549aec2b..3c428e4a2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1218,6 +1218,7 @@ impl SheetApp { && self.name_box.is_none() && !ctx.text_edit_focused() && self.grid.context_menu.is_none() + && self.grid.object_menu.is_none() && self.grid.header_menu.is_none() && self.grid.filter_menu.is_none() && self.grid.list_picker.is_none() diff --git a/crates/ui-egui/tests/chart_resize.rs b/crates/ui-egui/tests/chart_resize.rs index 5aa07e080..fb3e99165 100644 --- a/crates/ui-egui/tests/chart_resize.rs +++ b/crates/ui-egui/tests/chart_resize.rs @@ -117,3 +117,51 @@ fn dragging_the_size_field_resizes_the_chart() { let (w1, _) = size(&h, id); assert!(w1 > w0 + 20.0, "width went from {w0} to {w1}"); } + +fn right_click(h: &mut Harness, pos: egui::Pos2, wobble: egui::Vec2) { + h.input_mut().events.push(Event::PointerMoved(pos)); + h.run_steps(2); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(2); + let end = pos + wobble; + if wobble != vec2(0.0, 0.0) { + h.input_mut().events.push(Event::PointerMoved(end)); + h.run_steps(2); + } + h.input_mut().events.push(Event::PointerButton { pos: end, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +#[test] +fn right_clicking_a_chart_opens_its_menu_not_the_cells() { + // #2067: a right-click on a chart opened the menu of the cell under it, or nothing at all. + for wobble in [vec2(0.0, 0.0), vec2(20.0, 10.0)] { + let (mut h, id) = fixture(); + let (w0, h0) = size(&h, id); + let inside = corner(&h, id) - vec2(100.0, 80.0); + right_click(&mut h, inside, wobble); + assert_eq!(h.state().selected_chart, Some(id), "the right-clicked chart is selected ({wobble:?})"); + assert!(matches!(h.state().grid.object_menu, Some((_, "chart", i)) if i == id), "the chart's menu opens ({wobble:?})"); + assert!(h.state().grid.context_menu.is_none(), "the cell menu stays closed ({wobble:?})"); + assert_eq!(size(&h, id), (w0, h0)); + let a = h.state().session.doc().unwrap().wb.active().unwrap().charts.iter().find(|c| c.id == id).unwrap().anchor; + assert_eq!(a.cell, CellRef::parse("D3").unwrap(), "a right-button drag does not move the chart ({wobble:?})"); + } + // Delete from the menu removes the chart. + let (mut h, id) = fixture(); + let inside = corner(&h, id) - vec2(100.0, 80.0); + right_click(&mut h, inside, vec2(0.0, 0.0)); + h.get_by_label("Delete").click(); + h.run_steps(3); + assert!(h.state().session.doc().unwrap().wb.active().unwrap().charts.is_empty()); + assert!(h.state().grid.object_menu.is_none()); + // Outside the chart a right-click still opens the cell menu. + let (mut h, _) = fixture(); + let cell = { + let app = h.state(); + let sh = app.session.doc().unwrap().wb.active().unwrap(); + Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll).cell_rect(sh, CellRef::parse("A10").unwrap()).center() + }; + right_click(&mut h, cell, vec2(0.0, 0.0)); + assert!(h.state().grid.context_menu.is_some() && h.state().grid.object_menu.is_none()); +} From 1788c5a771dc1aa5eb11350c069c0041451364f6 Mon Sep 17 00:00:00 2001 From: dantebertuzzi Date: Tue, 20 Oct 2026 19:12:20 -0300 Subject: [PATCH 28/39] Grid: paint the scrollbar's column beside the column headings The vertical scrollbar's track starts below the column headings (and any frozen rows), and nothing painted the bar's column above it, so dark mode showed a white square at the top-right corner of the grid. The whole column now takes the track colour. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 106bc6684..60dfd143f 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -791,6 +791,9 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { header_menu(app, ui, previous_popups.2 == app.grid.header_menu); context_menu(app, ui); let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) }; + // The track starts below the column headings and frozen rows; the bar's column beside them + // takes its colour too instead of showing a light square in dark mode. + ui.painter().rect_filled(Rect::from_min_max(pos2(track_x.0, rect.top()), pos2(track_x.1, rect.bottom())), 0.0, t.tab_bar); vertical_scrollbar(app, ui, sh, &geo, track_x, max_y, &t); if app.session.clipboard.is_some() { // By the clock, not per frame: a gliding outline repaints more often than the ants step. From 226c055fb7fb269786524dd095b008a3df39fd64 Mon Sep 17 00:00:00 2001 From: Vedran Dobos <19259909+vedran-dobos@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:49:21 +0200 Subject: [PATCH 29/39] fix(ui): Esc and Enter close a data validation alert, and the keyboard goes back to the entry (#2089) A data validation alert is the general message box, drawn over the cell editor, which stays open so the entry can be fixed. The editor read Enter and Esc first: the first Esc cancelled the edit behind the alert and only the second closed it, and Enter tried the entry again so the alert came back. The editor now skips its own key handling while a message is open over it, so Esc and Enter close the alert (like its OK button). The message box also had the keyboard focus and didn't give it back, so typing went nowhere until the cell was clicked; closing it now asks the open editor for focus again. Tests: ui-egui tests/validation_alert.rs esc_closes_a_validation_alert_and_leaves_the_entry and enter_closes_a_validation_alert_and_leaves_the_entry (both fail on main, and without the focus change). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/formula_bar.rs | 4 +- crates/ui-egui/src/widgets.rs | 5 ++ crates/ui-egui/tests/validation_alert.rs | 105 +++++++++++++++++++++++ docs/ui-parity.md | 3 +- 4 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/validation_alert.rs diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 7f1ad87fc..e0038b77d 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -163,7 +163,9 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: let has_focus = focused == Some(id) || (mine && (ed.request_focus || focused.is_none())); let mut action: Option<(i64, i64, bool, bool)> = None; let mut cancel = false; - if has_focus && mine { + // A message box over the editor (a data validation alert, a formula error) gets Enter and Esc; + // the editor used to take them, so the first Esc cancelled the edit behind the message. + if has_focus && mine && app.message.is_none() { let (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, shift) = ui.input_mut(|i| { let m = i.modifiers; let alt_enter = diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e24105b49..ef40c764a 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -293,6 +293,11 @@ pub fn message_box(app: &mut SheetApp, ctx: &egui::Context) { }); if close { app.message = None; + // Back to the entry the message was about (a validation alert, a formula error), so it can + // be fixed from the keyboard without clicking the cell again. + if let Some(ed) = &mut app.editor { + ed.request_focus = true; + } } } diff --git a/crates/ui-egui/tests/validation_alert.rs b/crates/ui-egui/tests/validation_alert.rs new file mode 100644 index 000000000..eb80822c9 --- /dev/null +++ b/crates/ui-egui/tests/validation_alert.rs @@ -0,0 +1,105 @@ +//! A data validation alert over the cell editor answers Enter and Esc itself, and closing it +//! puts the keyboard back in the entry so it can be fixed straight away. + +use egui::{Event, Key, Modifiers}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::i18n::Language; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +const MESSAGE: &str = "This value doesn't match the data validation restrictions defined for this cell."; + +/// A1 holds 5 and accepts whole numbers from 1 to 10 (Stop alert). +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap(); + s.execute("data.validation", json!({"range": "A1", "type": "whole", "operator": "between", "formula1": "1", "formula2": "10"})).unwrap(); + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + let mut app = SheetApp::new(s, Default::default()); + app.ui.language = Language::En; + 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); + }, + app, + ); + h.run_steps(4); + h +} + +fn key(h: &mut Harness, k: Key) { + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +/// Types `50` into A1 and presses Enter, which opens the alert. +fn enter_50(h: &mut Harness) { + h.state_mut().begin_edit(Some("50".into()), false); + h.run_steps(2); + key(h, Key::Enter); + assert!(alert_shown(h)); +} + +/// Types text the way a keyboard does (into whatever has focus). +fn type_text(h: &mut Harness, text: &str) { + h.input_mut().events.push(Event::Text(text.into())); + h.run_steps(2); +} + +fn alert_shown(h: &Harness) -> bool { + h.query_by_label(MESSAGE).is_some() +} + +fn entry(h: &Harness) -> Option { + h.state().editor.as_ref().map(|e| e.text.clone()) +} + +fn a1(h: &Harness) -> Value { + h.state().session.active().unwrap().wb.sheet(0).unwrap().value(CellRef::new(0, 0)) +} + +/// The first Esc closes the alert and the entry is still there to fix, with the keyboard in it; a +/// second Esc cancels the edit as usual. Before, the first Esc cancelled the edit behind the alert +/// and the second closed it, and then typing went nowhere until the cell was clicked. +#[test] +fn esc_closes_a_validation_alert_and_leaves_the_entry() { + let mut h = harness(); + enter_50(&mut h); + key(&mut h, Key::Escape); + assert!(!alert_shown(&h), "one Esc closes the alert"); + assert_eq!(entry(&h), Some("50".to_string()), "the entry is still open behind it"); + type_text(&mut h, "9"); + assert_eq!(entry(&h), Some("509".to_string()), "typing goes into the entry"); + key(&mut h, Key::Escape); + assert_eq!(entry(&h), None, "the next Esc cancels the edit"); + assert_eq!(a1(&h), Value::Number(5.0)); +} + +/// Enter closes the alert too, without touching the entry, and the entry can be fixed and +/// committed from the keyboard. Before, the editor took Enter, tried the same entry again and the +/// alert stayed. +#[test] +fn enter_closes_a_validation_alert_and_leaves_the_entry() { + let mut h = harness(); + enter_50(&mut h); + key(&mut h, Key::Enter); + assert!(!alert_shown(&h), "Enter closes the alert"); + assert_eq!(entry(&h), Some("50".to_string())); + assert_eq!(a1(&h), Value::Number(5.0)); + key(&mut h, Key::Backspace); + key(&mut h, Key::Backspace); + type_text(&mut h, "8"); + assert_eq!(entry(&h), Some("8".to_string()), "the entry is fixed from the keyboard"); + key(&mut h, Key::Enter); + assert_eq!(entry(&h), None); + assert_eq!(a1(&h), Value::Number(8.0), "the fixed entry goes in"); + assert!(!alert_shown(&h)); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 34cb502f5..103df0238 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-19 · **Change:** minor (Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846) · **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 @@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | Esc and Enter close a message over the cell editor, such as a data validation alert, and the entry stays open to fix, with the keyboard back in it. Before, the editor took them (the first Esc cancelled the edit behind the alert and only the second closed it), and after the message typing went nowhere until the cell was clicked | | 2026-10-19 | minor | Ctrl+Enter (`range.fill`) gives each cell what typing the input there gives: a General cell gets the automatic number format (`5%`, `$5`, `3/4/2026`, `=TODAY()`) even when it already held a value, and a cell with its own format, a formatted row or column, or Text keeps it. Cells that held a value used to keep General (`5%` showed 0.05), and empty cells took the active cell's format | | 2026-10-18 | minor | Keytip letters typed with left Alt held continue the sequence (Alt+H, O, A / Alt+H, O, I ran nothing before: Alt+letter cancelled keytips); other Alt combinations, AltGr and macOS Option are unchanged (#1846) | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | From 68f956a4102daf17f4272608c9f1869efb212114 Mon Sep 17 00:00:00 2001 From: dantebertuzzi Date: Tue, 20 Oct 2026 19:59:48 -0300 Subject: [PATCH 30/39] Number Stored as Text: green indicator, warning button and Convert to Number (#1994) Numbers stored as text are skipped by SUM and friends, and GridCraft gave no hint. Like Excel's background error checking, a constant whose text would be a number if typed now gets a green triangle in its top-left corner; with such a cell active, a warning button beside it opens a menu naming the problem with Convert to Number, which converts every flagged cell in the selection as one undo step. - core: `parse::number_stored_as_text` (signs, thousands separators, currency, percent, exponent, one parenthesised negative). Dates, times and fractions kept as text are not flagged, as in Excel. - engine: `cmd::formulas::number_stored_as_text(cell)` (constants only, not formula results) and `formulas.convertToNumber {range?}`, which keeps each cell's format. - ui: the triangle in the grid and the warning button with its menu; pt-BR and German strings. Co-Authored-By: Claude Opus 5.5 --- crates/core/src/parse.rs | 22 +++++++ crates/engine/src/cmd/formulas.rs | 45 ++++++++++++++- crates/engine/src/tests.rs | 29 ++++++++++ crates/ui-egui/src/grid.rs | 57 +++++++++++++++++++ crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/pt.rs | 3 + crates/ui-egui/tests/number_stored_as_text.rs | 44 ++++++++++++++ docs/ui-parity.md | 3 +- 8 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/number_stored_as_text.rs diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index b465beaf9..d1435f37b 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -49,6 +49,17 @@ pub fn parse_number_text_in(s: &str, sys: DateSystem) -> Option { parse_numeric(s.trim(), sys).map(|(n, _)| n) } +/// Text that Excel's "Number Stored as Text" check flags: it would be a number if typed (signs, +/// thousands separators, currency, percent, exponent, a parenthesised negative). Dates, times and +/// fractions kept as text are not flagged, as in Excel. +pub fn number_stored_as_text(s: &str) -> Option { + let t = s.trim(); + match t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) { + Some(inner) => parse_plain(inner.trim()).map(|(n, _)| -n), + None => parse_plain(t).map(|(n, _)| n), + } +} + fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> { if t.is_empty() { return None; @@ -398,4 +409,15 @@ mod tests { assert_eq!(parse_input("2/29/1900", DateSystem::D1904).value, Value::text("2/29/1900")); assert_eq!(parse_input("2/29/1904", DateSystem::D1904).value, Value::Number(59.0)); } + + #[test] + fn numbers_stored_as_text() { + for (text, n) in [("123", 123.0), (" 42 ", 42.0), ("-1,000.5", -1000.5), ("$3", 3.0), ("50%", 0.5), ("1E3", 1000.0), ("(100)", -100.0)] { + assert_eq!(number_stored_as_text(text), Some(n), "{text}"); + } + // Dates, times, fractions, words and blanks are not flagged. + for text in ["3/4/2026", "10:30", "1 1/2", "abc", "12abc", "", " ", "TRUE", "#N/A", "((1))"] { + assert_eq!(number_stored_as_text(text), None, "{text}"); + } + } } diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 454c545d0..173493b56 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -1,6 +1,6 @@ //! Formulas tab: AutoSum, Insert Function, names, auditing, calculation. -use gridcraft_core::{CellRef, RangeRef}; +use gridcraft_core::{CellRef, RangeRef, Value}; use gridcraft_model::{CalcMode, DefinedName, Formula}; use serde_json::{Value as Json, json}; @@ -51,6 +51,15 @@ pub fn specs() -> Vec { cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents), cmd!(noundo "formulas.removeArrows", "Remove Arrows", ["Formulas", "Formula Auditing"], None, "{}", has_doc, |_, _| ok()), cmd!("formulas.showFormulas", "Show Formulas", ["Formulas", "Formula Auditing"], Some("Ctrl+`"), "{on?}", has_doc, show_formulas), + cmd!( + "formulas.convertToNumber", + "Convert to Number", + [], + None, + "{range?} → {converted} (numbers stored as text in the range or selection become numbers)", + has_doc, + convert_to_number + ), cmd!(query "formulas.errorChecking", "Error Checking", ["Formulas", "Formula Auditing"], None, "{} → cells with errors", has_doc, error_checking), cmd!(query "formulas.evaluateFormula", "Evaluate Formula", ["Formulas", "Formula Auditing"], None, "{cell?, formula?} → steps", has_doc, evaluate_formula), cmd!(query "formulas.evaluate", "Evaluate", [], None, "{formula: \"=SUM(A1:A3)\", cell?} → value without changing the sheet", has_doc, evaluate), @@ -489,6 +498,40 @@ fn show_formulas(s: &mut Session, p: &Json) -> Result { }) } +/// The number a cell's text stands for when Excel's background check flags it as a number stored +/// as text: a constant (not a formula's result) that would be a number if typed. +pub fn number_stored_as_text(cell: &gridcraft_model::Cell) -> Option { + match &cell.value { + Value::Text(t) if cell.formula.is_none() => gridcraft_core::parse::number_stored_as_text(t), + _ => None, + } +} + +/// Convert to Number: each number stored as text in the range becomes that number, keeping the +/// cell's format. +fn convert_to_number(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let ranges = target_ranges(s, p)?; + edit(s, |cx| { + let mut converted = 0; + for r in &ranges { + let Some(r) = cx.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) else { continue }; + let found: Vec<(CellRef, f64)> = cx + .wb + .sheet(sheet) + .map(|sh| sh.cells.iter_range(r).filter_map(|(c, x)| number_stored_as_text(x).map(|n| (c, n))).collect()) + .unwrap_or_default(); + let sh = cx.sheet_mut(sheet)?; + for (c, n) in &found { + sh.set_value(*c, Value::number(*n)); + } + cx.changed.extend(found.iter().map(|(c, _)| (sheet, *c))); + converted += found.len(); + } + Ok(json!({"converted": converted})) + }) +} + fn error_checking(s: &mut Session, _: &Json) -> Result { let d = s.doc()?; let mut out = Vec::new(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2ac290654..30194b54f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3344,3 +3344,32 @@ fn csv_as_text_keeps_identifier_columns_as_written() { let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap(); assert_eq!(crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap(), csv.replace('\n', "\r\n").as_bytes()); } + +#[test] +fn convert_to_number_turns_numbers_stored_as_text_into_numbers() { + let mut s = s(); + let value = |s: &mut Session, cell: &str| s.execute("cell.get", json!({"cell": cell})).unwrap()["value"].clone(); + for (cell, input) in [("A1", "'123"), ("A2", "'1,000.5"), ("A3", "'abc"), ("A4", "'3/4/2026"), ("A5", "=\"7\"")] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + s.execute("home.bold", json!({"range": "A1", "on": true})).unwrap(); + let r = s.execute("formulas.convertToNumber", json!({"range": "A1:A5"})).unwrap(); + assert_eq!(r["converted"], 2); + assert_eq!(value(&mut s, "A1"), json!(123.0)); + assert_eq!(value(&mut s, "A2"), json!(1000.5)); + // Words, date text and a formula's text result stay as they are. + assert_eq!(value(&mut s, "A3"), json!("abc")); + assert_eq!(value(&mut s, "A4"), json!("3/4/2026")); + assert_eq!(value(&mut s, "A5"), json!("7")); + assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"]["font"]["bold"], true, "the format is kept"); + // SUM now counts them; undo brings the text back in one step. + s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A1:A2)"})).unwrap(); + assert_eq!(value(&mut s, "B1"), json!(1123.5)); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(value(&mut s, "A1"), json!("123")); + // With no range it works on the selection. + s.execute("selection.set", json!({"range": "A2"})).unwrap(); + assert_eq!(s.execute("formulas.convertToNumber", json!({})).unwrap()["converted"], 1); + assert_eq!(value(&mut s, "A1"), json!("123")); +} diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 106bc6684..6f07144e0 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -790,6 +790,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); + error_check_tag(app, ui, &geo, sh); 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() { @@ -1383,6 +1384,15 @@ pub(crate) fn paint_quadrant( Stroke::NONE, )); } + // Background error checking: a number stored as text gets Excel's green corner (#1994). + if !sh.show_formulas && sh.cell(*c).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_some() { + let s = 6.0 * z.max(0.7); + p.add(egui::Shape::convex_polygon( + vec![rect.left_top(), pos2(rect.left() + s, rect.top()), pos2(rect.left(), rect.top() + s)], + ERROR_CHECK_GREEN, + Stroke::NONE, + )); + } } // Sparklines. for sp in &sh.sparklines { @@ -2970,6 +2980,53 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { } } +/// Excel's background error-checking indicator colour. +const ERROR_CHECK_GREEN: Color32 = Color32::from_rgb(0x1E, 0x8E, 0x3E); + +/// The warning button beside an active cell holding a number stored as text, as in Excel: its +/// menu names the problem and offers Convert to Number for the selection (#1994). +fn error_check_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { + if app.editor.is_some() || app.dialog.is_some() || sh.show_formulas || app.grid.drag != Drag::None { + return; + } + let Some(active) = app.session.active().map(|d| d.selection.active) else { return }; + if sh.cell(active).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_none() { + return; + } + let cell = geo.cell_rect(sh, active); + let size = vec2(18.0, 18.0); + // Left of the cell, or right of it when that would cover the row headings. + let left = if cell.left() - size.x - 2.0 >= geo.cells.left() { cell.left() - size.x - 2.0 } else { cell.right() + 2.0 }; + let tag = geo.mirror_rect(Rect::from_min_size(pos2(left, cell.top()), size)); + if !geo.cells.intersects(tag) { + return; + } + let lang = crate::i18n::current(ui.ctx()); + let resp = ui.interact(tag, egui::Id::new("gridcraft.error_check_tag"), Sense::click()).on_hover_text(lang.tr("Number Stored as Text")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, lang.tr("Error checking options"))); + let t = Tokens::get(ui.ctx()); + let p = ui.painter(); + p.rect_filled(tag, 3.0, if resp.hovered() { t.hover } else { t.window }); + p.rect_stroke(tag, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); + // A warning sign: an amber diamond with an exclamation mark. + let c = tag.center(); + let r = 6.0; + p.add(egui::Shape::convex_polygon( + vec![pos2(c.x, c.y - r), pos2(c.x + r, c.y), pos2(c.x, c.y + r), pos2(c.x - r, c.y)], + Color32::from_rgb(0xF2, 0xB7, 0x05), + Stroke::NONE, + )); + p.text(c, egui::Align2::CENTER_CENTER, "!", egui::FontId::proportional(10.0), Color32::BLACK); + egui::Popup::menu(&resp).show(|ui| { + ui.label(egui::RichText::new(lang.tr("Number Stored as Text")).strong()); + ui.separator(); + if ui.button(lang.tr("Convert to Number")).clicked() { + app.run_or_alert("formulas.convertToNumber", json!({})); + ui.close(); + } + }); +} + /// 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/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 371ea7981..6ad7993e6 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -61,6 +61,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Insert" => "Einfügen", "Insert Cells…" => "Zellen einfügen…", "Delete" => "Löschen", + "Number Stored as Text" => "Als Text gespeicherte Zahl", + "Convert to Number" => "In eine Zahl umwandeln", "Delete Cells…" => "Zellen löschen…", "Insert Sheet Rows" => "Blattzeilen einfügen", "Insert Sheet Columns" => "Blattspalten einfügen", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index fb9236cdb..5741ceb4e 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -426,6 +426,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Select a chart first." => "Selecione um gráfico primeiro.", "Text Box" => "Caixa de Texto", "Select Data" => "Selecionar Dados", + "Number Stored as Text" => "Número Armazenado como Texto", + "Convert to Number" => "Converter em Número", + "Error checking options" => "Opções de verificação de erros", "Row Height" => "Altura da Linha", "Column Width" => "Largura da Coluna", "Standard Width" => "Largura Padrão", diff --git a/crates/ui-egui/tests/number_stored_as_text.rs b/crates/ui-egui/tests/number_stored_as_text.rs new file mode 100644 index 000000000..8664cc6d3 --- /dev/null +++ b/crates/ui-egui/tests/number_stored_as_text.rs @@ -0,0 +1,44 @@ +//! A number stored as text gets Excel's green corner and a warning button whose menu converts it (#1994). + +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(active: &str) -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "B2", "input": "'123"})).unwrap(); + s.execute("cell.set", json!({"cell": "B3", "input": "'abc"})).unwrap(); + s.execute("selection.set", json!({"cell": active})).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(s, Default::default()), + ); + h.run_steps(4); + h +} + +#[test] +fn the_warning_button_converts_a_number_stored_as_text() { + let mut h = harness("B2"); + h.get_by_label("Error checking options").click(); + h.run_steps(3); + h.get_by_label("Convert to Number").click(); + h.run_steps(3); + let v = h.state_mut().session.execute("cell.get", json!({"cell": "B2"})).unwrap()["value"].clone(); + assert_eq!(v, json!(123.0)); + assert!(h.query_by_label("Error checking options").is_none(), "the button goes once the number is a number"); +} + +#[test] +fn text_that_is_not_a_number_gets_no_button() { + let h = harness("B3"); + assert!(h.query_by_label("Error checking options").is_none()); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 34cb502f5..abf5f669e 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-19 · **Change:** minor (Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Number Stored as Text indicator and Convert to Number) · **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 @@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Background error checking, first part (#1994): a constant whose text would be a number if typed (signs, thousands, currency, percent, exponent, parenthesised negative) gets Excel's green corner triangle; the active cell then shows a warning button whose menu names the problem and offers Convert to Number for the selection (`formulas.convertToNumber`). Dates, times and fractions kept as text and formula results are not flagged, as in Excel | | 2026-10-19 | minor | Ctrl+Enter (`range.fill`) gives each cell what typing the input there gives: a General cell gets the automatic number format (`5%`, `$5`, `3/4/2026`, `=TODAY()`) even when it already held a value, and a cell with its own format, a formatted row or column, or Text keeps it. Cells that held a value used to keep General (`5%` showed 0.05), and empty cells took the active cell's format | | 2026-10-18 | minor | Keytip letters typed with left Alt held continue the sequence (Alt+H, O, A / Alt+H, O, I ran nothing before: Alt+letter cancelled keytips); other Alt combinations, AltGr and macOS Option are unchanged (#1846) | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | From 377bb9226e245c09a4e6348474f2bdc8550ecfa1 Mon Sep 17 00:00:00 2001 From: Lord-Taw <149683190+Lord-Taw@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:24:12 -0300 Subject: [PATCH 31/39] Update i18n.rs ## CI Validation `cargo xtask ci` completed successfully.
Build Warning ```text warning: variants `At` and `Unknown` are never constructed --> crates\ui-egui\src\backstage.rs:416:5 ``` This warning appears to be pre-existing and is unrelated to the changes introduced in this PR. As it falls outside the scope of the current work, no changes were made to address it.
The build completed successfully, including the `wasm32-unknown-unknown` target checks. ```text wasm32-unknown-unknown check (gridcraft-calc, gridcraft-chart, gridcraft-core, gridcraft-engine, gridcraft-formula, gridcraft-functions, gridcraft-model, gridcraft-numfmt, gridcraft-pdf, gridcraft-web, gridcraft-xlsx): ok ```
CI Parity ```text CI summary: ok fmt ok clippy ok test ok assets ok layers ok wasm ```
--- crates/ui-egui/src/i18n.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6ca33e5a3..a3e52ce4d 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -629,7 +629,7 @@ mod tests { assert_eq!(Language::PtBr.tr_error("Select a chart first."), "Selecione um gráfico primeiro."); assert_eq!(Language::PtBr.tr_error("Sheet|Unknown failure"), "Sheet|Unknown failure"); assert_eq!(Language::PtBr.tr_error("command `chart`: Select a chart first."), "Selecione um gráfico primeiro."); - assert_eq!(Language::PtBr.tr_error("Printer Office: permission denied"),"Printer Office: permission denied"); + assert_eq!(Language::PtBr.tr_error("Printer Office: permission denied"), "Printer Office: permission denied"); assert_eq!(Language::Es.tr("Data"), "Datos"); assert_eq!(Language::Es.tr("Freeze Panes"), "Inmovilizar paneles"); assert_eq!(Language::Es.tr("Sheet1!A1"), "Sheet1!A1"); From f91f7c05d444d2cb4fd6c5739c16c33079a89659 Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:38:26 +0900 Subject: [PATCH 32/39] Print: JIS B4/B5/B6 and Japanese paper sizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Japanese Excel's B4/B5/B6 are the JIS sizes (257×364, 182×257 and 128×182 mm), but Page Setup's `b4`/`b5` were the ISO ones (250×353, 176×250): a Japanese B5 worksheet printed 8 mm narrow. `known_paper` now reads `B4`/`B5`/`B6` as JIS and keeps the ISO sizes reachable as `B4 (ISO)`/`B5 (ISO)`/`B6 (ISO)`. This also fixes the XLSX round-trip, whose `paperSize` ids 12/13 are the JIS sizes. Adds the Japanese papers Page Setup lacked: Japanese Postcard (はがき, 100×148 mm), Japanese Double Postcard (往復はがき, 148×200 mm), Japanese Envelope #3/#4 (長形3号/4号, 120×235 and 90×225 mm), Japanese Envelope Kaku #2/#3 (角形2号/3号, 240×332 and 216×277 mm), A6, and aliases such as `postcard`, `chou #3`, `kaku #2`; the xlsx paper table gains ids 42 (ISO B4) and 43/69–74/88 so these names survive save and reopen (the ISO B5/B6 names have no ECMA-376 paper id and stay engine-side). The pageSetup/pageLayout.size parameter docs list the names, the Page Layout Size menu lists them, and tests pin the point sizes, the PDF MediaBox for A4 (unchanged), B5, postcard, double postcard and kaku #2, landscape and fit-to-page, the unknown-name Letter fallback, and the XLSX round-trip. Docs: docs/target-app-parity.md print row + revision history, and ROADMAP.md progress log, dated 2026-10-21. --- ROADMAP.md | 4 +- crates/engine/src/cmd/print.rs | 94 ++++++++++++++++++++++++++++-- crates/engine/src/cmd/view.rs | 10 +++- crates/ui-egui/src/ribbon.rs | 23 +++++++- crates/xlsx/src/tables.rs | 55 ++++++++++++++++- crates/xlsx/src/tests/roundtrip.rs | 32 ++++++++++ docs/target-app-parity.md | 5 +- 7 files changed, 211 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..65fea18b7 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-21 · **Change:** minor (Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6) · **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-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×225 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here | | 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-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips | | 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/print.rs b/crates/engine/src/cmd/print.rs index 8350e56d7..663e59a49 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -50,7 +50,7 @@ pub fn specs() -> Vec { "Page Setup…", ["Page Layout", "Page Setup"], None, - "{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|Tabloid|Executive|Ledger|B5, margins?: {left,right,top,bottom,header,footer} (inches; also accepted as top-level keys), scale?: 10..400, fitWidth?, fitHeight? (pages; 0 = automatic), fitToPage?: bool, centerH?, centerV?, gridlines?, headings?, header?: \"&L&A&RPage &P of &N\", footer?, printArea?: \"A1:F50\" (\"\" clears), titleRows?: \"1:2\", titleCols?: \"A:A\", rowBreaks?: [46, 91], colBreaks?: [\"K\"]} → the sheet's print settings", + "{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|A6|Tabloid|Executive|Ledger|B4|B5|B6|B4 (ISO)|B5 (ISO)|B6 (ISO)|Japanese Postcard|Japanese Double Postcard|Japanese Envelope #3|Japanese Envelope #4|Japanese Envelope Kaku #2|Japanese Envelope Kaku #3 (B4/B5/B6 are the JIS sizes), margins?: {left,right,top,bottom,header,footer} (inches; also accepted as top-level keys), scale?: 10..400, fitWidth?, fitHeight? (pages; 0 = automatic), fitToPage?: bool, centerH?, centerV?, gridlines?, headings?, header?: \"&L&A&RPage &P of &N\", footer?, printArea?: \"A1:F50\" (\"\" clears), titleRows?: \"1:2\", titleCols?: \"A:A\", rowBreaks?: [46, 91], colBreaks?: [\"K\"]} → the sheet's print settings", has_doc, page_setup ), @@ -71,19 +71,35 @@ pub fn paper_size(name: &str) -> (f32, f32) { known_paper(name).unwrap_or((612.0, 792.0)) } +/// Paper sizes in points, portrait. `B4`/`B5`/`B6` are the **JIS** sizes Japanese Excel and its +/// e-mail attachments use (257×364, 182×257 and 128×182 mm); the ISO B series stays reachable as +/// `B4 (ISO)`/`B5 (ISO)`/`B6 (ISO)`. The Japanese postcards and envelopes are the JIS ones too. fn known_paper(name: &str) -> Option<(f32, f32)> { - let n = name.trim().to_ascii_lowercase().replace([' ', '-', '_'], ""); + // Spaces, hyphens, underscores and parentheses don't matter: `B5 (JIS)`, `b5-jis` and `b5` + // are one name. + let n = name.trim().to_ascii_lowercase().replace([' ', '-', '_', '(', ')'], ""); Some(match n.as_str() { "letter" | "usletter" => (612.0, 792.0), "legal" | "uslegal" => (612.0, 1008.0), "a4" => (595.28, 841.89), "a3" => (841.89, 1190.55), "a5" => (419.53, 595.28), + "a6" => (297.64, 419.53), "tabloid" | "11x17" => (792.0, 1224.0), "ledger" => (1224.0, 792.0), "executive" => (522.0, 756.0), - "b5" => (498.9, 708.66), - "b4" => (708.66, 1000.63), + "b4" | "b4jis" => (728.50, 1031.81), + "b5" | "b5jis" => (515.91, 728.50), + "b6" | "b6jis" => (362.83, 515.91), + "b4iso" => (708.66, 1000.63), + "b5iso" => (498.90, 708.66), + "b6iso" => (354.33, 498.90), + "japanesepostcard" | "postcard" => (283.46, 419.53), + "japanesedoublepostcard" | "doublepostcard" => (419.53, 566.93), + "japaneseenvelope#3" | "chou#3" => (340.16, 666.14), + "japaneseenvelope#4" | "chou#4" => (255.12, 637.80), + "japaneseenvelopekaku#2" | "kaku#2" => (680.31, 941.10), + "japaneseenvelopekaku#3" | "kaku#3" => (612.28, 785.20), _ => return None, }) } @@ -2061,6 +2077,76 @@ mod tests { assert!(s.doc().unwrap().wb.active().unwrap().print.print_area.is_some()); } + /// The Japanese paper sizes are the JIS ones: `B4`/`B5`/`B6` changed meaning from ISO, and the + /// ISO B series stays reachable by name. The postcards and envelopes are the JIS sizes too. + #[test] + fn japanese_paper_sizes_are_jis() { + for (name, w, h) in [ + ("A4", 595.28, 841.89), // unchanged + ("B4", 728.50, 1031.81), + ("B5", 515.91, 728.50), + ("B6", 362.83, 515.91), + ("B4 (JIS)", 728.50, 1031.81), + ("b5-jis", 515.91, 728.50), + ("B4 (ISO)", 708.66, 1000.63), + ("B5 (ISO)", 498.90, 708.66), + ("B6 (ISO)", 354.33, 498.90), + ("A6", 297.64, 419.53), + ("Japanese Postcard", 283.46, 419.53), + ("japanese double postcard", 419.53, 566.93), + ("Japanese Envelope #3", 340.16, 666.14), + ("chou #4", 255.12, 637.80), + ("Japanese Envelope Kaku #2", 680.31, 941.10), + ("kaku #3", 612.28, 785.20), + ] { + let got = paper_size(name); + assert!((got.0 - w).abs() < 0.01 && (got.1 - h).abs() < 0.01, "{name}: {got:?}"); + } + // Unknown names keep the old Letter fallback. + assert_eq!(paper_size("Napkin"), (612.0, 792.0)); + } + + /// Page Setup's paper reaches the PDF page: the MediaBox is the paper's, A4 is unchanged, B5 is + /// JIS B5, and the Japanese postcard prints at 283.46×419.53 pt. Landscape swaps the sides and + /// fit-to-page keeps the paper size. + #[test] + fn page_setup_paper_sizes_reach_the_pdf_page() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["x"]]})).unwrap(); + let close = |v: &Json, want: f64| (v.as_f64().unwrap() - want).abs() < 0.01; + for (paper, w, h) in [ + ("A4", 595.28f64, 841.89f64), + ("B5", 515.91, 728.50), + ("Japanese Postcard", 283.46, 419.53), + ("Japanese Double Postcard", 419.53, 566.93), + ("Japanese Envelope Kaku #2", 680.31, 941.10), + ] { + s.execute("pageLayout.pageSetup", json!({"paper": paper, "orientation": "portrait"})).unwrap(); + let pv = s.execute("file.printPreview", json!({})).unwrap(); + let page = &pv["pageList"][0]; + assert_eq!(page["paper"], paper); + assert!(close(&page["width"], w) && close(&page["height"], h), "{paper}: {page}"); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 1); + let want = format!("/MediaBox [0 0 {w} {h}]"); + assert!(contains(&pdf, &want), "{paper}: wanted {want}"); + } + // Landscape swaps the sides, and fit-to-page does not change the paper. + s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "landscape"})).unwrap(); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 1); + assert!(contains(&pdf, "/MediaBox [0 0 728.5 515.91]")); + s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "portrait"})).unwrap(); + let (pdf, _) = pdf_of(&mut s, json!({"fitToPage": true})); + assert!(contains(&pdf, "/MediaBox [0 0 515.91 728.5]")); + // `pageLayout.size` still takes any name, and an unknown one prints on Letter as before. + s.execute("pageLayout.size", json!({"paper": "Napkin"})).unwrap(); + let pv = s.execute("file.printPreview", json!({})).unwrap(); + assert!(close(&pv["pageList"][0]["width"], 612.0) && close(&pv["pageList"][0]["height"], 792.0)); + // Page Setup refuses an unknown name. + assert!(s.execute("pageLayout.pageSetup", json!({"paper": "Napkin"})).is_err()); + } + #[test] fn hostile_settings_never_panic_and_cap_pages() { let mut s = s(); diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index edcacf57d..cbaa59bec 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -35,7 +35,15 @@ pub fn specs() -> Vec { }), cmd!("view.zoomToSelection", "Zoom to Selection", ["View", "Zoom"], None, "{viewWidth?, viewHeight?}", has_doc, zoom_selection), cmd!("pageLayout.orientation", "Orientation", ["Page Layout", "Page Setup"], None, "{orientation: portrait|landscape}", has_doc, orientation), - cmd!("pageLayout.size", "Size", ["Page Layout", "Page Setup"], None, "{paper: Letter|Legal|A4|A3|A5|Tabloid|Executive}", has_doc, paper), + cmd!( + "pageLayout.size", + "Size", + ["Page Layout", "Page Setup"], + None, + "{paper: Letter|Legal|A4|A3|A5|A6|Tabloid|Executive|Ledger|B4|B5|B6|B4 (ISO)|B5 (ISO)|B6 (ISO)|Japanese Postcard|Japanese Double Postcard|Japanese Envelope #3|Japanese Envelope #4|Japanese Envelope Kaku #2|Japanese Envelope Kaku #3 (B4/B5/B6 are the JIS sizes)}", + has_doc, + paper + ), cmd!( "pageLayout.margins", "Margins", diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index a0ab5a339..513274869 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1663,7 +1663,28 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { let s = big_button(ui, Icon::Paper, "Size", "Paper Size", true); control_keytip(app, ui, &s, "pageLayout.size"); egui::Popup::menu(&s).show(|ui| { - for p in ["Letter", "Legal", "Tabloid", "Executive", "A3", "A4", "A5"] { + // B4/B5/B6 are the JIS sizes Japanese Excel uses; the ISO ones stay selectable by name. + for p in [ + "Letter", + "Legal", + "Tabloid", + "Executive", + "A3", + "A4", + "A5", + "A6", + "B4", + "B5", + "B6", + "Japanese Postcard", + "Japanese Double Postcard", + "Japanese Envelope #3", + "Japanese Envelope #4", + "Japanese Envelope Kaku #2", + "Japanese Envelope Kaku #3", + "B4 (ISO)", + "B5 (ISO)", + ] { if ui.button(p).clicked() { act(app, "pageLayout.size", json!({"paper": p})); } diff --git a/crates/xlsx/src/tables.rs b/crates/xlsx/src/tables.rs index fe7cbdc42..cf14d5b14 100644 --- a/crates/xlsx/src/tables.rs +++ b/crates/xlsx/src/tables.rs @@ -64,8 +64,10 @@ pub const INDEXED: [u32; 64] = [ 0x003366, 0x339966, 0x003300, 0x333300, 0x993300, 0x993366, 0x333399, 0x333333, // 56-63 ]; -/// Paper sizes (`pageSetup paperSize`) we name. -const PAPERS: [(u32, &str); 14] = [ +/// Paper sizes (`pageSetup paperSize`) we name. ID 12/13 are the JIS B4/B5 that +/// `pageLayout.pageSetup` calls `B4`/`B5`; 42 is ISO B4, and 43–88 are the Japanese postcards, +/// envelopes and B6. The engine's `known_paper` uses the same names and sizes. +const PAPERS: [(u32, &str); 23] = [ (1, "Letter"), (3, "Tabloid"), (4, "Ledger"), @@ -80,14 +82,40 @@ const PAPERS: [(u32, &str); 14] = [ (20, "Envelope #10"), (27, "Envelope DL"), (34, "Envelope B5"), + (42, "B4 (ISO)"), + (43, "Japanese Postcard"), + (69, "Japanese Double Postcard"), + (70, "A6"), + (71, "Japanese Envelope Kaku #2"), + (72, "Japanese Envelope Kaku #3"), + (73, "Japanese Envelope #3"), + (74, "Japanese Envelope #4"), + (88, "B6"), ]; pub fn paper_name(id: u32) -> Option<&'static str> { PAPERS.iter().find(|(i, _)| *i == id).map(|(_, n)| *n) } +/// The file ID for a paper name. Spelling is loose like `known_paper`'s — spaces, hyphens, +/// underscores and parentheses don't matter, and the JIS/ISO and Japanese-envelope spellings the +/// page-setup commands take are folded onto the name in [`PAPERS`]. pub fn paper_id(name: &str) -> Option { - PAPERS.iter().find(|(_, n)| n.eq_ignore_ascii_case(name.trim())).map(|(i, _)| *i) + let norm = |s: &str| s.trim().to_ascii_lowercase().replace([' ', '-', '_', '(', ')'], ""); + let normalized = norm(name); + let alias = match normalized.as_str() { + "b4jis" => "b4", + "b5jis" => "b5", + "b6jis" => "b6", + "postcard" => "japanesepostcard", + "doublepostcard" => "japanesedoublepostcard", + "chou#3" => "japaneseenvelope#3", + "chou#4" => "japaneseenvelope#4", + "kaku#2" => "japaneseenvelopekaku#2", + "kaku#3" => "japaneseenvelopekaku#3", + other => other, + }; + PAPERS.iter().find(|(_, n)| norm(n) == alias).map(|(i, _)| *i) } /// Functions introduced after Excel 2007 that files store with the `_xlfn.` prefix. Entries with @@ -339,5 +367,26 @@ mod tests { assert_eq!(builtin_id(builtin_format(5).unwrap()), None); assert_eq!(paper_name(9), Some("A4")); assert_eq!(paper_id("a4"), Some(9)); + // 12/13 are the JIS B4/B5 Japanese Excel uses; 42 is ISO B4. + assert_eq!(paper_name(12), Some("B4")); + assert_eq!(paper_id("b5"), Some(13)); + assert_eq!(paper_id("B5 (JIS)"), Some(13)); + assert_eq!(paper_id("B4 (ISO)"), Some(42)); + // The Japanese papers page setup names. + assert_eq!(paper_name(43), Some("Japanese Postcard")); + assert_eq!(paper_name(88), Some("B6")); + for (name, id) in [ + ("Japanese Postcard", 43), + ("japanese double postcard", 69), + ("A6", 70), + ("Japanese Envelope Kaku #2", 71), + ("kaku #3", 72), + ("Japanese Envelope #3", 73), + ("chou #4", 74), + ("B6 (JIS)", 88), + ] { + assert_eq!(paper_id(name), Some(id), "{name}"); + } + assert_eq!(paper_id("Napkin"), None); } } diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 7c7c19056..81de38d67 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -1165,3 +1165,35 @@ fn doc_props_keep_boundary_whitespace() { let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); assert_eq!(back.props, wb.props); } + +/// Japanese paper sizes survive an XLSX save and reopen. IDs 12/13 are the JIS B4/B5; the +/// Japanese postcards and envelopes have their own IDs, and `B4 (ISO)` keeps ISO B4. The ISO B5/B6 +/// names only exist in the engine: ECMA-376 has no `paperSize` id for them (only ISO B4), so they +/// are not part of the round-trip. +#[test] +fn japanese_paper_sizes_round_trip() { + for paper in [ + "B4", + "B5", + "B6", + "B4 (ISO)", + "A6", + "Japanese Postcard", + "Japanese Double Postcard", + "Japanese Envelope #3", + "Japanese Envelope #4", + "Japanese Envelope Kaku #2", + "Japanese Envelope Kaku #3", + ] { + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().print.paper = paper.into(); + let (back, report) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert!(report.warnings.is_empty(), "{paper}: {:?}", report.warnings); + assert_eq!(back.sheet(0).unwrap().print.paper, paper); + } + // An alias is written as the file's own name and read back as that name. + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().print.paper = "B5 (JIS)".into(); + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.sheet(0).unwrap().print.paper, "B5"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b623124d9..56449a3f3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and row formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (Page Setup paper sizes match Japanese Excel: B4/B5/B6 are the JIS sizes, with ISO B4/B5/B6 by name and the Japanese postcards, envelopes and A6 added; no score change) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -86,7 +86,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Sort, filter, tables, validation, outline | 8 | ~82% | ~70% | 10–16 | Multi-level sort, AutoFilter, Advanced Filter, tables with totals, validation, outline, subtotals, consolidate. Bugs: subtotal header (#131), validation edits (#129), table style panic caught (#130); no slicers on tables | | PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, values on rows or columns, date grouping, show-values-as, refresh, calculated fields, XLSX round-trip. Missing: slicers, timelines, calculated items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel | | Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) | -| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | +| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. Paper sizes match Japanese Excel: B4/B5/B6 are the JIS sizes, ISO B4/B5/B6 stay reachable by name, and Japanese Postcard (はがき), Japanese Double Postcard (往復はがき), the chou/kaku envelopes and A6 are available. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | | What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer and Limits reports; no Sensitivity report or options dialog). Missing: Forecast Sheet, Analyze Data | | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | @@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Page Setup paper sizes: `B4`/`B5`/`B6` are the JIS sizes Japanese Excel uses (ISO B4/B5/B6 by name), and Japanese Postcard, Japanese Double Postcard, the chou/kaku envelopes and A6 are added; PDF MediaBox and `file.print` follow, and the Size menu lists them | | 2026-10-12 | minor | Essentials AutoFit evidence: rows auto-fit oversized fonts on open and row formatting (#833) | | 2026-10-12 | minor | Sheet tabs: reveal the selected tab on navigation and resize while preserving manual scrolling (#681); no score change | | 2026-10-12 | minor | PivotTables: values on rows (Σ Values in Rows at any position, all three layouts; `pivot.moveField` and the field pane move it; `dataOnRows` round-trips) (no score change) | From b0926335619f463b9781703a34071b356a191c42 Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:24:30 +0900 Subject: [PATCH 33/39] =?UTF-8?q?Print:=20Japanese=20Envelope=20#4=20is=20?= =?UTF-8?q?90=C3=97205=20mm=20and=20add=20B6=20(ISO)=20to=20the=20Size=20m?= =?UTF-8?q?enu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 長形4号 is a 90×205 mm envelope (90×225 mm is 長形40号, a different one), so the paper table gave it a height 20 mm too tall — and XLSX paper id 74, the PDF MediaBox and the print preview all followed it. Use 205 mm (581.10 pt) and pin the corrected size with a dedicated assertion; the ROADMAP progress row is corrected too. 長形40号 is not added: it is a different envelope. The Page Layout ▸ Size menu stopped at `B5 (ISO)` and never offered `B6 (ISO)`, which the engine and the command docs support; add it. --- ROADMAP.md | 2 +- crates/engine/src/cmd/print.rs | 10 ++++++++-- crates/ui-egui/src/ribbon.rs | 1 + 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 65fea18b7..fc7c337d1 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-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×225 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here | +| 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here | | 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/print.rs b/crates/engine/src/cmd/print.rs index 663e59a49..43119b0b0 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -97,7 +97,8 @@ fn known_paper(name: &str) -> Option<(f32, f32)> { "japanesepostcard" | "postcard" => (283.46, 419.53), "japanesedoublepostcard" | "doublepostcard" => (419.53, 566.93), "japaneseenvelope#3" | "chou#3" => (340.16, 666.14), - "japaneseenvelope#4" | "chou#4" => (255.12, 637.80), + // 長形4号 is 90×205 mm; 90×225 mm is 長形40号, a different envelope, so it stays out. + "japaneseenvelope#4" | "chou#4" => (255.12, 581.10), "japaneseenvelopekaku#2" | "kaku#2" => (680.31, 941.10), "japaneseenvelopekaku#3" | "kaku#3" => (612.28, 785.20), _ => return None, @@ -2095,13 +2096,18 @@ mod tests { ("Japanese Postcard", 283.46, 419.53), ("japanese double postcard", 419.53, 566.93), ("Japanese Envelope #3", 340.16, 666.14), - ("chou #4", 255.12, 637.80), + ("chou #4", 255.12, 581.10), ("Japanese Envelope Kaku #2", 680.31, 941.10), ("kaku #3", 612.28, 785.20), ] { let got = paper_size(name); assert!((got.0 - w).abs() < 0.01 && (got.1 - h).abs() < 0.01, "{name}: {got:?}"); } + // 長形4号 is 90×205 mm (90×225 mm is 長形40号), so its height is 205 mm = 581.10 pt — not the + // 225 mm it once had. + let chou4 = paper_size("Japanese Envelope #4"); + assert_eq!((chou4.0, chou4.1), (255.12, 581.10)); + assert_eq!(chou4, paper_size("chou #4")); // Unknown names keep the old Letter fallback. assert_eq!(paper_size("Napkin"), (612.0, 792.0)); } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 513274869..0f313f865 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1684,6 +1684,7 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { "Japanese Envelope Kaku #3", "B4 (ISO)", "B5 (ISO)", + "B6 (ISO)", ] { if ui.button(p).clicked() { act(app, "pageLayout.size", json!({"paper": p})); From 5b908c04f8b00ce4f9628bee76598cfeadba566b Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 21 Oct 2026 20:24:15 -0400 Subject: [PATCH 34/39] core: iterative number coercion for nested parentheses (#2180) Parenthesised negatives were parsed by recursing once per level, so runtime text such as REPT("(",32767)&"1"&REPT(")",32767) overflowed the stack. Strip the parentheses in a loop, toggling the sign per level; results are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/core/src/parse.rs | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index b465beaf9..3be6d1f0b 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -50,13 +50,22 @@ pub fn parse_number_text_in(s: &str, sys: DateSystem) -> Option { } fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> { + // Parenthesised negatives: (100) = -100. Each level negates, so ((100)) = 100. Stripped in a + // loop, not by recursion: runtime text (e.g. from REPT) can nest thousands deep (#2180). + let mut t = t; + let mut neg = false; + while let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) { + t = inner.trim(); + neg = !neg; + } + let (n, f) = parse_unparenthesised(t, sys)?; + Some((if neg { -n } else { n }, f)) +} + +fn parse_unparenthesised(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> { if t.is_empty() { return None; } - // Parenthesised negatives: (100) = -100. - if let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) { - return parse_numeric(inner.trim(), sys).map(|(n, f)| (-n, f)); - } if let Some(r) = parse_plain(t) { return Some(r); } @@ -352,6 +361,24 @@ mod tests { assert_eq!(p(".5").value, Value::Number(0.5)); } + #[test] + fn deeply_nested_parentheses_do_not_overflow() { + // #2180: coercion used to recurse once per parenthesis and overflow the stack. + let depth = 100_000; + let s = format!("{}1{}", "(".repeat(depth), ")".repeat(depth)); + assert_eq!(parse_number_text(&s), Some(1.0)); + let odd = format!("{}1{}", "(".repeat(depth + 1), ")".repeat(depth + 1)); + assert_eq!(parse_number_text(&odd), Some(-1.0)); + let unbalanced = format!("{}1{}", "(".repeat(depth), ")".repeat(depth - 1)); + assert_eq!(parse_number_text(&unbalanced), None); + assert_eq!(p(&s).value, Value::Number(1.0)); + // Existing results are unchanged. + assert_eq!(parse_number_text("(100)"), Some(-100.0)); + assert_eq!(parse_number_text("((100))"), Some(100.0)); + assert_eq!(parse_number_text("( $1,200.50 )"), Some(-1200.5)); + assert_eq!(parse_number_text("()"), None); + } + #[test] fn texts_and_specials() { assert_eq!(p("hello").value, Value::text("hello")); From 8daecc7777e7482643f19696ed9924d7b9634dd5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:35:12 +0000 Subject: [PATCH 35/39] Integration: #1975 remove stray empty file '=560' --- =560 | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 =560 diff --git a/=560 b/=560 deleted file mode 100644 index e69de29bb..000000000 From 1aa5d4ea79f630d9e5f8f193f9ea843c5fb46e0c Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:35:18 +0000 Subject: [PATCH 36/39] Integration: #1996 same_name compares ASCII names without allocating --- crates/formula/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/formula/src/lib.rs b/crates/formula/src/lib.rs index 77f4e5899..32ac0e17c 100644 --- a/crates/formula/src/lib.rs +++ b/crates/formula/src/lib.rs @@ -20,6 +20,9 @@ pub use printer::{print, print_r1c1, quote_sheet}; /// folds lower-casing misses for letters a name may hold (`ß`/`ẞ` -> `ss`, `ſ` -> `s`, `ς` -> `σ`). /// Dotless `ı` stays distinct from `i`, as outside Turkic languages. pub fn same_name(a: &str, b: &str) -> bool { + if a.is_ascii() && b.is_ascii() { + return a.eq_ignore_ascii_case(b); + } let fold = |s: &str| -> String { s.chars() .flat_map(char::to_lowercase) From 826a22dc2b54232655f4e0642885b4bad7f8ffd6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:36:21 +0000 Subject: [PATCH 37/39] Integration: #2131 remove a leftover conflict marker line in ROADMAP.md --- ROADMAP.md | 1 - 1 file changed, 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index ebec2f475..c9e8043d6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,7 +3,6 @@ **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-21 · **Change:** minor (Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365) ->>>>>>> upr/2131 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. From 75f6c92eab114652cfb0cd128e2e7843443e52c1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:36:41 +0000 Subject: [PATCH 38/39] Integration: #2086 test that the strip beside the column headings is filled in dark mode --- crates/ui-egui/tests/scrollbar_column_fill.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 crates/ui-egui/tests/scrollbar_column_fill.rs diff --git a/crates/ui-egui/tests/scrollbar_column_fill.rs b/crates/ui-egui/tests/scrollbar_column_fill.rs new file mode 100644 index 000000000..cd14f09ba --- /dev/null +++ b/crates/ui-egui/tests/scrollbar_column_fill.rs @@ -0,0 +1,28 @@ +//! The strip beside the column headings, above the vertical scrollbar, takes the bar colour in +//! dark mode instead of showing a light square (#2086). + +use egui::{Shape, pos2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::{SheetApp, theme::Tokens}; +use serde_json::json; + +#[test] +fn dark_theme_fills_the_strip_right_of_the_last_column_heading() { + let mut session = Session::new(); + session.new_workbook(); + 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(session, Default::default()), + ); + h.state_mut().run("view.theme", json!({"mode": "dark"})).unwrap(); + h.run_steps(6); + let grid = h.state().grid.rect.expect("grid rect"); + // Inside the scrollbar column, at the height of the column headings. + let probe = pos2(grid.right() + 7.0, grid.top() + 5.0); + let fill = Tokens::dark().tab_bar; + let covered = h.output().shapes.iter().any(|s| matches!(&s.shape, Shape::Rect(r) if r.fill == fill && r.rect.contains(probe))); + assert!(covered, "no tab_bar fill covers {probe:?}"); +} From 59e6f123baba54c1c88c2dbc91bc12cc87ee3243 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:36:59 +0000 Subject: [PATCH 39/39] Integration: #2095 error-check colours come from theme tokens --- crates/ui-egui/src/grid.rs | 9 +++------ crates/ui-egui/src/theme.rs | 10 ++++++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index e7a8ee28b..eef4995b5 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1385,7 +1385,7 @@ pub(crate) fn paint_quadrant( let s = 6.0 * z.max(0.7); p.add(egui::Shape::convex_polygon( vec![rect.left_top(), pos2(rect.left() + s, rect.top()), pos2(rect.left(), rect.top() + s)], - ERROR_CHECK_GREEN, + t.error_check_green, Stroke::NONE, )); } @@ -3042,9 +3042,6 @@ fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) { } } -/// Excel's background error-checking indicator colour. -const ERROR_CHECK_GREEN: Color32 = Color32::from_rgb(0x1E, 0x8E, 0x3E); - /// The warning button beside an active cell holding a number stored as text, as in Excel: its /// menu names the problem and offers Convert to Number for the selection (#1994). fn error_check_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { @@ -3075,10 +3072,10 @@ fn error_check_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) let r = 6.0; p.add(egui::Shape::convex_polygon( vec![pos2(c.x, c.y - r), pos2(c.x + r, c.y), pos2(c.x, c.y + r), pos2(c.x - r, c.y)], - Color32::from_rgb(0xF2, 0xB7, 0x05), + t.warning_fill, Stroke::NONE, )); - p.text(c, egui::Align2::CENTER_CENTER, "!", egui::FontId::proportional(10.0), Color32::BLACK); + p.text(c, egui::Align2::CENTER_CENTER, "!", egui::FontId::proportional(10.0), t.warning_ink); egui::Popup::menu(&resp).show(|ui| { ui.label(egui::RichText::new(lang.tr("Number Stored as Text")).strong()); ui.separator(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 08097ba27..1c780d952 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -45,6 +45,10 @@ pub struct Tokens { pub menu_bg: Color32, pub shadow: Color32, pub danger: Color32, + /// Background error checking: the corner triangle, and the warning button's diamond and mark. + pub error_check_green: Color32, + pub warning_fill: Color32, + pub warning_ink: Color32, /// Info-bar (notice strip) fills, by severity. pub notice_warn_bg: Color32, pub notice_warn_border: Color32, @@ -88,6 +92,9 @@ impl Tokens { menu_bg: Color32::WHITE, shadow: Color32::from_black_alpha(40), danger: Color32::from_rgb(0xC4, 0x2B, 0x1C), + error_check_green: Color32::from_rgb(0x1E, 0x8E, 0x3E), + warning_fill: Color32::from_rgb(0xF2, 0xB7, 0x05), + warning_ink: Color32::BLACK, notice_warn_bg: Color32::from_rgb(0xFF, 0xF4, 0xCE), notice_warn_border: Color32::from_rgb(0xF0, 0xC3, 0x6E), notice_info_bg: Color32::from_rgb(0xEA, 0xF2, 0xFB), @@ -130,6 +137,9 @@ impl Tokens { menu_bg: Color32::from_rgb(0x30, 0x30, 0x30), shadow: Color32::from_black_alpha(90), danger: Color32::from_rgb(0xF1, 0x70, 0x5F), + error_check_green: Color32::from_rgb(0x4C, 0xC2, 0x6B), + warning_fill: Color32::from_rgb(0xF2, 0xB7, 0x05), + warning_ink: Color32::BLACK, notice_warn_bg: Color32::from_rgb(0x4A, 0x3F, 0x1E), notice_warn_border: Color32::from_rgb(0x7A, 0x67, 0x30), notice_info_bg: Color32::from_rgb(0x24, 0x33, 0x44),