From 74de1b32ebc2097fb5545f3851c3db0ae6b136c7 Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Wed, 14 Oct 2026 18:14:07 -0500 Subject: [PATCH 01/56] Keytips: Alt, H, B border letters apply their own preset Every Alt, H, B letter ran home.borders with no params, which defaults to a bottom border, so H,B,N (No Border) and H,B,A (All Borders) both drew a bottom border. keytips.json entries now take optional fixed `params` (Action::CommandWith / DialogWith), and each Borders menu letter passes the preset Excel's menu gives it. H,B,M opens Format Cells on the Border tab. Erase Border, Line Color and Line Style are reserved (pending) instead of drawing a bottom border. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 +- crates/ui-egui/src/keytips.rs | 5 + crates/ui-egui/src/keytips/keytip_table.rs | 4 +- .../src/keytips/keytip_table/generated.rs | 64 ++++++------- crates/ui-egui/src/keytips/keytips.json | 90 +++++++++++++----- crates/ui-egui/src/lib.rs | 12 +++ crates/ui-egui/tests/keytips.rs | 93 +++++++++++++++++++ crates/ui-egui/tests/keytips_generated.rs | 17 +++- docs/ui-parity.md | 5 +- xtask/src/keytips.rs | 39 ++++++-- 10 files changed, 260 insertions(+), 72 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..708f80383 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 (Alt, H, B border keytips apply their own preset) · **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 | Border keytips: each Alt, H, B letter applies the preset Excel's Borders menu gives it (H,B,O bottom, H,B,B bottom double, H,B,N no border, H,B,A all borders and the rest); they all drew a bottom border before. H,B,M opens Format Cells on the Border tab | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index e1163d9bb..07324dac7 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -10,7 +10,12 @@ pub mod keytip_table; pub enum Action { Tab(&'static str), Command(&'static str), + /// A command run with fixed params (a JSON object literal from keytips.json `params`), for + /// menu items that share one command id, like the Borders presets (HBO bottom, HBN none). + CommandWith(&'static str, &'static str), Dialog(&'static str), + /// A dialog opened with fixed params, like Format Cells on its Border tab (HBM). + DialogWith(&'static str, &'static str), /// The target command does not exist yet: the sequence is reserved, its badge shows, and /// invoking it is a silent no-op until the command lands (see keytips.json `pending`). Pending(&'static str), diff --git a/crates/ui-egui/src/keytips/keytip_table.rs b/crates/ui-egui/src/keytips/keytip_table.rs index 5415b9d2b..d14af141b 100644 --- a/crates/ui-egui/src/keytips/keytip_table.rs +++ b/crates/ui-egui/src/keytips/keytip_table.rs @@ -50,8 +50,8 @@ pub fn is_group_level(seq: &str) -> bool { fn matches_target(action: &Action, target: &str) -> bool { match action { - Action::Command(id) | Action::Pending(id) | Action::Control(id) => *id == target, - Action::Dialog(name) => format!("dialog:{name}") == target, + Action::Command(id) | Action::CommandWith(id, _) | Action::Pending(id) | Action::Control(id) => *id == target, + Action::Dialog(name) | Action::DialogWith(name, _) => format!("dialog:{name}") == target, Action::Tab(_) => false, } } diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 79920b5b8..5f750e770 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -37,40 +37,40 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("HAN", Entry::Finish(Action::Command("home.accounting"))), // Excel: Accounting Format; fires immediately in Excel; deeper aliases dropped as unreachable ("HAS", Entry::Finish(Action::Command("home.addIns"))), // Excel: Add-ins; containment(Add-ins) ("HB", Entry::Continue), // Excel: Borders Menu; group level: - ("HBA", Entry::Finish(Action::Command("home.borders"))), // Excel: All Borders; containment(Borders) - ("HBB", Entry::Finish(Action::Command("home.borders"))), // Excel: Bottom Double Border; border-side TBD - ("HBC", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Thick Bottom Border; border-side TBD - ("HBD", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Bottom Border; border-side TBD - ("HBE", Entry::Finish(Action::Command("home.borders"))), // Excel: Erase Border; border-side TBD - ("HBG", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border Grid; containment(Draw Border) - ("HBH", Entry::Finish(Action::Command("home.borders"))), // Excel: Thick Bottom Border; border-side TBD - ("HBI", Entry::Finish(Action::Command("home.borders"))), // Excel: Line Color; Line Color (submenu TBD) - ("HBL", Entry::Finish(Action::Command("home.borders"))), // Excel: Left Border; border-side TBD - ("HBM", Entry::Finish(Action::Command("home.borders"))), // Excel: More Borders; containment(Borders) - ("HBN", Entry::Finish(Action::Command("home.borders"))), // Excel: No Border; border-side TBD - ("HBO", Entry::Finish(Action::Command("home.borders"))), // Excel: Bottom Border; border-side TBD - ("HBP", Entry::Finish(Action::Command("home.borders"))), // Excel: Top Border; border-side TBD - ("HBR", Entry::Finish(Action::Command("home.borders"))), // Excel: Right Border; border-side TBD - ("HBS", Entry::Finish(Action::Command("home.borders"))), // Excel: Outside Borders; containment(Borders) - ("HBT", Entry::Finish(Action::Command("home.borders"))), // Excel: Thick Outside Borders; border-side TBD - ("HBU", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Double Bottom Border; border-side TBD - ("HBW", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border - ("HBY", Entry::Finish(Action::Command("home.borders"))), // Excel: Line Style; Line Style (submenu TBD) - ("HC", Entry::Finish(Action::Command("edit.copy"))), // Excel: Copy; fires immediately in Excel; deeper aliases dropped as unreachable - ("HDC", Entry::Finish(Action::Command("home.deleteColumns"))), // Excel: Delete Columns; Delete Columns - ("HDD", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Delete Cells - ("HDR", Entry::Finish(Action::Command("home.deleteRows"))), // Excel: Delete Rows; Delete Rows - ("HDS", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Delete Sheet - ("HEA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Clear All - ("HEC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Clear Contents - ("HEF", Entry::Finish(Action::Command("edit.clearFormats"))), // Excel: Clear Formats + ("HBA", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"all\"}"))), // Excel: All Borders; Borders menu item + ("HBB", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"doubleBottom\"}"))), // Excel: Bottom Double Border; Borders menu item + ("HBC", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topThickBottom\"}"))), // Excel: Top and Thick Bottom Border; Borders menu item + ("HBD", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topBottom\"}"))), // Excel: Top and Bottom Border; Borders menu item + ("HBE", Entry::Finish(Action::Pending("home.eraseBorder"))), // pending; Excel: Erase Border; pending: Excel arms an eraser pen; GridCraft has no border pen mode yet + ("HBG", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border Grid; containment(Draw Border) + ("HBH", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"thickBottom\"}"))), // Excel: Thick Bottom Border; Borders menu item + ("HBI", Entry::Finish(Action::Pending("home.borderLineColor"))), // pending; Excel: Line Color; pending: Line Color submenu + ("HBL", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"left\"}"))), // Excel: Left Border; Borders menu item + ("HBM", Entry::Finish(Action::DialogWith("formatCells", "{\"tab\":\"Border\"}"))), // Excel: More Borders; Format Cells on its Border tab + ("HBN", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"none\"}"))), // Excel: No Border; Borders menu item + ("HBO", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"bottom\"}"))), // Excel: Bottom Border; Borders menu item + ("HBP", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"top\"}"))), // Excel: Top Border; Borders menu item + ("HBR", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"right\"}"))), // Excel: Right Border; Borders menu item + ("HBS", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"outside\"}"))), // Excel: Outside Borders; Borders menu item + ("HBT", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"thickOutside\"}"))), // Excel: Thick Outside Borders; Borders menu item + ("HBU", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topDoubleBottom\"}"))), // Excel: Top and Double Bottom Border; Borders menu item + ("HBW", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border + ("HBY", Entry::Finish(Action::Pending("home.borderLineStyle"))), // pending; Excel: Line Style; pending: Line Style submenu + ("HC", Entry::Finish(Action::Command("edit.copy"))), // Excel: Copy; fires immediately in Excel; deeper aliases dropped as unreachable + ("HDC", Entry::Finish(Action::Command("home.deleteColumns"))), // Excel: Delete Columns; Delete Columns + ("HDD", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Delete Cells + ("HDR", Entry::Finish(Action::Command("home.deleteRows"))), // Excel: Delete Rows; Delete Rows + ("HDS", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Delete Sheet + ("HEA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Clear All + ("HEC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Clear Contents + ("HEF", Entry::Finish(Action::Command("edit.clearFormats"))), // Excel: Clear Formats ("HEL", Entry::Finish(Action::Command("edit.clearHyperlinks"))), // Excel: Clear Hyperlinks - ("HEM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Clear Comments and Notes; containment(Clear Comments and Notes) - ("HFC", Entry::Finish(Action::Control("home.fontColor"))), // Excel: Font Color; opens the font-color palette + ("HEM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Clear Comments and Notes; containment(Clear Comments and Notes) + ("HFC", Entry::Finish(Action::Control("home.fontColor"))), // Excel: Font Color; opens the font-color palette ("HFDC", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Find Conditional Formatting; containment(Conditional Formatting) - ("HFDF", Entry::Finish(Action::Dialog("find"))), // Excel: Find - ("HFDG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Go To - ("HFDM", Entry::Finish(Action::Command("edit.find"))), // Excel: Find Notes; containment(Find) + ("HFDF", Entry::Finish(Action::Dialog("find"))), // Excel: Find + ("HFDG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Go To + ("HFDM", Entry::Finish(Action::Command("edit.find"))), // Excel: Find Notes; containment(Find) ("HFDO", Entry::Finish(Action::Pending("home.selectObjects"))), // pending; Excel: Select Objects; Select Objects; reserved until the command lands ("HFDP", Entry::Finish(Action::Command("arrange.selectionPane"))), // Excel: Selection Pane ("HFDR", Entry::Finish(Action::Dialog("find"))), // Excel: Replace; opens Find and Replace diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 59aeadcfb..b24241c3c 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -8,6 +8,7 @@ " \"continue\" (an explicit group level with no deeper sequences yet).", " - Intermediate levels need no entries: the matcher waits automatically when deeper sequences exist.", " - pending: true = the command does not exist yet; the sequence stays reserved and silent until it lands.", + " - params: an optional JSON object passed to a command or dialog, for menu items that share one id (the Borders presets: HBO {\"preset\": \"bottom\"}).", " - Keep entries grouped by tab and sorted by sequence within a group; the FIRST entry for a tab is its canonical badge (legacy aliases come after it)." ], "sequences": [ @@ -196,36 +197,49 @@ "seq": "HBA", "kind": "command", "target": "home.borders", + "params": { + "preset": "all" + }, "excel": "All Borders", - "note": "containment(Borders)" + "note": "Borders menu item" }, { "seq": "HBB", "kind": "command", "target": "home.borders", + "params": { + "preset": "doubleBottom" + }, "excel": "Bottom Double Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBC", "kind": "command", "target": "home.borders", + "params": { + "preset": "topThickBottom" + }, "excel": "Top and Thick Bottom Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBD", "kind": "command", "target": "home.borders", + "params": { + "preset": "topBottom" + }, "excel": "Top and Bottom Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBE", "kind": "command", - "target": "home.borders", + "target": "home.eraseBorder", + "pending": true, "excel": "Erase Border", - "note": "border-side TBD" + "note": "pending: Excel arms an eraser pen; GridCraft has no border pen mode yet" }, { "seq": "HBG", @@ -238,78 +252,109 @@ "seq": "HBH", "kind": "command", "target": "home.borders", + "params": { + "preset": "thickBottom" + }, "excel": "Thick Bottom Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBI", "kind": "command", - "target": "home.borders", + "target": "home.borderLineColor", + "pending": true, "excel": "Line Color", - "note": "Line Color (submenu TBD)" + "note": "pending: Line Color submenu" }, { "seq": "HBL", "kind": "command", "target": "home.borders", + "params": { + "preset": "left" + }, "excel": "Left Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBM", - "kind": "command", - "target": "home.borders", + "kind": "dialog", + "target": "formatCells", + "params": { + "tab": "Border" + }, "excel": "More Borders", - "note": "containment(Borders)" + "note": "Format Cells on its Border tab" }, { "seq": "HBN", "kind": "command", "target": "home.borders", + "params": { + "preset": "none" + }, "excel": "No Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBO", "kind": "command", "target": "home.borders", + "params": { + "preset": "bottom" + }, "excel": "Bottom Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBP", "kind": "command", "target": "home.borders", + "params": { + "preset": "top" + }, "excel": "Top Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBR", "kind": "command", "target": "home.borders", + "params": { + "preset": "right" + }, "excel": "Right Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBS", "kind": "command", "target": "home.borders", + "params": { + "preset": "outside" + }, "excel": "Outside Borders", - "note": "containment(Borders)" + "note": "Borders menu item" }, { "seq": "HBT", "kind": "command", "target": "home.borders", + "params": { + "preset": "thickOutside" + }, "excel": "Thick Outside Borders", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBU", "kind": "command", "target": "home.borders", + "params": { + "preset": "topDoubleBottom" + }, "excel": "Top and Double Bottom Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBW", @@ -321,9 +366,10 @@ { "seq": "HBY", "kind": "command", - "target": "home.borders", + "target": "home.borderLineStyle", + "pending": true, "excel": "Line Style", - "note": "Line Style (submenu TBD)" + "note": "pending: Line Style submenu" }, { "seq": "HC", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f96cdac3c..4e7de595a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1193,7 +1193,19 @@ impl SheetApp { keytips::Action::Command(id @ ("edit.copy" | "edit.cut")) => self.copy_to_clipboard(ctx, id), keytips::Action::Command("edit.paste") => self.paste_from_clipboard(), keytips::Action::Command(id) => self.run_or_alert(id, json!({})), + keytips::Action::CommandWith(id, params) => { + let params: Json = serde_json::from_str(params).unwrap_or_else(|_| json!({})); + // A Borders preset picked by key tip becomes the split button's face, as a + // click on the same menu item does. + if id == "home.borders" + && let Some(preset) = params.get("preset").and_then(Json::as_str) + { + self.grid.last_border = preset.to_string(); + } + self.run_or_alert(id, params); + } keytips::Action::Dialog(name) => self.open_dialog(name, json!({})), + keytips::Action::DialogWith(name, params) => self.open_dialog(name, serde_json::from_str(params).unwrap_or_else(|_| json!({}))), // Reserved sequence: fires silently while its command is missing. When the // command lands in the catalog it goes live with no table edit. keytips::Action::Pending(id) => { diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 24be742a5..76ddd4467 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -550,3 +550,96 @@ fn every_sequence_terminates_without_panicking() { } assert_eq!(SEQUENCES.len(), SEQUENCES.len(), "every sequence ran"); } + +fn borders(h: &Harness, cell: &str) -> gridcraft_engine::model::Borders { + let d = h.state().session.active().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).borders +} + +/// [left, right, top, bottom] line styles of `cell`. +fn sides(h: &Harness, cell: &str) -> [gridcraft_engine::model::BorderStyle; 4] { + let b = borders(h, cell); + [b.left.style, b.right.style, b.top.style, b.bottom.style] +} + +fn selected(range: &str) -> Session { + let mut s = blank(); + s.execute("selection.set", json!({"range": range})).unwrap(); + s +} + +#[test] +fn border_keytips_apply_their_own_preset() { + use gridcraft_engine::model::BorderStyle::{Double, None as N, Thick, Thin}; + // Alt, H, B, letter on A1 alone: each Borders menu letter draws its own preset (they all drew + // a bare bottom border before). Letters observed in Excel's Borders menu. + for (k, want) in [ + (Key::O, [N, N, N, Thin]), + (Key::P, [N, N, Thin, N]), + (Key::L, [Thin, N, N, N]), + (Key::R, [N, Thin, N, N]), + (Key::A, [Thin, Thin, Thin, Thin]), + (Key::S, [Thin, Thin, Thin, Thin]), + (Key::T, [Thick, Thick, Thick, Thick]), + (Key::B, [N, N, N, Double]), + (Key::H, [N, N, N, Thick]), + (Key::D, [N, N, Thin, Thin]), + (Key::C, [N, N, Thin, Thick]), + (Key::U, [N, N, Thin, Double]), + ] { + let mut h = harness(selected("A1")); + sequence(&mut h, &[Key::H, Key::B, k], false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "H,B,{k:?} must run cleanly"); + assert_eq!(sides(&h, "A1"), want, "H,B,{k:?}"); + } +} + +#[test] +fn border_keytip_no_border_clears_a_bordered_cell() { + use gridcraft_engine::model::BorderStyle::None as N; + let mut s = selected("A1"); + s.execute("home.borders", json!({"range": "A1", "preset": "all"})).unwrap(); + let mut h = harness(s); + sequence(&mut h, &[Key::H, Key::B, Key::N], false); + assert_eq!(sides(&h, "A1"), [N, N, N, N], "H,B,N must clear every side, not add a bottom border"); + assert_eq!(h.state().grid.last_border, "none", "the picked preset becomes the Borders button's face"); +} + +#[test] +fn border_keytip_all_borders_covers_every_side_of_a_block() { + use gridcraft_engine::model::BorderStyle::Thin; + let mut h = harness(selected("A1:B2")); + sequence(&mut h, &[Key::H, Key::B, Key::A], false); + for cell in ["A1", "B1", "A2", "B2"] { + assert_eq!(sides(&h, cell), [Thin; 4], "H,B,A must border every side of {cell}"); + } +} + +#[test] +fn border_keytip_outside_borders_skips_the_inside_edges() { + use gridcraft_engine::model::BorderStyle::{None as N, Thin}; + let mut h = harness(selected("A1:B2")); + sequence(&mut h, &[Key::H, Key::B, Key::S], false); + assert_eq!(sides(&h, "A1"), [Thin, N, Thin, N]); + assert_eq!(sides(&h, "B2"), [N, Thin, N, Thin]); +} + +#[test] +fn border_keytip_more_borders_opens_format_cells_on_the_border_tab() { + let mut h = harness(blank()); + sequence(&mut h, &[Key::H, Key::B, Key::M], false); + let d = h.state().dialog.as_ref().expect("H,B,M opens Format Cells"); + assert_eq!(d.tab, "Border"); +} + +#[test] +fn border_keytips_without_a_command_stay_silent() { + // Erase Border (a pen mode), Line Color and Line Style are reserved: no border is drawn. + for k in [Key::E, Key::I, Key::Y] { + let mut h = harness(selected("A1")); + sequence(&mut h, &[Key::H, Key::B, k], false); + assert!(!h.state().session.journal.iter().any(|(id, _)| id.starts_with("home.")), "H,B,{k:?} must not run a command"); + assert_eq!(borders(&h, "A1"), Default::default(), "H,B,{k:?} must not draw a border"); + } +} diff --git a/crates/ui-egui/tests/keytips_generated.rs b/crates/ui-egui/tests/keytips_generated.rs index 653554857..dcf71cde1 100644 --- a/crates/ui-egui/tests/keytips_generated.rs +++ b/crates/ui-egui/tests/keytips_generated.rs @@ -308,7 +308,10 @@ fn keytip_hbd_() { fn keytip_hbe_() { let mut h = harness(blank()); sequence(&mut h, "HBE", false); - assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBE must journal home.borders"); + assert!(h.state().dialog.is_none(), "HBE must not open a dialog"); + assert!(h.state().message.is_none(), "HBE must not open a message box"); + assert!(h.state().toast.is_none(), "HBE must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "HBE must not journal"); } #[test] @@ -329,7 +332,10 @@ fn keytip_hbh_() { fn keytip_hbi_() { let mut h = harness(blank()); sequence(&mut h, "HBI", false); - assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBI must journal home.borders"); + assert!(h.state().dialog.is_none(), "HBI must not open a dialog"); + assert!(h.state().message.is_none(), "HBI must not open a message box"); + assert!(h.state().toast.is_none(), "HBI must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "HBI must not journal"); } #[test] @@ -343,7 +349,7 @@ fn keytip_hbl_() { fn keytip_hbm_() { let mut h = harness(blank()); sequence(&mut h, "HBM", false); - assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBM must journal home.borders"); + assert!(h.state().dialog.is_some(), "HBM must open the formatCells dialog"); } #[test] @@ -406,7 +412,10 @@ fn keytip_hbw_() { fn keytip_hby_() { let mut h = harness(blank()); sequence(&mut h, "HBY", false); - assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBY must journal home.borders"); + assert!(h.state().dialog.is_none(), "HBY must not open a dialog"); + assert!(h.state().message.is_none(), "HBY must not open a message box"); + assert!(h.state().toast.is_none(), "HBY must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "HBY must not journal"); } #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..209c55d93 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Alt, H, B border keytips apply their own preset) · **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 @@ -77,7 +77,7 @@ this logic, with one undo step. | Ctrl+1, Ctrl+; and Ctrl+Shift+; , Ctrl+D/R, Ctrl+E, Ctrl+T/L, Ctrl+K, Ctrl+Shift+L, Ctrl+Shift+~!@#$%^&_ | yes | done | | F2, F4, F5, F7, F9 family, Shift+F2/F3/F10/F11, F12 | yes | done; F11 chart sheet, F8 extend mode, F3 Paste Name, F4 Repeat missing | | Mac-specific (Cmd variants, Control variants, Fn keys) | yes | done where a feature exists (`keymap.rs` picks the Mac variant) | -| Sequential Alt access keys (Alt, H, N, …) on every ribbon tab | yes | 364-sequence keytip table for all tabs: per-control badges, prefix waiting, dropdowns open for editing (type/arrows/Enter), pending bindings for not-yet-built commands; QAT/Backstage excluded | +| Sequential Alt access keys (Alt, H, N, …) on every ribbon tab | yes | 364-sequence keytip table for all tabs: per-control badges, prefix waiting, dropdowns open for editing (type/arrows/Enter), pending bindings for not-yet-built commands; QAT/Backstage excluded. Keytips can carry fixed params, so each Borders menu letter (Alt, H, B, O/P/L/R/N/A/S/T/B/H/D/C/U) draws its own preset and B, M opens Format Cells on the Border tab; Erase Border, Line Color and Line Style are reserved | | Requests | — | #110 (more shortcuts and hot keys), #33 (sequential Alt; initial support landed in #43) | ### Shortcut audit (#110) @@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Alt, H, B border keytips apply the preset each letter names in Excel's Borders menu (they all drew a bottom border, so H,B,N No Border added one): keytips.json entries take optional `params`; H,B,M opens Format Cells on the Border tab; Erase Border, Line Color and Line Style stay reserved | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | | 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md | diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs index 36847222f..11e7773aa 100644 --- a/xtask/src/keytips.rs +++ b/xtask/src/keytips.rs @@ -44,25 +44,35 @@ struct Entry { kind: String, target: String, pending: bool, + /// Fixed params as a compact JSON object (`params` in keytips.json), empty for none. + params: String, excel: String, note: String, } +impl Entry { + /// The probe key: a command and the params its keytip passes. + fn probe_key(&self) -> String { + if self.params.is_empty() { self.target.clone() } else { format!("{} {}", self.target, self.params) } + } +} + /// 0 = ran and journaled, 1 = ran without a journal step, 2 = errored (params -/// missing, disabled, or environment-dependent). +/// missing, disabled, or environment-dependent). Keyed by [`Entry::probe_key`]. type Probe = std::collections::HashMap; #[cfg(feature = "engine")] -fn probe_live_commands(ids: &[String]) -> Probe { +fn probe_live_commands(calls: &[(String, String)]) -> Probe { use gridcraft_engine::Session; let mut map = Probe::new(); - for id in ids { + for (id, params) in calls { if ENV_SENSITIVE.contains(&id.as_str()) { continue; } + let params = if params.is_empty() { serde_json::json!({}) } else { serde_json::from_str(params).unwrap_or_default() }; let mut s = Session::new(); s.new_workbook(); - let status = match s.execute(id, serde_json::json!({})) { + let status = match s.execute(id, params.clone()) { Ok(_) => { if s.journal.iter().any(|(jid, _)| jid == id) { 0 @@ -72,7 +82,8 @@ fn probe_live_commands(ids: &[String]) -> Probe { } Err(_) => 2, }; - map.insert(id.clone(), status); + let key = if params.as_object().is_some_and(|o| !o.is_empty()) { format!("{id} {params}") } else { id.clone() }; + map.insert(key, status); } map } @@ -132,6 +143,13 @@ fn parse_and_probe(source: &str) -> Result<(Vec, Probe), String> { if pending && kind != "command" { return Err(format!("{at}: `pending` is only valid with kind `command`")); } + let params = match e.get("params") { + None => String::new(), + Some(v) if v.is_object() && matches!(kind.as_str(), "command" | "dialog") && !pending => { + serde_json::to_string(v).map_err(|e| format!("{at}: params: {e}"))? + } + Some(_) => return Err(format!("{at}: `params` must be a JSON object, on a non-pending command or dialog")), + }; match kind.as_str() { "tab" => { if target.is_empty() || !TABS.contains(&target.as_str()) { @@ -163,6 +181,7 @@ fn parse_and_probe(source: &str) -> Result<(Vec, Probe), String> { kind, target, pending, + params, excel: e.get("excel").and_then(|v| v.as_str()).unwrap_or("").to_string(), note: e.get("note").and_then(|v| v.as_str()).unwrap_or("").to_string(), }); @@ -170,10 +189,10 @@ fn parse_and_probe(source: &str) -> Result<(Vec, Probe), String> { // JSON order is authoritative and curated: the first entry for a tab is its canonical // badge (legacy aliases come later), and groups stay together. Do not sort. - let live_ids: Vec = entries + let live_calls: Vec<(String, String)> = entries .iter() .filter(|e| e.kind == "command" && !e.pending) - .map(|e| e.target.clone()) + .map(|e| (e.target.clone(), e.params.clone())) .collect::>() .into_iter() .collect(); @@ -187,7 +206,7 @@ fn parse_and_probe(source: &str) -> Result<(Vec, Probe), String> { e.target.clone() }) .collect(); - let mut probe = probe_live_commands(&live_ids); + let mut probe = probe_live_commands(&live_calls); for id in landed { let mut s = gridcraft_engine::Session::new(); s.new_workbook(); @@ -219,7 +238,9 @@ fn render_table(entries: &[Entry]) -> String { let value = match e.kind.as_str() { "tab" => format!("Entry::Tab({:?})", e.target), "command" if e.pending => format!("Entry::Finish(Action::Pending({:?}))", e.target), + "command" if !e.params.is_empty() => format!("Entry::Finish(Action::CommandWith({:?}, {:?}))", e.target, e.params), "command" => format!("Entry::Finish(Action::Command({:?}))", e.target), + "dialog" if !e.params.is_empty() => format!("Entry::Finish(Action::DialogWith({:?}, {:?}))", e.target, e.params), "dialog" => format!("Entry::Finish(Action::Dialog({:?}))", e.target), "control" => format!("Entry::Finish(Action::Control({:?}))", e.target), "continue" => "Entry::Continue".to_string(), @@ -269,7 +290,7 @@ fn render_tests(entries: &[Entry], probe: &Probe) -> String { "sequence(&mut h, {seq:?}, false);\n assert!(h.state().dialog.is_none(), \"{seq} must not open a dialog\");\n assert!(h.state().message.is_none(), \"{seq} must not open a message box\");\n assert!(h.state().toast.is_none(), \"{seq} must not raise a toast\");\n assert!(h.state().session.journal.is_empty(), \"{seq} must not journal\");", seq = e.seq, ), - "command" => match probe.get(&e.target).copied().unwrap_or(2) { + "command" => match probe.get(&e.probe_key()).copied().unwrap_or(2) { 0 => format!( "sequence(&mut h, {seq:?}, false);\n assert!(h.state().session.journal.iter().any(|(id, _)| id == {id:?}), \"{seq} must journal {id}\");", seq = e.seq, From ab4d58ca6ffd65a8e9e71fe0470916343ea41133 Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Wed, 14 Oct 2026 18:26:06 -0500 Subject: [PATCH 02/56] Lookups compare numbers exactly, like Excel MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP and LOOKUP compared numbers at 15 significant digits (through compare / compare_numbers), so a computed key such as =0.1+0.2 (0.30000000000000004) found 0.3. Excel 365 compares lookup keys exactly in exact, approximate and binary search modes, while =, SWITCH and the criteria functions (COUNTIF(S), SUMIF(S), AVERAGEIF, MAXIFS) keep the 15-digit comparison. Observed in Excel via COM with synthetic values. lookup_equal now compares numbers with ==, and the lookup searches order values with lookup_order (exact for numbers, compare otherwise). compare_numbers and the criteria path are unchanged. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/calc/src/tests.rs | 143 +++++++++++++++++++++++++++++++ crates/functions/src/criteria.rs | 9 +- crates/functions/src/lookup.rs | 63 ++++++++++++-- docs/function-parity.md | 3 +- 5 files changed, 211 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..fba2c6a35 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 (lookups compare numbers exactly, like Excel) · **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 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison | | 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: lookups compare numbers exactly | | 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 196c57de7..4a2eb701a 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -310,6 +310,149 @@ fn date_text_coerces_in_the_workbook_date_system() { } } +/// Lookups compare numbers exactly while `=` and the criteria functions compare them at 15 +/// significant digits. Every expectation was observed in Excel 365 with these fixtures +/// (`=0.1+0.2` is 0.30000000000000004, `=100+1E-14` is 100.00000000000001). +#[test] +fn lookups_compare_numbers_exactly() { + let mut t = T::new(); + for (at, input) in [ + ("A1", "=0.1+0.2"), + ("A2", "0.3"), + ("A3", "=100+1E-14"), + ("A4", "100"), + ("A5", "=1E15+0.5"), + ("A6", "1E15"), + // Ascending 0.1, 0.3+e, 0.5 and the same descending. + ("B1", "0.1"), + ("B2", "=0.1+0.2"), + ("B3", "0.5"), + ("C1", "0.5"), + ("C2", "=0.1+0.2"), + ("C3", "0.1"), + // 0.3 next to 0.3+e, ascending and descending. + ("D1", "0.3"), + ("D2", "=0.1+0.2"), + ("D3", "0.7"), + ("E1", "0.7"), + ("E2", "=0.1+0.2"), + ("E3", "0.3"), + ("G1", "=0.1+0.2"), + ("H1", "x"), + ("G2", "row2"), + // The bisection probes 0.3 first and must not run on into 0.3+e. + ("J1", "0.1"), + ("J2", "0.2"), + ("J3", "0.3"), + ("J4", "=0.1+0.2"), + ("J5", "0.4"), + ("K1", "a"), + ("K2", "b"), + ("K3", "c"), + ("K4", "d"), + ("K5", "e"), + ] { + t.set(at, input); + } + let e = 0.1 + 0.2; + let n = Value::Number; + let s = |x: &str| Value::from(x); + let na = Value::Error(CellError::NA); + let cases: Vec<(&str, Value)> = vec![ + // `=` and friends stay at 15 digits. + ("=A1=A2", Value::Bool(true)), + ("=A3=A4", Value::Bool(true)), + ("=A5=A6", Value::Bool(true)), + ("=SWITCH(A1,0.3,\"hit\",\"miss\")", s("hit")), + // Exact match. + ("=MATCH(A1,A2:A2,0)", na.clone()), + ("=MATCH(A2,A1:A1,0)", na.clone()), + ("=VLOOKUP(A1,A2:A2,1,FALSE)", na.clone()), + ("=HLOOKUP(A1,A2,1,FALSE)", na.clone()), + ("=HLOOKUP(0.3,G1:H2,2,FALSE)", na.clone()), + ("=XLOOKUP(A1,A2:A2,A2:A2,\"nf\")", s("nf")), + ("=XMATCH(A1,A2:A2)", na.clone()), + ("=MATCH(0.1+0.2,{0.3},0)", na.clone()), + ("=MATCH(0.3,A1:A2,0)", n(2.0)), + ("=MATCH(A1,A1:A2,0)", n(1.0)), + ("=MATCH(0.3,INDEX(A1:A2,0),0)", n(2.0)), + ("=MATCH(100,A3:A3,0)", na.clone()), + ("=MATCH(1E15,A5:A5,0)", na.clone()), + ("=XLOOKUP(0.3,A1:A2,{1;2},\"nf\",0,-1)", n(2.0)), + ("=XMATCH(A1,A1:A2,0,-1)", n(1.0)), + // Approximate match against a single entry. + ("=MATCH(0.3,A1:A1,1)", na.clone()), + ("=MATCH(0.3,A1:A1,-1)", n(1.0)), + ("=LOOKUP(0.3,A1:A1)", na.clone()), + ("=LOOKUP(0.3,A1:A1,G2)", na.clone()), + ("=VLOOKUP(0.3,A1:A1,1,TRUE)", na.clone()), + ("=HLOOKUP(0.3,G1:H2,2,TRUE)", na.clone()), + ("=XLOOKUP(0.3,A1:A1,A1:A1,\"nf\",-1)", s("nf")), + ("=XLOOKUP(0.3,A1:A1,A1:A1,\"nf\",1)", n(e)), + ("=XMATCH(0.3,A1:A1,-1)", na.clone()), + ("=XMATCH(0.3,A1:A1,1)", n(1.0)), + ("=MATCH(100,A3:A3,1)", na.clone()), + ("=MATCH(A3,A4:A4,1)", n(1.0)), + // Approximate match in sorted lists. + ("=MATCH(0.3,B1:B3,1)", n(1.0)), + ("=MATCH(A1,B1:B3,1)", n(2.0)), + ("=MATCH(0.3,C1:C3,-1)", n(2.0)), + ("=MATCH(0.3,D1:D3,1)", n(1.0)), + ("=MATCH(A1,D1:D3,1)", n(2.0)), + ("=MATCH(0.3,E1:E3,-1)", n(3.0)), + ("=MATCH(A1,E1:E3,-1)", n(2.0)), + ("=MATCH(0.3,J1:J5,1)", n(3.0)), + ("=MATCH(A1,J1:J5,1)", n(4.0)), + ("=MATCH(0.3,J1:J5,0)", n(3.0)), + ("=MATCH(A1,J1:J5,0)", n(4.0)), + ("=VLOOKUP(0.3,J1:K5,2,TRUE)", s("c")), + ("=VLOOKUP(A1,J1:K5,2,TRUE)", s("d")), + ("=LOOKUP(0.3,J1:J5,K1:K5)", s("c")), + ("=LOOKUP(A1,J1:J5,K1:K5)", s("d")), + // XLOOKUP / XMATCH match and search modes. + ("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",0,2)", s("nf")), + ("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",-1,2)", n(0.1)), + ("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",1,2)", n(e)), + ("=XMATCH(0.3,B1:B3,-1,2)", n(1.0)), + ("=XMATCH(0.3,B1:B3,1,2)", n(2.0)), + ("=XMATCH(0.3,C1:C3,1,-2)", n(2.0)), + ("=XMATCH(0.3,C1:C3,-1,-2)", n(3.0)), + ("=XMATCH(0.3,C1:C3,0,-2)", na.clone()), + ("=XMATCH(0.3,J1:J5,0,2)", n(3.0)), + ("=XMATCH(A1,J1:J5,0,2)", n(4.0)), + ("=XMATCH(0.3,J1:J5,1,2)", n(3.0)), + ("=XMATCH(0.3,J1:J5,-1)", n(3.0)), + ("=XMATCH(0.3,J1:J5,1)", n(3.0)), + ("=XMATCH(A1,J1:J5,-1)", n(4.0)), + ("=XMATCH(0.3,B1:B3,1)", n(2.0)), + ("=XMATCH(0.3,B1:B3,-1)", n(1.0)), + ("=XMATCH(0.3,B1:B3,0,-1)", na.clone()), + ("=XMATCH(0.3,D1:D3,1,-1)", n(1.0)), + // The criteria functions keep 15 digits. + ("=COUNTIF(A1:A1,0.3)", n(1.0)), + ("=COUNTIF(A1:A1,\"<0.3\")", n(0.0)), + ("=COUNTIF(A1:A1,\">0.3\")", n(0.0)), + ("=COUNTIF(A2:A2,A1)", n(1.0)), + ("=COUNTIFS(A1:A1,0.3)", n(1.0)), + ("=SUMIF(A1:A1,0.3)", n(e)), + ("=SUMIFS(A1:A1,A1:A1,0.3)", n(e)), + ("=AVERAGEIF(A1:A1,0.3)", n(e)), + ("=MAXIFS(A1:A1,A1:A1,0.3)", n(e)), + ("=COUNTIF(A3:A3,\">100\")", n(0.0)), + ("=COUNTIF(J1:J5,0.3)", n(2.0)), + ("=ROWS(FILTER(A1:A2,A1:A2=0.3))", n(2.0)), + ("=MATCH(TRUE,A1:A2=0.3,0)", n(1.0)), + ]; + let mut wrong = Vec::new(); + for (formula, expected) in cases { + let got = crate::evaluate(&t.wb, 0, c("Z1"), formula); + if got != expected { + wrong.push(format!("{formula}: got {got:?}, Excel {expected:?}")); + } + } + assert!(wrong.is_empty(), "{}", wrong.join("\n")); +} + #[test] fn operators() { let mut t = T::new(); diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index e326267ce..58f0e6594 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -314,7 +314,9 @@ pub(crate) fn wildcard_find(pattern: &str, text: &str, start: usize) -> Option bool { match (needle, hay) { (Value::Text(p), Value::Text(t)) => { @@ -324,7 +326,7 @@ pub(crate) fn lookup_equal(needle: &Value, hay: &Value, wild: bool) -> bool { compare_text(p, t) == Ordering::Equal } } - (Value::Number(a), Value::Number(b)) => compare_numbers(*a, *b) == Ordering::Equal, + (Value::Number(a), Value::Number(b)) => a == b, (Value::Bool(a), Value::Bool(b)) => a == b, (Value::Error(a), Value::Error(b)) => a == b, (Value::Empty, Value::Empty) => true, @@ -370,7 +372,8 @@ mod tests { assert!(m("#N/A", Value::Error(CellError::NA))); assert!(Criterion::parse(&Value::Number(3.0)).matches(&Value::Number(3.0))); assert!(Criterion::parse(&Value::Number(0.3)).matches(&Value::Number(0.1 + 0.2))); - assert!(lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false)); + assert!(!lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false)); + assert!(lookup_equal(&Value::Number(0.0), &Value::Number(-0.0), false)); assert!(m("<>a*", Value::from("bcd"))); assert!(m(">1/1/2020", Value::Number(44000.0))); // Text patterns never match blank cells, but an empty string is text. diff --git a/crates/functions/src/lookup.rs b/crates/functions/src/lookup.rs index 56739c933..89ea86ad7 100644 --- a/crates/functions/src/lookup.rs +++ b/crates/functions/src/lookup.rs @@ -23,6 +23,19 @@ fn needle(args: &[Arg], i: usize) -> R { } } +/// Order of two values for the approximate and binary searches: [`compare`], except that numbers +/// compare exactly. Excel's `=` treats numbers equal to 15 significant digits as equal, but its +/// lookups don't. Observed in Excel 365 with A1 holding +/// `=0.1+0.2` (0.30000000000000004): `A1=0.3` is TRUE, yet `MATCH(0.3,A1,1)` is #N/A because +/// A1 is larger, and in {0.1;0.2;0.3;A1;0.4} `MATCH(0.3,…,1)` is 3 and `MATCH(A1,…,1)` is 4. +/// The same holds for `=100+1E-14` against 100, and in every XLOOKUP / XMATCH mode. +fn lookup_order(a: &Value, b: &Value) -> Ordering { + match (a, b) { + (Value::Number(x), Value::Number(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal), + _ => compare(a, b), + } +} + fn same_type(a: &Value, b: &Value) -> bool { matches!((a, b), (Value::Number(_), Value::Number(_)) | (Value::Text(_), Value::Text(_)) | (Value::Bool(_), Value::Bool(_))) } @@ -59,10 +72,10 @@ fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option { continue; }; let v = hay.get(j)?; - let o = compare(v, x); + let o = lookup_order(v, x); if o == Ordering::Equal { let mut k = j; - while hay.get(k + 1).is_some_and(|w| same_type(x, w) && compare(w, x) == Ordering::Equal) { + while hay.get(k + 1).is_some_and(|w| same_type(x, w) && lookup_order(w, x) == Ordering::Equal) { k += 1; } return Some(k); @@ -88,8 +101,8 @@ fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option { fn binary_order(v: &Value, x: &Value, desc: bool) -> Ordering { match v { Value::Empty => Ordering::Greater, - _ if desc => compare(v, x).reverse(), - _ => compare(v, x), + _ if desc => lookup_order(v, x).reverse(), + _ => lookup_order(v, x), } } @@ -261,7 +274,7 @@ fn xsearch(x: &Value, hay: &[Value], match_mode: i64, search_mode: i64) -> R R true, Some(bv) => { - let c = compare(v, bv); + let c = lookup_order(v, bv); if match_mode == -1 { c == Ordering::Greater } else { c == Ordering::Less } } }; @@ -747,6 +760,44 @@ mod tests { assert_eq!(rows_of(&r), vec![vec![nv(3.0), nv(1.0)]]); } + /// Lookups compare numbers exactly, unlike `=` (15 significant digits). Observed in Excel 365. + #[test] + fn lookups_compare_numbers_exactly() { + let e = 0.1 + 0.2; + let one = || rf(col(&[e])); + // Exact modes miss a key one bit away. + is_err(ev("MATCH", vec![n(0.3), one(), n(0.0)]), CellError::NA); + is_err(ev("VLOOKUP", vec![n(0.3), one(), n(1.0), b(false)]), CellError::NA); + is_err(ev("XMATCH", vec![n(0.3), one()]), CellError::NA); + close(ev("MATCH", vec![n(0.3), rf(col(&[e, 0.3])), n(0.0)]), 2.0); + close(ev("MATCH", vec![n(e), rf(col(&[e, 0.3])), n(0.0)]), 1.0); + is_err(ev("MATCH", vec![n(100.0), rf(col(&[100.0 + 1e-14])), n(0.0)]), CellError::NA); + // 0 and -0 are still the same key. + close(ev("MATCH", vec![n(-0.0), rf(col(&[0.0])), n(0.0)]), 1.0); + // Approximate modes: 0.3 sorts below 0.30000000000000004. + is_err(ev("MATCH", vec![n(0.3), one(), n(1.0)]), CellError::NA); + close(ev("MATCH", vec![n(0.3), one(), n(-1.0)]), 1.0); + is_err(ev("LOOKUP", vec![n(0.3), one()]), CellError::NA); + is_err(ev("XMATCH", vec![n(0.3), one(), n(-1.0)]), CellError::NA); + close(ev("XMATCH", vec![n(0.3), one(), n(1.0)]), 1.0); + // The bisection stops on 0.3 and doesn't run on into the next entry. + let list = || rf(col(&[0.1, 0.2, 0.3, e, 0.4])); + close(ev("MATCH", vec![n(0.3), list(), n(1.0)]), 3.0); + close(ev("MATCH", vec![n(e), list(), n(1.0)]), 4.0); + close(ev("XMATCH", vec![n(0.3), list(), n(0.0), n(2.0)]), 3.0); + close(ev("XMATCH", vec![n(e), list(), n(0.0), n(2.0)]), 4.0); + let asc = || rf(col(&[0.1, e, 0.5])); + let desc = || rf(col(&[0.5, e, 0.1])); + close(ev("MATCH", vec![n(0.3), asc(), n(1.0)]), 1.0); + close(ev("MATCH", vec![n(0.3), desc(), n(-1.0)]), 2.0); + is_err(ev("XMATCH", vec![n(0.3), asc(), n(0.0), n(2.0)]), CellError::NA); + close(ev("XMATCH", vec![n(0.3), asc(), n(-1.0), n(2.0)]), 1.0); + close(ev("XMATCH", vec![n(0.3), asc(), n(1.0), n(2.0)]), 2.0); + close(ev("XMATCH", vec![n(0.3), desc(), n(-1.0), n(-2.0)]), 3.0); + close(ev("XMATCH", vec![n(0.3), asc(), n(-1.0)]), 1.0); + close(ev("XMATCH", vec![n(0.3), asc(), n(1.0)]), 2.0); + } + #[test] fn index() { let t3 = table_rows3(); diff --git a/docs/function-parity.md b/docs/function-parity.md index 760ffd6ac..6dad80804 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-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (lookups compare numbers exactly, like Excel 365) · **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). @@ -108,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP and LOOKUP compare numbers exactly in every match and search mode, as Excel 365 does: `MATCH(0.1+0.2,{0.3},0)` is `#N/A` and 0.3 sorts below 0.30000000000000004 in approximate and binary search. `=`, SWITCH and the criteria functions (COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS…) keep comparing at 15 significant digits, also as observed | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) | | 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) | | 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled | From 5c20f164342f0b443fb7e6897f52f6e0b3934163 Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Wed, 14 Oct 2026 18:30:16 -0500 Subject: [PATCH 03/56] core: read accounting negatives like $(1,234) in number text Excel 365 reads a dollar sign before accounting parentheses ($(1,234), $ (5)) and a percent sign after them ((5)%) as negative numbers when the text is typed, coerced by arithmetic or passed to VALUE. GridCraft gave #VALUE! for these. The parentheses branch no longer recurses: Excel reads only an unsigned plain number or fraction inside, so (-5), ((5)), ($-5), and dates or times in parentheses are now #VALUE! as in Excel (they used to give 5 or a negative serial). $5% is #VALUE! too: Excel never combines the currency and percent signs. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/calc/src/tests.rs | 15 +++++ crates/core/src/parse.rs | 115 ++++++++++++++++++++++++++++++++++- crates/functions/src/text.rs | 7 +++ docs/function-parity.md | 3 +- 5 files changed, 139 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..610011d87 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 (accounting negatives such as `$(1,234)` read as numbers) · **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 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text | | 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: accounting negatives in number text | | 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 196c57de7..1d5a1a464 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -310,6 +310,21 @@ fn date_text_coerces_in_the_workbook_date_system() { } } +#[test] +fn accounting_negative_text_coerces() { + let mut t = T::new(); + t.set("A1", "'$(1,234)"); + t.set("B1", "=A1+0"); + t.set("C1", "=VALUE(A1)"); + t.set("D1", "=--\"$ (5)\""); + t.set("E1", "=\"(5)%\"*100"); + t.set("F1", "=\"(-5)\"+0"); + t.set("G1", "$(1,234)"); + assert_eq!((t.num("B1"), t.num("C1"), t.num("D1"), t.num("E1")), (-1234.0, -1234.0, -5.0, -5.0)); + assert_eq!(t.get("F1"), Value::Error(CellError::Value)); + assert_eq!(t.num("G1"), -1234.0); +} + #[test] fn operators() { let mut t = T::new(); diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index b465beaf9..bf8487622 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -54,8 +54,13 @@ fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>) 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(inner) = parenthesized(t) { + // Excel reads no sign and no nested parentheses inside: `(-5)`, `($-5)`, `((5))`. + if inner.strip_prefix('$').map_or(inner.as_str(), str::trim_start).starts_with(['-', '+', '(']) { + return None; + } + let r = parse_plain(&inner).or_else(|| parse_fraction(&inner).map(|n| (n, Some("# ?/?")))); + return r.map(|(n, f)| (-n, f)); } if let Some(r) = parse_plain(t) { return Some(r); @@ -69,6 +74,22 @@ fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>) None } +/// The number inside accounting parentheses, as plain text: `(5)` gives `5`. A dollar sign may +/// stand before them (`$(5)` reads as `($5)`) and a percent sign after them (`(5)%` reads as +/// `(5%)`), with spaces around either. The caller reads only a plain number or a fraction from it. +fn parenthesized(t: &str) -> Option { + let (currency, rest) = match t.strip_prefix('$') { + Some(r) => ("$", r.trim_start()), + None => ("", t), + }; + let rest = rest.strip_prefix('(')?; + let (body, percent) = match rest.strip_suffix(')') { + Some(b) => (b, ""), + None => (rest.strip_suffix('%')?.trim_end().strip_suffix(')')?, "%"), + }; + Some(format!("{currency}{}{percent}", body.trim())) +} + /// Signs, currency, thousands separators, percent, exponent. fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> { let mut s = t; @@ -93,7 +114,8 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> { percent = true; s = r.trim_end(); } - if s.is_empty() { + // Excel reads `$5` and `5%` but not `$5%`. + if s.is_empty() || currency && percent { return None; } let has_sep = s.contains(','); @@ -352,6 +374,93 @@ mod tests { assert_eq!(p(".5").value, Value::Number(0.5)); } + /// Observed in Excel 365 (en-US): typed entry, VALUE and `--"text"` agree. + #[test] + fn accounting_parentheses() { + let accepted: [(&str, f64, Option<&str>); 22] = [ + ("$(5)", -5.0, Some("\"$\"#,##0")), + ("$ (5)", -5.0, Some("\"$\"#,##0")), + ("$( 5 )", -5.0, Some("\"$\"#,##0")), + ("$ ( 1,234 )", -1234.0, Some("\"$\"#,##0")), + ("$(1,234)", -1234.0, Some("\"$\"#,##0")), + ("$(1,000.50)", -1000.5, Some("\"$\"#,##0.00")), + ("$(1,234.)", -1234.0, Some("\"$\"#,##0.00")), + ("$(.5)", -0.5, Some("\"$\"#,##0.00")), + ("$(0)", 0.0, Some("\"$\"#,##0")), + (" $(5) ", -5.0, Some("\"$\"#,##0")), + ("($5)", -5.0, Some("\"$\"#,##0")), + ("( $ 1,234 )", -1234.0, Some("\"$\"#,##0")), + ("(5)", -5.0, None), + ("( 5 )", -5.0, None), + ("(1,234.5)", -1234.5, Some("#,##0.00")), + ("(1e-3)", -0.001, Some("0.00E+00")), + ("(5)%", -0.05, Some("0%")), + ("( 5 ) %", -0.05, Some("0%")), + ("(5%)", -0.05, Some("0%")), + ("(5.)%", -0.05, Some("0.00%")), + ("(1,234)%", -12.34, Some("0%")), + ("(1 1/2)", -1.5, Some("# ?/?")), + ]; + for (s, n, f) in accepted { + assert_eq!(p(s), Parsed { value: Value::Number(n), format: f }, "{s}"); + assert_eq!(parse_number_text(s), Some(n), "{s}"); + } + for s in [ + "$5%", + "$(5%)", + "$(5)%", + "($5%)", + "($5)%", + "$ ( 5 ) %", + "(5)%%", + "(5%)%", + "((5))", + "(-5)", + "(+5)", + "($-5)", + "($ -5)", + "($+5)", + "(-$5)", + "$(-5)", + "$ (-5)", + "$(+5)", + "-(5)", + "-$(5)", + "+$(5)", + "$+(5)", + "$$(5)", + "$ $(5)", + "$((5))", + "($(5))", + "(-1 1/2)", + "()", + "$()", + "(5)$", + "$(5)$", + "(5)(5)", + "$(5)0", + "(5)e3", + "(5)-", + "(5-)", + "$(5-)", + "$(5", + "$5)", + "(5", + "$(1,23)", + "$(,5)", + "(5e)", + "(1/2)", + "$(1/2)", + "(3/4/2026)", + "$(3/4/2026)", + "(10:30)", + "$(10:30)", + ] { + assert_eq!(p(s).value, Value::text(s), "{s}"); + assert_eq!(parse_number_text(s), None, "{s}"); + } + } + #[test] fn texts_and_specials() { assert_eq!(p("hello").value, Value::text("hello")); diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 91962d6e7..84fad9898 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -1092,6 +1092,13 @@ mod tests { close(ev("VALUE", vec![t("12:00")]), 0.5); close(ev("VALUE", vec![t("2020-01-01")]), 43831.0); is_err(ev("VALUE", vec![t("abc")]), CellError::Value); + // Accounting negatives, as Excel 365 reads them. + close(ev("VALUE", vec![t("$(5)")]), -5.0); + close(ev("VALUE", vec![t("$ (1,000.50)")]), -1000.5); + close(ev("VALUE", vec![t("(5)%")]), -0.05); + for s in ["$(5)%", "$(5%)", "$5%", "(-5)", "((5))", "$(-5)", "(1/2)"] { + is_err(ev("VALUE", vec![t(s)]), CellError::Value); + } close(ev("NUMBERVALUE", vec![t("2.500,27"), t(","), t(".")]), 2500.27); close(ev("NUMBERVALUE", vec![t("3.5%")]), 0.035); close(ev("NUMBERVALUE", vec![t(" 1 000 ")]), 1000.0); diff --git a/docs/function-parity.md b/docs/function-parity.md index 760ffd6ac..3b25b144f 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-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (number text with accounting parentheses coerces like Excel 365) · **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). @@ -108,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Number text coercion (VALUE, arithmetic, typed entry): a dollar sign before accounting parentheses (`$(1,234)`, `$ (5)`) and a percent sign after them (`(5)%`) read as negatives; `(-5)`, `((5))`, `($-5)`, `$5%`, `$(5)%` and dates or times in parentheses are `#VALUE!`, as observed in Excel 365 | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) | | 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) | | 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled | From ea190adad6a1edb792521a7818ef426570a8229c Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Wed, 14 Oct 2026 19:55:46 -0500 Subject: [PATCH 04/56] functions: Analysis ToolPak value arguments reject multi-cell references EOMONTH, EDATE, WORKDAY(.INTL), NETWORKDAYS(.INTL), YEARFRAC, WEEKNUM, the coupon, bond and T-bill functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give #VALUE! in Excel 365 for a reference to more than one cell in a value argument, in dynamic-array and legacy formulas alike, where GridCraft lifted the range and spilled. Array constants and computed arrays still lift, and range arguments (holidays, cash flows, schedules) and the .INTL weekend still take ranges. FnSpec gains a value_args mask, filled at registration from one table in lift.rs; call_lifted turns a multi-cell reference at a value position into a scalar #VALUE! before lifting, so EOMONTH(A1:A3,{1;2}) is a 2x1 array of #VALUE! as in Excel. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/calc/src/tests.rs | 85 +++++++++++++++++++++++++++++++++++ crates/functions/src/lib.rs | 18 ++++++++ crates/functions/src/lift.rs | 87 ++++++++++++++++++++++++++++++++++++ docs/function-parity.md | 4 +- 5 files changed, 196 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..2abac7262 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 (Analysis ToolPak functions reject multi-cell references in value arguments) · **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 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift | | 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: Analysis ToolPak value arguments reject multi-cell references | | 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 196c57de7..a9317528b 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1266,3 +1266,88 @@ fn offset_extreme_arguments_return_ref() { assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{formula}"); } } + +#[test] +fn analysis_toolpak_value_params_reject_multi_cell_references() { + // Excel 365 (observed): EOMONTH, WORKDAY, COUPDAYS, BIN2DEC... give #VALUE! for a reference to more + // than one cell, however the reference is formed, and lift array constants and computed arrays. + let mut t = T::new(); + for (a, v) in [ + ("A1", "45000"), + ("A2", "45031"), + ("A3", "45061"), + ("B1", "1"), + ("B2", "2"), + ("B3", "3"), + ("C2", "7"), + ("F1", "1"), + ("F2", "2"), + ("F3", "11"), + ] { + t.set(a, v); + } + t.wb.names.push(gridcraft_model::DefinedName { + name: "Dates".into(), + scope: None, + formula: "Sheet1!$A$1:$A$3".into(), + comment: String::new(), + hidden: false, + }); + let value = Value::Error(CellError::Value); + for f in [ + "=EOMONTH(A1:A3,1)", + "=EOMONTH(A1,B1:B3)", + "=EDATE(A1:A3,1)", + "=SUMPRODUCT(EOMONTH(A1:A3,0))", + "=EOMONTH(INDEX(A1:A3,0,1),0)", + "=EOMONTH(OFFSET(A1,0,0,3,1),0)", + "=EOMONTH(IF(TRUE,A1:A3),0)", + "=EOMONTH(CHOOSE(1,A1:A3),0)", + "=LET(x,A1:A3,EOMONTH(x,0))", + "=EOMONTH(Dates,0)", + "=EOMONTH((A1,A2),0)", + "=EOMONTH(Z:Z,0)", + "=EOMONTH(A1:INDEX(A1:A3,3),0)", + "=WORKDAY(A1:A3,1)", + "=NETWORKDAYS(A1,A3:A4)", + "=YEARFRAC(A1,A3,B1:B2)", + "=WEEKNUM(A1,B1:B3)", + "=COUPDAYS(A1:A3,46000,2,0)", + "=DISC(A1,46000,98,100,B1:B3)", + "=BIN2DEC(B1:B3)", + "=ISEVEN(B1:B3)", + ] { + t.set("H5", f); + assert_eq!((t.get("H5"), t.get("H6")), (value.clone(), Value::Empty), "{f}"); + t.set("H5", ""); + } + // One-cell references, intersections, @ and the range parameters are fine. + for (f, want) in [ + ("=EOMONTH(A1:A1,0)", 45016.0), + ("=EOMONTH(INDEX(A1:A3,2),0)", 45046.0), + ("=EOMONTH(OFFSET(A1,0,0,1,1),0)", 45016.0), + ("=EOMONTH(A1:A3 A2:C2,0)", 45046.0), + ("=EOMONTH(@A1:A3,0)", 45046.0), + ("=WORKDAY(A1,1,A1:A3)", 45001.0), + ("=XNPV(0,B1:B3,A1:A3)", 6.0), + ] { + t.set("H2", f); + assert_eq!(t.num("H2"), want, "{f}"); + t.set("H2", ""); + } + // Arrays that aren't references lift; so does the WORKDAY.INTL/NETWORKDAYS.INTL weekend. + for (f, below, right) in [ + ("=EOMONTH(A1:A3+0,0)", Value::Number(45046.0), Value::Empty), + ("=EOMONTH(A1,{1,2})", Value::Empty, Value::Number(45077.0)), + ("=EOMONTH(TRANSPOSE(A1:A3),0)", Value::Empty, Value::Number(45046.0)), + ("=MAP(A1:A3,LAMBDA(x,EOMONTH(x,0)))", Value::Number(45046.0), Value::Empty), + ("=NETWORKDAYS.INTL(A1,A3+30,F1:F3)", Value::Number(66.0), Value::Empty), + ("=ISOWEEKNUM(A1:A3)", Value::Number(15.0), Value::Empty), + // The rejected reference adds no shape: a 2x1 array of #VALUE!. + ("=EOMONTH(A1:A3,{1;2})", value.clone(), Value::Empty), + ] { + t.set("H5", f); + assert_eq!((t.get("H6"), t.get("I5")), (below, right), "{f}"); + t.set("H5", ""); + } +} diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index 148d68221..5f9dcb67d 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -30,6 +30,7 @@ macro_rules! f { category: $crate::Category::$cat, volatile: true, scalar_args: $scal, + value_args: &[], signature: $sig, description: $desc, imp: { @@ -48,6 +49,7 @@ macro_rules! f { category: $crate::Category::$cat, volatile: false, scalar_args: $scal, + value_args: &[], signature: $sig, description: $desc, imp: { @@ -147,6 +149,11 @@ pub struct FnSpec { /// (broadcast like Excel's dynamic arrays). The last entry repeats for variadic tails; an /// empty slice means no lifting. pub scalar_args: &'static [bool], + /// Parameter positions that take a single value and reject a reference to more than one + /// cell with `#VALUE!` (the Analysis ToolPak functions: EOMONTH, WORKDAY, COUPDAYS, BIN2DEC…), + /// where other scalar parameters lift the range. Arrays that aren't references still lift. + /// Same layout as `scalar_args`; filled at registration from [`lift::value_args`]. + pub value_args: &'static [bool], pub signature: &'static str, pub description: &'static str, pub imp: FnImpl, @@ -167,6 +174,14 @@ impl FnSpec { } } + /// Whether parameter `i` takes a single value, so a multi-cell reference there is `#VALUE!`. + pub fn is_value_arg(&self, i: usize) -> bool { + match self.value_args.get(i) { + Some(b) => *b, + None => self.value_args.last().copied().unwrap_or(false), + } + } + /// Whether a call with `n` arguments is valid (Excel allows at most 255). pub fn accepts(&self, n: usize) -> bool { (self.min_args..=self.max_args).contains(&n) && n <= 255 @@ -195,6 +210,9 @@ fn registry() -> &'static (Vec, HashMap<&'static str, usize>) { ] { v.extend(list); } + for s in &mut v { + s.value_args = lift::value_args(s.name); + } let mut map = HashMap::with_capacity(v.len()); for (i, s) in v.iter().enumerate() { map.entry(s.name).or_insert(i); diff --git a/crates/functions/src/lift.rs b/crates/functions/src/lift.rs index cea0067ee..056cb6d31 100644 --- a/crates/functions/src/lift.rs +++ b/crates/functions/src/lift.rs @@ -6,7 +6,61 @@ use gridcraft_core::{Array, CellError, Value}; use crate::util::MAX_CELLS; use crate::{Arg, Ctx, FnSpec}; +/// Value-parameter masks (see [`FnSpec::value_args`]), observed in Excel 365: a reference to more +/// than one cell at these positions gives `#VALUE!` in dynamic-array and legacy formulas alike (no +/// implicit intersection), while array constants and computed arrays lift. Range parameters +/// (holidays, schedules, cash flows, coefficients) and the WORKDAY.INTL/NETWORKDAYS.INTL weekend +/// (which lifts a range) are `false`. +pub(crate) const VALUE_ARGS: &[(&str, &[bool])] = &[ + // Date and time. + ("EOMONTH EDATE YEARFRAC WEEKNUM", &[true]), + ("WORKDAY NETWORKDAYS", &[true, true, false]), + ("WORKDAY.INTL NETWORKDAYS.INTL", &[true, true, false, false]), + // Financial. + ( + "ACCRINT ACCRINTM AMORDEGRC AMORLINC COUPDAYBS COUPDAYS COUPDAYSNC COUPNCD COUPNUM COUPPCD CUMIPMT CUMPRINC DISC \ + DOLLARDE DOLLARFR DURATION EFFECT INTRATE MDURATION NOMINAL ODDFPRICE ODDFYIELD ODDLPRICE ODDLYIELD PRICE PRICEDISC \ + PRICEMAT RECEIVED TBILLEQ TBILLPRICE TBILLYIELD YIELD YIELDDISC YIELDMAT", + &[true], + ), + ("FVSCHEDULE XNPV", &[true, false]), + ("XIRR", &[false, false, true]), + // Math and information. + ("ISEVEN ISODD QUOTIENT MROUND FACTDOUBLE SQRTPI RANDBETWEEN", &[true]), + ("SERIESSUM", &[true, true, true, false]), + // Engineering (IMSUM and IMPRODUCT aggregate, so they aren't here). + ( + "BIN2DEC BIN2HEX BIN2OCT DEC2BIN DEC2HEX DEC2OCT HEX2BIN HEX2DEC HEX2OCT OCT2BIN OCT2DEC OCT2HEX CONVERT DELTA GESTEP \ + ERF ERF.PRECISE ERFC ERFC.PRECISE BESSELI BESSELJ BESSELK BESSELY COMPLEX IMABS IMAGINARY IMARGUMENT IMCONJUGATE \ + IMCOS IMCOSH IMCOT IMCSC IMCSCH IMDIV IMEXP IMLN IMLOG10 IMLOG2 IMPOWER IMREAL IMSEC IMSECH IMSIN IMSINH IMSQRT IMSUB \ + IMTAN", + &[true], + ), +]; + +/// The value-parameter mask of `name` (empty for functions without value parameters). +pub(crate) fn value_args(name: &str) -> &'static [bool] { + VALUE_ARGS.iter().find(|(names, _)| names.split_ascii_whitespace().any(|n| n == name)).map_or(&[], |(_, mask)| mask) +} + +/// A reference to more than one cell at a value parameter becomes a scalar `#VALUE!`, so it adds +/// no shape: `EOMONTH(A1:A3, {1;2})` is a 2x1 array of `#VALUE!`, as in Excel. +fn reject_multi_cell_refs(spec: &FnSpec, args: &[Arg]) -> Option> { + let multi = |i: usize, a: &Arg| a.from_ref && spec.is_value_arg(i) && matches!(&a.value, Value::Array(arr) if arr.data.len() > 1); + if !args.iter().enumerate().any(|(i, a)| multi(i, a)) { + return None; + } + Some( + args.iter() + .enumerate() + .map(|(i, a)| if multi(i, a) { Arg { value: Value::Error(CellError::Value), from_ref: false } } else { a.clone() }) + .collect(), + ) +} + pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Value { + let rejected = if spec.value_args.is_empty() { None } else { reject_multi_cell_refs(spec, args) }; + let args = rejected.as_deref().unwrap_or(args); let mut rows = 0usize; let mut cols = 0usize; let mut any = false; @@ -94,4 +148,37 @@ mod tests { ); is_err(huge, CellError::Num); } + + #[test] + fn value_params_reject_multi_cell_references() { + let s = FnSpec { value_args: &[true, false], ..spec() }; + let mut ctx = TestCtx::default(); + // A multi-cell reference is #VALUE!; a one-cell reference and a computed array are fine. + is_err(crate::call(&s, &[rf(col(&[1.0, 2.0, 3.0])), n(1.0)], &mut ctx), CellError::Value); + assert_eq!(crate::call(&s, &[rf(col(&[4.0])), n(1.0)], &mut ctx), nv(5.0)); + assert_eq!(rows_of(&crate::call(&s, &[av(col(&[1.0, 2.0])), n(1.0)], &mut ctx)), vec![vec![nv(2.0)], vec![nv(3.0)]]); + // The rejected reference adds no shape: the other argument still lifts. + let v = Value::Error(CellError::Value); + assert_eq!(rows_of(&crate::call(&s, &[rf(col(&[1.0, 2.0, 3.0])), av(col(&[1.0, 2.0]))], &mut ctx)), vec![vec![v.clone()], vec![v]]); + // A position that isn't a value parameter still lifts the reference. + assert_eq!(rows_of(&crate::call(&s, &[n(1.0), rf(row(&[1.0, 2.0]))], &mut ctx)), vec![vec![nv(2.0), nv(3.0)]]); + } + + #[test] + fn value_arg_table_names_registered_scalar_positions() { + for (names, mask) in super::VALUE_ARGS { + assert!(!mask.is_empty()); + for name in names.split_ascii_whitespace() { + let Some(s) = crate::lookup(name) else { panic!("{name} isn't registered") }; + assert_eq!(s.value_args, *mask, "{name}"); + for i in 0..s.max_args.min(12) { + assert!(!s.is_value_arg(i) || s.is_scalar_arg(i), "{name} argument {i} is a value parameter but doesn't lift"); + } + } + } + assert!(crate::lookup("WORKDAY").is_some_and(|s| s.is_value_arg(1) && !s.is_value_arg(2))); + assert!(crate::lookup("WORKDAY.INTL").is_some_and(|s| !s.is_value_arg(2) && s.is_scalar_arg(2))); + assert!(crate::lookup("SUM").is_some_and(|s| s.value_args.is_empty())); + assert!(crate::lookup("ISOWEEKNUM").is_some_and(|s| s.value_args.is_empty())); + } } diff --git a/docs/function-parity.md b/docs/function-parity.md index 760ffd6ac..91283721d 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-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (Analysis ToolPak functions give #VALUE! for a multi-cell reference in a value argument, like Excel 365) · **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). @@ -77,6 +77,7 @@ PIVOTBY, REGEXTEST…) are all present. | Defined names (workbook and sheet scope), Create from Selection | done | | Dynamic arrays, spill, `#SPILL!`, spill operator `A1#` | done; stale-spill bugs (#133, #139; PRs #148, #157) | | Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) | +| Array lifting: an array in a scalar argument evaluates the function per element | done. The Analysis ToolPak functions (EOMONTH, EDATE, WORKDAY(.INTL), NETWORKDAYS(.INTL), YEARFRAC, WEEKNUM, the COUP*, PRICE*, YIELD*, ODD*, TBILL*, ACCRINT*, AMOR*, DISC, DURATION, CUMIPMT/CUMPRINC, EFFECT, NOMINAL, DOLLARDE/FR, XNPV/XIRR rate and guess, FVSCHEDULE principal, ISEVEN/ISODD, QUOTIENT, MROUND, FACTDOUBLE, SQRTPI, RANDBETWEEN, SERIESSUM, the base conversions, CONVERT, DELTA, GESTEP, ERF/ERFC, BESSEL*, COMPLEX and IM* except IMSUM/IMPRODUCT) give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does in both dynamic-array and legacy formulas; array constants and computed arrays (`A1:A3+0`) still lift, and range arguments (holidays, schedule, cash flows) and the .INTL weekend accept ranges. Difference: BYROW/BYCOL pass their row or column to the LAMBDA as values, so `BYROW(A1:B3,LAMBDA(r,EOMONTH(r,0)))` lifts where Excel gives `#VALUE!` | | Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too | | LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done | | Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as ``), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Differences: no Options dialog yet (command only); GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened | @@ -108,6 +109,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Analysis ToolPak value arguments (EOMONTH, WORKDAY, YEARFRAC, COUPDAYS, DISC, BIN2DEC, IM* and the rest, observed in Excel 365) give `#VALUE!` for a multi-cell reference instead of lifting it | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) | | 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) | | 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled | From 6db94d154a14697cb8a0d3b85c77f8a5c4c33efb Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Thu, 15 Oct 2026 05:19:32 -0500 Subject: [PATCH 05/56] Calc: INDEX with one index on a 2-D reference is #REF!, as in Excel INDEX(A1:C3,2) returned the whole second row. Excel gives #REF! when a reference has several rows and columns and the column argument is omitted, so the error also carries into ranges built from it (A1:INDEX(A1:C3,2)), position arrays (INDEX(A1:C3,{1;2})) and outer functions. An empty column argument (INDEX(A1:C3,2,)) still gives the row, and arrays are unchanged: INDEX({1,2;3,4},2) is the row. Observed in Excel that a column array with one index, INDEX({10;20;30},2), is a 1x1 array (TYPE 64), which is what calc already returned; the new test pins it. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 ++- crates/calc/src/eval.rs | 8 ++++- crates/calc/src/tests.rs | 75 ++++++++++++++++++++++++++++++++++++++++ docs/function-parity.md | 3 +- 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..3292b595f 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-15 · **Change:** minor (INDEX with one index on a 2-D reference is #REF! as in Excel) · **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-15 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row | | 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-15 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! | | 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/eval.rs b/crates/calc/src/eval.rs index 0f9cfb03d..1f58c50be 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -1251,7 +1251,13 @@ impl<'h> Evaluator<'h> { let area = usize::try_from((area_n as u64).saturating_sub(1)).ok(); let Some(a) = area.and_then(|i| areas.get(i)).copied() else { return err(CellError::Ref) }; let r = a.range; - // A single row or column with one index picks along it. + // A single row or column with one index picks along it. An area with several rows + // and columns needs both: `INDEX(A1:C3,2)` is #REF! in Excel (an empty third + // argument, `INDEX(A1:C3,2,)`, still means the whole row). Arrays differ: + // `INDEX({1,2;3,4},2)` is the row. + if two_args && r.height() > 1 && r.width() > 1 { + return err(CellError::Ref); + } let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) }; if row as u64 > r.height() as u64 || col as u64 > r.width() as u64 { return err(CellError::Ref); diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 196c57de7..5abedec60 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -172,6 +172,81 @@ fn index_area_number_overflow() { } } +/// INDEX with one index, as observed in Excel: a 2-D reference needs both indexes (#REF!), a +/// column array gives a 1×1 array (TYPE 64), a row array or 1-D reference gives one value. +#[test] +fn index_with_one_index() { + let mut t = T::new(); + for (i, v) in [10, 20, 30].iter().enumerate() { + t.set(&format!("A{}", i + 1), &v.to_string()); + } + let mut k = 1; + for r in 5..=7 { + for col in ["A", "B", "C"] { + t.set(&format!("{col}{r}"), &k.to_string()); + k += 1; + } + } + for (row, (formula, expected)) in [ + // Column arrays keep Excel's 1×1 array; row arrays and explicit columns give a value. + ("=INDEX({10;20;30},2)", 20.0), + ("=TYPE(INDEX({10;20;30},2))", 64.0), + ("=TYPE(INDEX(SEQUENCE(3),2))", 64.0), + ("=TYPE(INDEX({10;20;30},2,1))", 1.0), + ("=TYPE(INDEX({10,20,30},2))", 1.0), + ("=TYPE(INDEX(INDEX({10;20;30},2),1))", 1.0), + ("=SUM(INDEX({10;20;30},2))", 20.0), + // 1-D references with one index are single-cell references. + ("=TYPE(INDEX(A1:A3,2))", 1.0), + ("=SUM(INDEX(A1:A3,1):INDEX(A1:A3,3))", 60.0), + ("=SUM(INDEX((A1:A3,A5:C7),2))", 20.0), + // A 2-D array with one index is the row. + ("=SUM(INDEX({1,2;3,4},2))", 7.0), + ("=SUM(INDEX({1,2;3,4},0))", 10.0), + // A 2-D reference with an empty (not omitted) column argument is the row. + ("=SUM(INDEX(A5:C7,2,))", 15.0), + ("=SUM(INDEX(A5:C7,2,,1))", 15.0), + ("=COLUMNS(INDEX((A1:A3,A5:C7),2,,2))", 3.0), + ("=INDEX(A5:C7,2,2)", 5.0), + // A 2-D reference with one index is #REF!, and the error carries through. + ("=TYPE(INDEX(A5:C7,2))", 16.0), + ("=ISREF(INDEX(A5:C7,2))", 0.0), + ] + .iter() + .enumerate() + { + let at = format!("J{}", row * 4 + 1); + t.set(&at, formula); + let got = match t.get(&at) { + Value::Bool(b) => f64::from(u8::from(b)), + v => v.as_f64().unwrap_or_else(|| panic!("{formula} = {v:?}")), + }; + assert_eq!(got, *expected, "{formula}"); + } + for formula in [ + "=INDEX(A5:C7,2)", + "=INDEX(A5:C7,0)", + "=INDEX(A5:C7,1.9)", + "=INDEX(A5:C7,)", + "=INDEX(A5:B6,1)", + "=INDEX(A5:C7,2)+1", + "=SUM(INDEX(A5:C7,{1;2}))", + "=ROWS(INDEX(INDEX(A5:C7,2),3))", + "=SUM(A5:INDEX(A5:C7,2))", + "=SUM(INDEX(A5:C7,2):C7)", + ] { + t.set("L1", formula); + assert_eq!(t.get("L1"), Value::Error(CellError::Ref), "{formula}"); + } + t.set("L1", "=INDEX(A5:C7,{1;2})"); + assert_eq!([t.get("L1"), t.get("L2")], [Value::Error(CellError::Ref), Value::Error(CellError::Ref)]); + t.set("L1", "=IFERROR(INDEX(A5:C7,2),\"e\")"); + assert_eq!(t.get("L1"), Value::from("e")); + // A bad position is still #VALUE!. + t.set("L1", "=INDEX(A5:C7,\"x\")"); + assert_eq!(t.get("L1"), Value::Error(CellError::Value)); +} + #[test] fn errors_and_cycles() { let mut t = T::new(); diff --git a/docs/function-parity.md b/docs/function-parity.md index 760ffd6ac..12bef8ee9 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-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (INDEX with one index on a reference of several rows and columns is #REF! as in Excel) · **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). @@ -108,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | INDEX with only a row index on a reference of several rows and columns (`INDEX(A1:C3,2)`, `INDEX(A1:C3,0)`) is `#REF!`, as observed in Excel, so it also fails as a range endpoint and inside other functions; an empty column argument (`INDEX(A1:C3,2,)`) still gives the row. Checked that a column array with one index stays a 1×1 array (`TYPE` 64), which is what Excel returns | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) | | 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) | | 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled | From 672c102eb2eb375b3b2752f1e446147aebd522dc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 15:12:02 -0700 Subject: [PATCH 06/56] Calc: a LAMBDA that calls itself without end answers #NUM! instead of overflowing the stack The evaluator stopped at MAX_DEPTH (400) nested eval calls. A LAMBDA call takes one of those levels but several stack frames (eval, eval_inner, call, builtin, apply_lambda, call_lambda_values), so on the 1 MiB main thread of the Windows build `=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))` overflowed the stack before it reached the limit, and the process ended. eval now also stops once a formula has used 512 KiB of stack below its first eval. Both limits answer #NUM!, the error Excel documents for too many recursive calls, and a defined name that holds the recursion passes that error on instead of answering #NAME?. Fixes #1662 --- crates/calc/src/eval.rs | 37 +++++++++++++++++++++++++++++-------- crates/calc/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 0f9cfb03d..2bbedd551 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -49,6 +49,18 @@ fn err(e: CellError) -> Ev { Ev::V(Value::Error(e)) } +/// Native stack, in bytes, that one formula's evaluation may use below its first `eval`. Past it, +/// `eval` answers `#NUM!`. One level of `MAX_DEPTH` can take several stack frames. The Windows main +/// thread and the web build have 1 MiB of stack. +const STACK_BUDGET: usize = 512 * 1024; + +/// The address of a local variable, which shows how far down the native stack this call is. +#[inline(always)] +fn stack_position() -> usize { + let marker = 0u8; + std::hint::black_box(&marker) as *const u8 as usize +} + /// What the evaluator needs from its surroundings. pub trait Host { fn workbook(&self) -> &Workbook; @@ -84,6 +96,8 @@ pub struct Evaluator<'h> { pub at: CellRef, env: Vec<(String, Ev)>, depth: usize, + /// `stack_position()` at the outermost `eval`. + stack_top: usize, } struct FnCtx<'a, 'h> { @@ -121,14 +135,14 @@ impl Ctx for FnCtx<'_, '_> { _ => None, }) .collect(); - let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth }; + let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth, stack_top: self.ev.stack_top }; Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true))) } } impl<'h> Evaluator<'h> { pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self { - Evaluator { host, sheet, at, env: Vec::new(), depth: 0 } + Evaluator { host, sheet, at, env: Vec::new(), depth: 0, stack_top: 0 } } /// Evaluates to a value (references are dereferenced; multi-cell → array). @@ -205,10 +219,16 @@ impl<'h> Evaluator<'h> { } pub fn eval(&mut self, e: &Expr) -> Ev { + let here = stack_position(); + if self.depth == 0 { + self.stack_top = here; + } else if self.stack_top.abs_diff(here) > STACK_BUDGET { + return err(CellError::Num); + } self.depth += 1; if self.depth > MAX_DEPTH { self.depth -= 1; - return err(CellError::Calc); + return err(CellError::Num); } let r = self.eval_inner(e); self.depth -= 1; @@ -492,7 +512,7 @@ impl<'h> Evaluator<'h> { } _ => { // A long chain such as A1+A2+…+A500 nests to the left: fold it in a loop instead - // of one level of recursion per operator (past the depth limit it was #CALC!). + // of one level of recursion per operator (past the depth limit it was an error). let mut rest = vec![(op, b)]; let mut left = a; while let Expr::Binary(o, l, r) = left @@ -801,10 +821,11 @@ impl<'h> Evaluator<'h> { fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev { let Some(spec) = gridcraft_functions::lookup(name) else { // A defined name holding a LAMBDA, or a LET-bound lambda. - if let Ev::L(l) = self.name(name) { - return self.apply_lambda(&l, args); - } - return err(CellError::Name); + return match self.name(name) { + Ev::L(l) => self.apply_lambda(&l, args), + Ev::V(Value::Error(x)) => err(x), + _ => err(CellError::Name), + }; }; if args.len() < spec.min_args || args.len() > spec.max_args { return err(CellError::Value); diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 196c57de7..5cbbd5509 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -643,6 +643,38 @@ fn lambda_argument_count() { assert_eq!(t.get("A9"), Value::Bool(true)); } +#[test] +fn a_lambda_that_calls_itself_without_end_is_num_on_a_small_stack() { + // 1 MiB is the main thread's stack on Windows, where commands, recalculation and file open run. + let handle = std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(|| { + let mut t = T::new(); + t.wb.names.push(gridcraft_model::DefinedName { + name: "LOOP".into(), + scope: None, + formula: "LAMBDA(n,LOOP(n))".into(), + comment: String::new(), + hidden: false, + }); + t.calc.rebuild(&t.wb); + let endless = "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))"; + t.set("A1", endless); + t.set("A2", "=LOOP(1)"); + t.set("A3", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+SUM(MAP({1},LAMBDA(v,me(me,n-1)))))),F(F,100000))"); + t.set("A4", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+me(me,n-1))),F(F,50))"); + let evaluated = crate::evaluate(&t.wb, 0, c("B1"), endless); + [t.get("A1"), t.get("A2"), t.get("A3"), evaluated, t.get("A4")] + }) + .expect("spawn"); + let [endless, endless_name, through_map, evaluated, countdown] = handle.join().expect("recursion must not overflow the stack"); + assert_eq!(endless, Value::Error(CellError::Num)); + assert_eq!(endless_name, Value::Error(CellError::Num)); + assert_eq!(through_map, Value::Error(CellError::Num)); + assert_eq!(evaluated, Value::Error(CellError::Num)); + assert_eq!(countdown, Value::Number(50.0)); +} + #[test] fn names_and_sheets() { let mut t = T::new(); From 09973e7831ecdb03f8ee71ef9a3841cd26c6547f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 15:12:19 -0700 Subject: [PATCH 07/56] Contributors: add dotCooCoo (Robert Lee) --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index d522b84e0..96bead93e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -65,3 +65,7 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true From d0fdf948db4ad425b3fe19ec8ad8ee035d792059 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 18 Oct 2026 13:09:44 -0700 Subject: [PATCH 08/56] Calc: a recursive LAMBDA stops at a fixed count of 140 calls A LAMBDA recursion answered #NUM! once the formula had used 512 KiB of native stack, so the depth it reached depended on frame sizes. On a Windows release build the deepest countdown was F(F,94), where 0.7.0 reached F(F,131). The evaluator now counts the LAMBDA calls a formula has in progress, in call_lambda_values, which direct calls, defined names, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY all go through. The 141st call answers #NUM!. The countdown F(F,131), the deepest that the 400-level MAX_DEPTH allows, has 132 calls in progress and still runs. The stack check stays as a guard. Its budget rises to 896 KiB, which leaves 128 KiB of a 1 MiB thread for the frames above the formula. a_recursive_lambda_stops_at_the_same_call_on_any_stack_size runs recursions on each side of the limit on a 1 MiB and a 64 MiB thread and expects the same results. The 1 MiB test now runs the countdown F(F,131). --- crates/calc/src/eval.rs | 31 ++++++++++++++++++++++++++----- crates/calc/src/tests.rs | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 2bbedd551..bc60c5b7a 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -49,10 +49,16 @@ fn err(e: CellError) -> Ev { Ev::V(Value::Error(e)) } +/// LAMBDA calls that one formula may have in progress at once, counted the same way on every build. +/// The next call answers `#NUM!`. A countdown from 131, the deepest that `MAX_DEPTH` allows, has 132 +/// calls in progress, so it still runs. +const MAX_LAMBDA_CALLS: usize = 140; + /// Native stack, in bytes, that one formula's evaluation may use below its first `eval`. Past it, -/// `eval` answers `#NUM!`. One level of `MAX_DEPTH` can take several stack frames. The Windows main -/// thread and the web build have 1 MiB of stack. -const STACK_BUDGET: usize = 512 * 1024; +/// `eval` answers `#NUM!`. It guards the stack and leaves 128 KiB of a 1 MiB thread for the frames +/// above the formula. A cell that another cell's formula computes on demand gets its own `Evaluator`, +/// which starts its own budget. +const STACK_BUDGET: usize = 896 * 1024; /// The address of a local variable, which shows how far down the native stack this call is. #[inline(always)] @@ -96,6 +102,8 @@ pub struct Evaluator<'h> { pub at: CellRef, env: Vec<(String, Ev)>, depth: usize, + /// LAMBDA calls in progress. + calls: usize, /// `stack_position()` at the outermost `eval`. stack_top: usize, } @@ -135,14 +143,22 @@ impl Ctx for FnCtx<'_, '_> { _ => None, }) .collect(); - let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth, stack_top: self.ev.stack_top }; + let mut ev = Evaluator { + host: &mut *self.ev.host, + sheet: area.sheet, + at, + env, + depth: self.ev.depth, + calls: self.ev.calls, + stack_top: self.ev.stack_top, + }; Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true))) } } impl<'h> Evaluator<'h> { pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self { - Evaluator { host, sheet, at, env: Vec::new(), depth: 0, stack_top: 0 } + Evaluator { host, sheet, at, env: Vec::new(), depth: 0, calls: 0, stack_top: 0 } } /// Evaluates to a value (references are dereferenced; multi-cell → array). @@ -963,10 +979,15 @@ impl<'h> Evaluator<'h> { } fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev { + if self.calls >= MAX_LAMBDA_CALLS { + return err(CellError::Num); + } + self.calls += 1; let saved = std::mem::replace(&mut self.env, l.env.clone()); self.env.extend(bound); let r = self.eval(&l.body); self.env = saved; + self.calls -= 1; r } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 99ef117ce..d56bf0c13 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -662,7 +662,7 @@ fn a_lambda_that_calls_itself_without_end_is_num_on_a_small_stack() { t.set("A1", endless); t.set("A2", "=LOOP(1)"); t.set("A3", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+SUM(MAP({1},LAMBDA(v,me(me,n-1)))))),F(F,100000))"); - t.set("A4", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+me(me,n-1))),F(F,50))"); + t.set("A4", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+me(me,n-1))),F(F,131))"); let evaluated = crate::evaluate(&t.wb, 0, c("B1"), endless); [t.get("A1"), t.get("A2"), t.get("A3"), evaluated, t.get("A4")] }) @@ -672,7 +672,35 @@ fn a_lambda_that_calls_itself_without_end_is_num_on_a_small_stack() { assert_eq!(endless_name, Value::Error(CellError::Num)); assert_eq!(through_map, Value::Error(CellError::Num)); assert_eq!(evaluated, Value::Error(CellError::Num)); - assert_eq!(countdown, Value::Number(50.0)); + assert_eq!(countdown, Value::Number(131.0)); +} + +#[test] +fn a_recursive_lambda_stops_at_the_same_call_on_any_stack_size() { + let run = |stack: usize| { + std::thread::Builder::new() + .stack_size(stack) + .spawn(|| { + let mut wb = Workbook::new(); + wb.names.push(gridcraft_model::DefinedName { + name: "CNT".into(), + scope: None, + formula: "LAMBDA(n,IF(n=0,0,1+CNT(n-1)))".into(), + comment: String::new(), + hidden: false, + }); + // F(F,n) has n + 1 calls in progress at its deepest; W adds one more call per step. + let tail = |n: u32| format!("=LET(F,LAMBDA(me,n,IF(n=0,0,me(me,n-1))),F(F,{n}))"); + let wrapped = |n: u32| format!("=LET(W,LAMBDA(f,k,f(f,k)),F,LAMBDA(me,n,IF(n<=0,0,1+W(me,n-1))),F(F,{n}))"); + [tail(139), tail(140), wrapped(69), wrapped(70), "=CNT(131)".to_string()].map(|f| crate::evaluate(&wb, 0, c("B1"), &f)) + }) + .expect("spawn") + .join() + .expect("recursion must not overflow the stack") + }; + let small = run(1 << 20); + assert_eq!(small, [Value::Number(0.0), Value::Error(CellError::Num), Value::Number(69.0), Value::Error(CellError::Num), Value::Number(131.0)]); + assert_eq!(run(64 << 20), small); } #[test] From 450f457535e3c8537e45986deff5b602cff00e76 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 22 Oct 2026 11:06:01 -0700 Subject: [PATCH 09/56] Calc: a formula calculated inside another one shares its stack budget A cell that another cell's formula calculates on demand got its own evaluator, which started a new 896 KiB stack budget wherever it was called. Two cells that each count down from 131 with a LAMBDA, one read at the deepest call of the other, overflowed a 1 MiB thread. The budget of a formula calculated on demand is now counted from where the outermost formula started. When it runs out there, the formula is calculated again from the top, as a formula needed at the nesting limit is, so its value does not depend on the order cells are calculated in. --- crates/calc/src/eval.rs | 19 ++++++++++++++----- crates/calc/src/recalc.rs | 31 ++++++++++++++++++++++++++++++- crates/calc/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 1d39fd818..f2f5f5ef4 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -60,13 +60,13 @@ const MAX_LAMBDA_CALLS: usize = 140; /// Native stack, in bytes, that one formula's evaluation may use below its first `eval`. Past it, /// `eval` answers `#NUM!`. It guards the stack and leaves 128 KiB of a 1 MiB thread for the frames -/// above the formula. A cell that another cell's formula computes on demand gets its own `Evaluator`, -/// which starts its own budget. +/// above the formula. A cell that another cell's formula computes on demand shares the budget of the +/// outermost formula ([`Host::stack_top`]). const STACK_BUDGET: usize = 896 * 1024; /// The address of a local variable, which shows how far down the native stack this call is. #[inline(always)] -fn stack_position() -> usize { +pub(crate) fn stack_position() -> usize { let marker = 0u8; std::hint::black_box(&marker) as *const u8 as usize } @@ -98,6 +98,13 @@ pub trait Host { fn visit_range(&mut self, _sheet: usize, _range: RangeRef, _f: &mut dyn FnMut(&Value) -> bool) -> bool { false } + /// While a formula is calculated on demand inside another one: `stack_position()` where the + /// outermost one started, which the stack budget is counted from. + fn stack_top(&self) -> Option { + None + } + /// Called when a formula's evaluation runs past the stack budget. + fn stack_exhausted(&mut self) {} } pub struct Evaluator<'h> { @@ -265,8 +272,10 @@ impl<'h> Evaluator<'h> { pub fn eval(&mut self, e: &Expr) -> Ev { let here = stack_position(); if self.depth == 0 { - self.stack_top = here; - } else if self.stack_top.abs_diff(here) > STACK_BUDGET { + self.stack_top = self.host.stack_top().unwrap_or(here); + } + if self.stack_top.abs_diff(here) > STACK_BUDGET { + self.host.stack_exhausted(); return err(CellError::Num); } self.depth += 1; diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 66374bed2..eabc29314 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -44,7 +44,7 @@ use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value}; use gridcraft_formula::Expr; use gridcraft_model::{CalcMode, Workbook}; -use crate::eval::{Area, Evaluator, Host, precedents}; +use crate::eval::{Area, Evaluator, Host, precedents, stack_position}; pub type Key = (usize, CellRef); @@ -432,6 +432,10 @@ struct PassHost<'a> { tainted: HashSet, /// Whether the formula being evaluated has read a previous value. taint: bool, + /// `stack_position()` where the outermost formula being calculated started. + stack_top: usize, + /// Whether an evaluation ran past the stack budget since the innermost formula started. + out_of_stack: bool, } impl<'a> PassHost<'a> { @@ -462,6 +466,8 @@ impl<'a> PassHost<'a> { members: HashSet::default(), tainted: HashSet::default(), taint: false, + stack_top: 0, + out_of_stack: false, } } } @@ -580,6 +586,9 @@ impl PassHost<'_> { self.suspend = Some(k); return Value::Error(CellError::Calc); } + if self.depth == 0 { + self.stack_top = stack_position(); + } self.in_progress.insert(k); self.depth += 1; let outer_taint = std::mem::replace(&mut self.taint, false); @@ -587,10 +596,24 @@ impl PassHost<'_> { self.stack.push(k); } let expr = std::sync::Arc::clone(expr); + let outer_out_of_stack = std::mem::replace(&mut self.out_of_stack, false); let v = { let mut ev = Evaluator::new(self, k.0, k.1); ev.value(&expr) }; + let out_of_stack = std::mem::replace(&mut self.out_of_stack, outer_out_of_stack); + if out_of_stack && self.depth > 1 && self.suspend.is_none() { + // It ran out of the stack left below the formulas that read it: it is calculated + // from the top instead, as a formula needed at `MAX_NESTED` levels deep is. + self.suspend = Some(k); + self.depth -= 1; + self.in_progress.remove(&k); + if self.iterate { + self.stack.pop(); + } + self.taint = outer_taint; + return Value::Error(CellError::Calc); + } if self.suspend.is_some() { // Abandoned: `calculate` evaluates this formula again once the one it needs is done. self.depth -= 1; @@ -745,6 +768,12 @@ impl Host for PassHost<'_> { fn workbook(&self) -> &Workbook { self.wb } + fn stack_top(&self) -> Option { + (self.depth > 1).then_some(self.stack_top) + } + fn stack_exhausted(&mut self) { + self.out_of_stack = true; + } fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option { let k = (sheet, anchor); if !(self.pending.contains(&k) || self.results.contains_key(&k)) { diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 608af9320..6106f1bfc 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -834,6 +834,40 @@ fn a_recursive_lambda_stops_at_the_same_call_on_any_stack_size() { assert_eq!(run(64 << 20), small); } +#[test] +fn a_recursive_lambda_that_reads_another_cells_recursive_lambda_shares_the_stack() { + let run = |stack: usize| { + std::thread::Builder::new() + .stack_size(stack) + .spawn(|| { + // Row-major order calculates A1 first, so each cell below is calculated on demand + // at the deepest call of the one above it. + let countdown = |then: &str| format!("=LET(F,LAMBDA(me,n,IF(n<=0,{then},1+me(me,n-1))),F(F,131))"); + let mut wb = Workbook::new(); + let sheet = wb.sheet_mut(0).expect("sheet"); + for (at, f) in [ + ("A1", countdown("A2")), + ("A2", countdown("A3")), + ("A3", countdown("0")), + ("B1", countdown("B2")), + ("B2", "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))".to_string()), + ] { + sheet.cells.set(c(at), Cell::formula(Formula::new(&f))); + } + let mut calc = Calc::new(); + calc.recalc_all(&mut wb); + ["A1", "A2", "A3", "B1", "B2"].map(|at| wb.sheet(0).map(|s| s.value(c(at))).unwrap_or_default()) + }) + .expect("spawn") + .join() + .expect("recursion must not overflow the stack") + }; + let small = run(1 << 20); + let num = Value::Error(CellError::Num); + assert_eq!(small, [Value::Number(393.0), Value::Number(262.0), Value::Number(131.0), num.clone(), num]); + assert_eq!(run(64 << 20), small); +} + #[test] fn names_and_sheets() { let mut t = T::new(); From d5aa6fbcbcabaf195c904202bcf3c44a5b094f9e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 21:56:39 +0200 Subject: [PATCH 10/56] Conditional formatting: New Rule's formula box takes references clicked or dragged on the grid (#2348) --- crates/ui-egui/src/dialogs.rs | 84 +++++++++++++ crates/ui-egui/src/grid.rs | 22 +++- crates/ui-egui/tests/it/cf_rule_pick.rs | 160 ++++++++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/ui-parity.md | 6 +- 5 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/it/cf_rule_pick.rs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bfb203c16..8be7f55cb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7,6 +7,7 @@ use egui::{Color32, Key, Sense, Stroke, StrokeKind, vec2}; use crate::icons::{self, Icon}; +use gridcraft_engine::core::{CellRef, RangeRef}; use serde_json::{Map, Value as Json, json}; use crate::SheetApp; @@ -44,6 +45,24 @@ pub struct Dialog { /// The dialog this one was opened from (Name Manager › New…): it comes back when this closes. back: Option>, focus_initialized: bool, + /// The formula box that takes references clicked or dragged on the grid (New Formatting Rule). + pick: Option, +} + +/// A formula box in point mode, as in Excel: a click on the grid writes the cell's absolute +/// reference at the caret instead of selecting it, and a drag grows it into a range. +#[derive(Clone, Debug)] +struct RefPick { + key: &'static str, + /// The sheet the dialog was opened on: references to another sheet carry its name. + sheet: usize, + /// Caret (char index), mirrored from the text box while it has the keyboard. + caret: usize, + /// The reference last inserted (byte span), replaced by the next click or the drag. + point: Option<(usize, usize)>, + anchor: CellRef, + /// Give the box the keyboard back, caret after the reference, on the next frame. + refocus: bool, } impl Dialog { @@ -77,6 +96,43 @@ impl Dialog { self.values.insert(key.into(), value); } + /// Whether a click on the grid goes to a formula box of this dialog rather than the selection. + pub fn picks_references(&self) -> bool { + self.pick.is_some() + } + + /// A click (`extend` false) or a drag reaching `cell`: writes the absolute reference at the + /// formula box's caret, replacing the reference the press inserted, as in the formula bar's + /// point mode. An empty box starts with `=`. After an operand there is no place for a + /// reference, so nothing changes. `active_sheet` names the sheet shown when it is not the + /// dialog's own. + pub fn pick_reference(&mut self, cell: CellRef, active_sheet: (usize, &str), extend: bool) { + let Some(pick) = self.pick.as_mut() else { return }; + let text = self.values.get(pick.key).and_then(Json::as_str).unwrap_or("").to_string(); + let mut ed = crate::editor::EditState::new(pick.sheet, cell, text, true, false); + ed.caret = pick.caret.min(ed.text.chars().count()); + ed.point = pick.point; + if ed.text.trim().is_empty() { + ed.text = "=".into(); + ed.caret = 1; + ed.point = None; + } + if !ed.can_point() { + return; + } + if !extend { + pick.anchor = cell; + } + let range = abs(&RangeRef::new(pick.anchor, cell)); + let reference = + if active_sheet.0 == pick.sheet { range } else { format!("{}!{range}", gridcraft_engine::formula::quote_sheet(active_sheet.1)) }; + ed.insert_ref(&reference); + pick.caret = ed.caret; + pick.point = ed.point; + pick.refocus = true; + self.values.insert(pick.key.into(), json!(ed.text)); + } + fn form(name: &str, title: &str, command: &'static str, fields: Vec, defaults: Json) -> Dialog { Dialog { name: name.into(), @@ -94,6 +150,7 @@ impl Dialog { picture_target: None, back: None, focus_initialized: false, + pick: None, } } @@ -114,6 +171,7 @@ impl Dialog { picture_target: None, back: None, focus_initialized: false, + pick: None, } } @@ -690,6 +748,10 @@ impl Dialog { fields, json!({"preset": "lightRedFillDarkRedText", "period": "today"}), ); + if ty == "expression" { + let sheet = app.session.active().map(|doc| doc.wb.active_sheet).unwrap_or(0); + d.pick = Some(RefPick { key: "formula", sheet, caret: 0, point: None, anchor: CellRef::new(0, 0), refocus: false }); + } d.values.insert("type".into(), json!(ty)); d.values.insert("operator".into(), p("operator")); d @@ -1634,6 +1696,25 @@ fn take_first_focus(d: &mut Dialog, r: &egui::Response) { d.focus_first_field = false; } +/// Keeps a picking formula box's caret, and gives it the keyboard back after a grid click put a +/// reference in it (the click took the focus), its caret after the reference. +fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) { + if typed { + pick.point = None; + } + if pick.refocus { + let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(pick.caret)))); + state.store(&r.ctx, r.id); + r.request_focus(); + pick.refocus = false; + } else if r.has_focus() + && let Some(range) = egui::text_edit::TextEditState::load(&r.ctx, r.id).and_then(|state| state.cursor.char_range()) + { + pick.caret = range.primary.index.into(); + } +} + /// Draws a dialog's `fields`, editing its values. fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { // Field labels, choices and notes are English source strings, translated as they are drawn. @@ -1659,6 +1740,9 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { if r.changed() { d.values.insert(key.into(), json!(s)); } + if let Some(pick) = d.pick.as_mut().filter(|pick| pick.key == key) { + track_pick(pick, &r, r.changed()); + } }); } Field::Password { key, label } => { diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 590294904..157f608fe 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2336,6 +2336,19 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & app.grid.filter_menu = Some((c.col, c.row, geo.mirror_pos(p))); return; } + // A dialog's formula box in point mode (New Formatting Rule) takes the reference; the + // selection, which the dialog acts on, stays. + if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { + d.pick_reference(c, (wb.active_sheet, &sh.name), false); + app.grid.drag = Drag::Select; + app.grid.drag_select = true; + if resp.drag_started() + && let Some(p) = pos + { + select_to_pointer(app, geo, sh, wb, p); + } + return; + } // Point mode while editing a formula: clicking inserts a reference. if let Some(ed) = app.editor.as_mut() && ed.can_point() @@ -2643,7 +2656,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & /// An armed Format Painter pastes onto the cells just clicked or dragged over (not while /// pointing a formula reference, which also drags a Select). fn format_painter_apply(app: &mut SheetApp) { - if app.session.format_painter.is_some() && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) { + if app.session.format_painter.is_some() + && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) + && !app.dialog.as_ref().is_some_and(|d| d.picks_references()) + { let dest = app.session.active().map(|d| d.selection.current().a1()); if let Some(dest) = dest && let Err(e) = app.run("edit.formatPainter", json!({"apply": dest})) @@ -2696,7 +2712,9 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)), ); - if let Some(ed) = app.editor.as_mut() + if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { + d.pick_reference(cell, (wb.active_sheet, &sh.name), true); + } else if let Some(ed) = app.editor.as_mut() && ed.point.is_some() { if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet).map(|(_, cell)| cell).or(ed.point_cell) { diff --git a/crates/ui-egui/tests/it/cf_rule_pick.rs b/crates/ui-egui/tests/it/cf_rule_pick.rs new file mode 100644 index 000000000..dc004ff25 --- /dev/null +++ b/crates/ui-egui/tests/it/cf_rule_pick.rs @@ -0,0 +1,160 @@ +//! New Formatting Rule ▸ formula: clicking or dragging on the grid writes the absolute reference +//! into the formula box at its caret, as in Excel, and leaves the selection the rule applies to alone. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("home.insertSheet", json!({})).unwrap(); + session.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + session.execute("selection.set", json!({"range": "F8:G9"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h.state_mut().open_dialog("cfQuick", json!({"type": "expression", "title": "New Formatting Rule"})); + h.run_steps(6); + h +} + +fn formula(h: &Harness) -> String { + h.state().dialog.as_ref().and_then(|d| d.values.get("formula")).and_then(|v| v.as_str()).unwrap_or("").to_string() +} + +fn selection(h: &Harness) -> String { + h.state().session.active().unwrap().selection.current().a1() +} + +fn type_text(h: &mut Harness, text: &str) { + for c in text.chars() { + h.input_mut().events.push(Event::Text(c.to_string())); + h.run_steps(1); + } +} + +fn cell_center(h: &Harness, a1: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + let c = gridcraft_engine::core::CellRef::parse(a1).unwrap(); + pos2(geo.x(sheet, c.col) + sheet.col_width(c.col) * geo.z * 0.5, geo.y(sheet, c.row) + sheet.row_height(c.row) * geo.z * 0.5) +} + +fn click(h: &mut Harness, a1: &str) { + let pos = cell_center(h, a1); + for pressed in [true, false] { + h.input_mut() + .events + .extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + h.step(); + } + h.run_steps(3); +} + +fn drag(h: &mut Harness, from: &str, to: &str) { + let (a, b) = (cell_center(h, from), cell_center(h, to)); + h.input_mut() + .events + .extend([Event::PointerMoved(a), Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }]); + h.step(); + for i in 1..=6 { + let p = a + (b - a) * (i as f32 / 6.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.step(); + h.run_steps(3); +} + +#[test] +fn clicking_a_cell_after_equals_inserts_its_absolute_reference() { + let mut h = harness(); + type_text(&mut h, "="); + assert_eq!(formula(&h), "=", "the formula box had the keyboard"); + click(&mut h, "B2"); + assert_eq!(formula(&h), "=$B$2"); + assert_eq!(selection(&h), "F8:G9", "the range the rule applies to is still selected"); +} + +#[test] +fn clicking_a_cell_in_an_empty_box_starts_the_formula() { + let mut h = harness(); + click(&mut h, "C3"); + assert_eq!(formula(&h), "=$C$3"); + assert_eq!(selection(&h), "F8:G9"); +} + +#[test] +fn a_second_click_replaces_the_reference_just_inserted() { + let mut h = harness(); + type_text(&mut h, "="); + click(&mut h, "B2"); + click(&mut h, "D5"); + assert_eq!(formula(&h), "=$D$5"); +} + +#[test] +fn dragging_inserts_a_range() { + let mut h = harness(); + type_text(&mut h, "="); + drag(&mut h, "B2", "C4"); + assert_eq!(formula(&h), "=$B$2:$C$4"); + assert_eq!(selection(&h), "F8:G9"); +} + +#[test] +fn the_reference_goes_at_the_caret_and_typing_continues_after_it() { + let mut h = harness(); + type_text(&mut h, "=SUM()"); + h.key_press(Key::ArrowLeft); + h.run_steps(2); + click(&mut h, "B2"); + assert_eq!(formula(&h), "=SUM($B$2)"); + type_text(&mut h, "+1"); + assert_eq!(formula(&h), "=SUM($B$2+1)", "the box has the keyboard back, its caret after the reference"); +} + +#[test] +fn a_click_after_an_operand_changes_nothing() { + let mut h = harness(); + type_text(&mut h, "=A1>5"); + click(&mut h, "B2"); + assert_eq!(formula(&h), "=A1>5"); + assert_eq!(selection(&h), "F8:G9"); +} + +#[test] +fn ok_applies_the_picked_formula_to_the_selection() { + let mut h = harness(); + type_text(&mut h, "="); + click(&mut h, "B2"); + type_text(&mut h, ">0"); + h.key_press(Key::Enter); + h.run_steps(4); + assert!(h.state().dialog.is_none(), "Enter confirmed the rule"); + let sheet = h.state().session.active().unwrap().wb.active().unwrap(); + let cf = sheet.cond_formats.last().expect("a rule was added"); + assert_eq!(cf.ranges.iter().map(|r| r.a1()).collect::>(), ["F8:G9"]); + assert!(format!("{:?}", cf.rule).contains("$B$2>0"), "rule: {:?}", cf.rule); +} + +#[test] +fn other_dialogs_leave_grid_clicks_alone() { + let mut h = harness(); + h.state_mut().dialog = None; + h.state_mut().open_dialog("cfQuick", json!({"type": "containsText", "title": "Text that Contains"})); + h.run_steps(6); + click(&mut h, "B2"); + assert_eq!(selection(&h), "B2", "a box that is not a formula does not take references"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index b89253bb9..94d654089 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -10,6 +10,7 @@ mod autosave_persist; mod borders; mod cell_editor_caret; mod cell_pictures; +mod cf_rule_pick; mod chart_resize; mod clipboard; mod comments_pane; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c7cbc349e..4795fb196 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-22 · **Change:** minor (Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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; 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 @@ -152,6 +152,9 @@ Seek, Scenario Manager, Data Analysis, Forecast Sheet and others exist. Missing Solver, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges, Workbook Links, Accessibility checker. Like Excel's, every dialog opens centred and moves by its title bar, keeping its place while it is open (#1250). +New Formatting Rule's formula box points like the formula bar: a click on the grid writes the +cell's absolute reference at the caret (`=$B$2`) and a drag a range, leaving the selection the rule +applies to as it is (#2348). Other dialogs' reference fields don't take clicks yet. ## Accessibility @@ -175,6 +178,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) | | 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is | | 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) | | 2026-10-22 | minor | Show Outline Symbols (`view.showOutlineSymbols`, Ctrl+8): a sheet can hide the outline's level buttons and brackets while keeping its outline, as Excel saves with `showOutlineSymbols="0"` (read and written); the grid then gives the bands no room | From 45f06f50da6c32fd2f76b8a9de0673d5dd2e3675 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 22:05:24 +0200 Subject: [PATCH 11/56] Conditional formatting: New Rule keeps the range it was opened for when pointing on another sheet (#2348) --- crates/ui-egui/src/dialogs.rs | 15 +++++++++++++-- crates/ui-egui/tests/it/cf_rule_pick.rs | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8be7f55cb..95855e56a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -63,6 +63,9 @@ struct RefPick { anchor: CellRef, /// Give the box the keyboard back, caret after the reference, on the next frame. refocus: bool, + /// The selection the rule applies to, fixed when the dialog opened as Excel's "Applies to": + /// pointing on another sheet must not move the rule there. + applies_to: String, } impl Dialog { @@ -749,8 +752,12 @@ impl Dialog { json!({"preset": "lightRedFillDarkRedText", "period": "today"}), ); if ty == "expression" { - let sheet = app.session.active().map(|doc| doc.wb.active_sheet).unwrap_or(0); - d.pick = Some(RefPick { key: "formula", sheet, caret: 0, point: None, anchor: CellRef::new(0, 0), refocus: false }); + let (sheet, applies_to) = app + .session + .active() + .map(|doc| (doc.wb.active_sheet, doc.selection.ranges.iter().map(RangeRef::a1).collect::>().join(","))) + .unwrap_or_default(); + d.pick = Some(RefPick { key: "formula", sheet, caret: 0, point: None, anchor: CellRef::new(0, 0), refocus: false, applies_to }); } d.values.insert("type".into(), json!(ty)); d.values.insert("operator".into(), p("operator")); @@ -1177,6 +1184,10 @@ pub(crate) fn build_params(d: &Dialog) -> Json { rule.insert(k.clone(), val.clone()); } v = json!({"rule": Json::Object(rule)}); + if let Some(pick) = d.pick.as_ref().filter(|pick| !pick.applies_to.is_empty()) { + v["sheet"] = json!(pick.sheet); + v["range"] = json!(pick.applies_to); + } } "series" => { if let Some(s) = d.values.get("stop").and_then(Json::as_str).and_then(|s| s.parse::().ok()) { diff --git a/crates/ui-egui/tests/it/cf_rule_pick.rs b/crates/ui-egui/tests/it/cf_rule_pick.rs index dc004ff25..642a91a18 100644 --- a/crates/ui-egui/tests/it/cf_rule_pick.rs +++ b/crates/ui-egui/tests/it/cf_rule_pick.rs @@ -158,3 +158,20 @@ fn other_dialogs_leave_grid_clicks_alone() { click(&mut h, "B2"); assert_eq!(selection(&h), "B2", "a box that is not a formula does not take references"); } + +#[test] +fn pointing_on_another_sheet_keeps_the_rule_on_the_sheet_it_was_opened_for() { + let mut h = harness(); + type_text(&mut h, "="); + h.state_mut().session.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + h.run_steps(3); + click(&mut h, "A1"); + assert_eq!(formula(&h), "=Sheet2!$A$1"); + h.key_press(Key::Enter); + h.run_steps(4); + assert!(h.state().dialog.is_none(), "Enter confirmed the rule"); + let wb = &h.state().session.active().unwrap().wb; + assert!(wb.sheet(1).unwrap().cond_formats.is_empty(), "nothing lands on the sheet pointed at"); + let cf = wb.sheet(0).unwrap().cond_formats.last().expect("the rule is on the sheet the dialog was opened on"); + assert_eq!(cf.ranges.iter().map(|r| r.a1()).collect::>(), ["F8:G9"]); +} From 4b2ee2e2dd8c4e71793fa8a658242627e356fd22 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 22:07:22 +0200 Subject: [PATCH 12/56] Conditional formatting: New Rule remembers its sheet by name, so moving or inserting sheets while it is open keeps the rule there (#2348) --- crates/ui-egui/src/dialogs.rs | 27 ++++++++++++++++--------- crates/ui-egui/src/grid.rs | 4 ++-- crates/ui-egui/tests/it/cf_rule_pick.rs | 20 ++++++++++++++++++ 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 95855e56a..e83db2fa1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -54,8 +54,9 @@ pub struct Dialog { #[derive(Clone, Debug)] struct RefPick { key: &'static str, - /// The sheet the dialog was opened on: references to another sheet carry its name. - sheet: usize, + /// The sheet the dialog was opened on, by name: its index changes when sheets are inserted or + /// moved while the dialog is open. References to another sheet carry that sheet's name. + sheet: String, /// Caret (char index), mirrored from the text box while it has the keyboard. caret: usize, /// The reference last inserted (byte span), replaced by the next click or the drag. @@ -109,10 +110,10 @@ impl Dialog { /// point mode. An empty box starts with `=`. After an operand there is no place for a /// reference, so nothing changes. `active_sheet` names the sheet shown when it is not the /// dialog's own. - pub fn pick_reference(&mut self, cell: CellRef, active_sheet: (usize, &str), extend: bool) { + pub fn pick_reference(&mut self, cell: CellRef, active_sheet: &str, extend: bool) { let Some(pick) = self.pick.as_mut() else { return }; let text = self.values.get(pick.key).and_then(Json::as_str).unwrap_or("").to_string(); - let mut ed = crate::editor::EditState::new(pick.sheet, cell, text, true, false); + let mut ed = crate::editor::EditState::new(0, cell, text, true, false); ed.caret = pick.caret.min(ed.text.chars().count()); ed.point = pick.point; if ed.text.trim().is_empty() { @@ -127,8 +128,11 @@ impl Dialog { pick.anchor = cell; } let range = abs(&RangeRef::new(pick.anchor, cell)); - let reference = - if active_sheet.0 == pick.sheet { range } else { format!("{}!{range}", gridcraft_engine::formula::quote_sheet(active_sheet.1)) }; + let reference = if active_sheet.eq_ignore_ascii_case(&pick.sheet) { + range + } else { + format!("{}!{range}", gridcraft_engine::formula::quote_sheet(active_sheet)) + }; ed.insert_ref(&reference); pick.caret = ed.caret; pick.point = ed.point; @@ -755,7 +759,12 @@ impl Dialog { let (sheet, applies_to) = app .session .active() - .map(|doc| (doc.wb.active_sheet, doc.selection.ranges.iter().map(RangeRef::a1).collect::>().join(","))) + .map(|doc| { + ( + doc.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), + doc.selection.ranges.iter().map(RangeRef::a1).collect::>().join(","), + ) + }) .unwrap_or_default(); d.pick = Some(RefPick { key: "formula", sheet, caret: 0, point: None, anchor: CellRef::new(0, 0), refocus: false, applies_to }); } @@ -1184,8 +1193,8 @@ pub(crate) fn build_params(d: &Dialog) -> Json { rule.insert(k.clone(), val.clone()); } v = json!({"rule": Json::Object(rule)}); - if let Some(pick) = d.pick.as_ref().filter(|pick| !pick.applies_to.is_empty()) { - v["sheet"] = json!(pick.sheet); + if let Some(pick) = d.pick.as_ref().filter(|pick| !pick.applies_to.is_empty() && !pick.sheet.is_empty()) { + v["sheet"] = json!(pick.sheet.as_str()); v["range"] = json!(pick.applies_to); } } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 157f608fe..87ac5787e 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2339,7 +2339,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & // A dialog's formula box in point mode (New Formatting Rule) takes the reference; the // selection, which the dialog acts on, stays. if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { - d.pick_reference(c, (wb.active_sheet, &sh.name), false); + d.pick_reference(c, &sh.name, false); app.grid.drag = Drag::Select; app.grid.drag_select = true; if resp.drag_started() @@ -2713,7 +2713,7 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)), ); if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { - d.pick_reference(cell, (wb.active_sheet, &sh.name), true); + d.pick_reference(cell, &sh.name, true); } else if let Some(ed) = app.editor.as_mut() && ed.point.is_some() { diff --git a/crates/ui-egui/tests/it/cf_rule_pick.rs b/crates/ui-egui/tests/it/cf_rule_pick.rs index 642a91a18..a523c9c6d 100644 --- a/crates/ui-egui/tests/it/cf_rule_pick.rs +++ b/crates/ui-egui/tests/it/cf_rule_pick.rs @@ -175,3 +175,23 @@ fn pointing_on_another_sheet_keeps_the_rule_on_the_sheet_it_was_opened_for() { let cf = wb.sheet(0).unwrap().cond_formats.last().expect("the rule is on the sheet the dialog was opened on"); assert_eq!(cf.ranges.iter().map(|r| r.a1()).collect::>(), ["F8:G9"]); } + +#[test] +fn the_rule_follows_its_sheet_when_sheets_move_while_the_dialog_is_open() { + let mut h = harness(); + type_text(&mut h, "="); + click(&mut h, "B2"); + h.state_mut().session.execute("sheet.move", json!({"sheet": 0, "to": 1})).unwrap(); + h.run_steps(3); + let names = |h: &Harness| h.state().session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect::>(); + assert_eq!(names(&h), ["Sheet2", "Sheet1"], "Sheet1 now sits at index 1"); + h.key_press(Key::Enter); + h.run_steps(4); + assert!(h.state().dialog.is_none(), "Enter confirmed the rule"); + let wb = &h.state().session.active().unwrap().wb; + let sheet1 = wb.sheets.iter().find(|s| s.name == "Sheet1").unwrap(); + let sheet2 = wb.sheets.iter().find(|s| s.name == "Sheet2").unwrap(); + assert!(sheet2.cond_formats.is_empty(), "nothing lands on the sheet that took index 0"); + let cf = sheet1.cond_formats.last().expect("the rule stays on Sheet1"); + assert_eq!(cf.ranges.iter().map(|r| r.a1()).collect::>(), ["F8:G9"]); +} From 1ce4781d3e059e14696cd4a351f61e4242062767 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:24:18 -0500 Subject: [PATCH 13/56] Accuracy: PMT to Excel's last bits via ln_1p/exp_m1 PMT computed (1 + rate)^nper with powf, which rounds 1 + rate first and drops the low bits of a small rate. Over 400 synthetic probes that left PMT up to about 1000 ULPs from Excel (59 bit-exact); the ln_1p/exp_m1 form is within 4 ULPs (244 bit-exact). A loan schedule guarded by IF(p - SUM(principal) > 0, ...) turns on those bits: in 3000 synthetic schedules main disagreed with Excel on the number of live periods in 1382, the fix in 823. Also follows Excel at the edges: a rate of -1 or less is #NUM!, a huge nper gives -pv*rate instead of #NUM!, a tiny rate no longer collapses, and a small negative rate keeps its payment. IPMT, PPMT, CUMIPMT and CUMPRINC pick this up through pmt_calc. FV and PV are unchanged: Excel's own FV/PV sit further from the exact value than the current powf form does. The tests allow the one-ULP libm difference between platforms. --- crates/calc/src/tests.rs | 37 ++++++++++++++++++++ crates/functions/src/financial.rs | 58 +++++++++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 622ed6cc9..e71015213 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1781,3 +1781,40 @@ fn text_before_after_empty_instance_defaults_to_one() { t.set("A4", "=TEXTBEFORE(\"a-b\",\"-\",0)"); assert_eq!(t.get("A4"), Value::Error(CellError::Value)); } + +#[test] +fn loan_schedule_residue_keeps_excels_sign() { + // A synthetic monthly loan schedule: interest on the opening balance, principal = PMT - interest, + // each later period guarded by IF(p - SUM(principal so far) > 0, ...). After the 12th payment + // Excel is left with 2^-30 > 0, so it takes a 13th, tiny-interest period. That last bit comes + // from PMT; the old (1 + rate)^nper form left -2.8e-8 and returned "" instead. + let mut t = T::new(); + t.set("B1", "4653300"); + t.set("B2", "1"); + t.set("B3", "0.141"); + let col = |i: u8| char::from(b'A' + i).to_string(); + t.set("C8", "=$B$1*($B$3/12)"); + t.set("C7", "=PMT($B$3/12,$B$2*12,-$B$1)-C8"); + t.set("D8", "=($B1-C7)*($B$3/12)"); + t.set("D7", "=PMT($B$3/12,$B$2*12,-$B$1)-D8"); + for i in 4..16 { + let (c, p) = (col(i), col(i - 1)); + t.set(&format!("{c}8"), &format!("=IF($B1-SUM($C7:{p}7)>0,($B1-SUM($C7:{p}7))*($B$3/12),\"\")")); + t.set(&format!("{c}7"), &format!("=IF($B1-SUM($C7:{p}7)>0,PMT($B$3/12,$B$2*12,-$B$1)-{c}8,\"\")")); + } + t.set("N4", "=IF($B$1-SUM($C7:N7)>0,$B$1-SUM($C7:N7),\"\")"); + // Excel's values (Value2) for the same synthetic inputs. + // The leftover (2^-30 in Excel) is 12 payments' worth of rounding, so a one-ULP libm difference + // moves it by as much as its own size: only its sign is checked, and the 13th period must exist. + let positive = |cell: &str| match t.get(cell) { + Value::Number(x) => assert!(x > 0.0 && x < 1e-6, "{cell}: {x}"), + other => panic!("{cell}: expected a small positive number, got {other:?}"), + }; + positive("N4"); + positive("O8"); + match t.get("O7") { + Value::Number(x) => assert!((x - 418025.37230211904).abs() < 1e-3, "O7: {x}"), + other => panic!("O7: expected a number, got {other:?}"), + } + assert_eq!(t.get("P7"), Value::Text("".into())); +} diff --git a/crates/functions/src/financial.rs b/crates/functions/src/financial.rs index af28326e7..043c09ed6 100644 --- a/crates/functions/src/financial.rs +++ b/crates/functions/src/financial.rs @@ -13,12 +13,31 @@ use crate::{Arg, Ctx, FnSpec, VAR}; // Time value of money // --------------------------------------------------------------------------------------------- +/// The payment per period. `(1 + rate)^nper` comes from `nper * ln_1p(rate)` and `p - 1` from +/// `exp_m1`: rounding `1 + rate` first throws away the low bits of a small rate, which left PMT up to +/// about 1000 ULPs from Excel (observed over 400 synthetic probes; this form is within 4). A loan +/// schedule's last `IF(p - SUM(principal) > 0, ...)` turns on those bits. Excel gives #NUM! for a +/// rate of -1 or less, and -pv*rate (no divide) once `(1 + rate)^nper` overflows. fn pmt_calc(rate: f64, nper: f64, pv: f64, fv: f64, typ: f64) -> f64 { if rate == 0.0 { return -(pv + fv) / nper; } - let p = (1.0 + rate).powf(nper); - -(rate * (fv + pv * p)) / ((1.0 + rate * typ) * (p - 1.0)) + if rate <= -1.0 { + return f64::NAN; + } + let l = nper * rate.ln_1p(); + let m = l.exp_m1(); + let k = 1.0 + rate * typ; + if m > 0.0 { + // (fv + pv*p) / (p - 1) = pv + (pv + fv) / (p - 1), without the cancellation in p - 1. + -(pv + (pv + fv) / m) * rate / k + } else if m > -0.5 { + // p near 1 from below: fv + pv*p = (pv + fv) + pv*m keeps the small terms that rounding p would drop. + -(rate * (pv + fv + pv * m)) / (k * m) + } else { + // p well below 1: m nears -1 and pv*m would swallow pv + fv, so use p itself. + -(rate * (fv + pv * l.exp())) / (k * m) + } } fn fv_calc(rate: f64, nper: f64, pmt: f64, pv: f64, typ: f64) -> f64 { @@ -1790,6 +1809,41 @@ mod tests { is_err(ev("CUMIPMT", vec![n(0.01), n(360.0), n(125000.0), n(1.0), n(1.0), n(2.0)]), CellError::Num); } + /// PMT within about 4 ULPs of Excel's (Value2 of synthetic probes; libm differs by an ULP across + /// platforms). The old `(1 + rate)^nper` form was 25 to 91 ULPs off on these, enough to flip a loan schedule's final `IF(p - SUM(r) > 0, ...)`. + #[test] + fn pmt_matches_excel_within_a_few_ulps() { + for (rate, nper, pv, fv, typ, excel) in [ + (0.141 / 12.0, 12.0, -4653300.0, 0.0, 0.0, 418025.37230211904), + (0.005716666666666666, 26.0, 42678411.0, -26010.62, 0.0, -1770235.3828507778), + (0.012164, 29.0, 16722649.0, 0.0, 1.0, -679516.0784762775), + (0.009916666666666666, 5.0, 8590842.8, 0.0, 0.0, -1769620.32624324), + (0.011158333333333333, 36.0, 21457108.0, 49218.26, 1.0, -720095.3912155787), + (-0.5, 2.5, 100.0, 0.0, 0.0, -10.736861692729645), + ] { + let got = ev("PMT", vec![n(rate), n(nper), n(pv), n(fv), n(typ)]); + close_tol(got, excel, 1e-15); + } + } + + /// Edge cases observed in Excel: a rate of -1 or less is #NUM!, a huge nper leaves -pv*rate, and a + /// tiny rate no longer loses its low digits in 1 + rate. + #[test] + fn pmt_edge_cases_follow_excel() { + is_err(ev("PMT", vec![n(-2.0), n(3.0), n(100.0)]), CellError::Num); + is_err(ev("PMT", vec![n(-1.0), n(3.0), n(100.0)]), CellError::Num); + is_err(ev("PMT", vec![n(-2.0), n(2.5), n(100.0)]), CellError::Num); + assert_eq!(ev("PMT", vec![n(0.1), n(10000.0), n(100.0)]), Value::Number(-10.0)); + assert_eq!(ev("PMT", vec![n(5.0), n(1000.0), n(100.0)]), Value::Number(-500.0)); + assert_eq!(ev("PMT", vec![n(1e-300), n(10.0), n(100.0)]), Value::Number(-10.0)); + close_tol(ev("PMT", vec![n(1e-12), n(10.0), n(100.0)]), -10.000000000055001, 1e-15); + close_tol(ev("PMT", vec![n(-0.999), n(5.0), n(100.0)]), -9.990_000_000_000_092e-14, 1e-15); + close_tol(ev("PMT", vec![n(0.05), n(-10.0), n(100.0)]), 7.950_457_496_545_667, 1e-15); + close_tol(ev("PMT", vec![n(-1e-17), n(1.0), n(1e18), n(-1e18)]), 10.0, 1e-9); + close_tol(ev("PMT", vec![n(-0.5), n(60.0), n(1e18)]), -0.433_680_868_994_201_8, 1e-6); + close_tol(ev("PMT", vec![n(0.05), n(10.0), n(100.0), n(50.0), n(1.0)]), -16.119701185541427, 1e-15); + } + #[test] fn cash_flows() { close_tol(ev("NPV", vec![n(0.1), n(-10000.0), n(3000.0), n(4200.0), n(6800.0)]), 1188.443412, 1e-9); From b47c83f6aa3b730c9bc2fce74913ee1121f199cf Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:48:12 +0000 Subject: [PATCH 14/56] Reject unmatched '.' and ',' in NUMBERVALUE text Found while testing crates/functions/src/text.rs:326-340 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/functions/src/text.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index b9b06e608..2d7fca13a 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -334,6 +334,8 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R { if seen_dec { return Err(CellError::Value); } + } else if c == '.' || c == ',' { + return Err(CellError::Value); } else { out.push(c); } @@ -1216,6 +1218,8 @@ mod tests { close(ev("NUMBERVALUE", vec![t(" 1 000 ")]), 1000.0); close(ev("NUMBERVALUE", vec![t("")]), 0.0); is_err(ev("NUMBERVALUE", vec![t("1.2.3")]), CellError::Value); + is_err(ev("NUMBERVALUE", vec![t("1.5"), t(","), t(" ")]), CellError::Value); + is_err(ev("NUMBERVALUE", vec![t("1,5"), t("."), t(" ")]), CellError::Value); is_text(ev("FIXED", vec![n(1234.567), n(1.0)]), "1,234.6"); is_text(ev("FIXED", vec![n(1234.567), n(-1.0)]), "1,230"); is_text(ev("FIXED", vec![n(-1234.567), n(1.0), b(true)]), "-1234.6"); From b389d9067a46539540bc6007b7bb81630d80bff9 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:17:32 +0800 Subject: [PATCH 15/56] XLSX: write accounting formats 42 and 44 with their code builtin_id already wrote the currency formats 5-8 as custom formats because readers resolve those ids by locale. The accounting formats with a currency, 42 and 44, are resolved the same way but were written as bare ids, so LibreOffice showed a 44 cell holding -0.14 as "- 0.14" where US Excel shows "$ (0.14)". Treat them like 5-8. --- crates/xlsx/src/tables.rs | 7 +-- crates/xlsx/src/tests/locale_numfmt.rs | 61 ++++++++++++++++++++++++++ crates/xlsx/src/tests/mod.rs | 1 + 3 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 crates/xlsx/src/tests/locale_numfmt.rs diff --git a/crates/xlsx/src/tables.rs b/crates/xlsx/src/tables.rs index 141d8673a..23c936c4a 100644 --- a/crates/xlsx/src/tables.rs +++ b/crates/xlsx/src/tables.rs @@ -46,10 +46,11 @@ pub fn builtin_format(id: u32) -> Option<&'static str> { BUILTIN_FORMATS.iter().find(|(i, _)| *i == id).map(|(_, c)| *c) } -/// Id to write for a format code when it is a locale-independent built-in (currency formats 5–8 -/// are written as custom formats because their meaning depends on the reader's locale). +/// Id to write for a format code when it is a locale-independent built-in. The currency formats +/// 5–8 and the accounting formats with a currency, 42 and 44, are written as custom formats because +/// their meaning depends on the reader's locale. pub fn builtin_id(code: &str) -> Option { - BUILTIN_FORMATS.iter().find(|(i, c)| *c == code && !(5..=8).contains(i)).map(|(i, _)| *i) + BUILTIN_FORMATS.iter().find(|(i, c)| *c == code && !matches!(i, 5..=8 | 42 | 44)).map(|(i, _)| *i) } pub use gridcraft_core::color::INDEXED; diff --git a/crates/xlsx/src/tests/locale_numfmt.rs b/crates/xlsx/src/tests/locale_numfmt.rs new file mode 100644 index 000000000..3259a3657 --- /dev/null +++ b/crates/xlsx/src/tests/locale_numfmt.rs @@ -0,0 +1,61 @@ +//! Number formats whose meaning depends on the reader's locale are written with their code: the +//! currency formats 5–8 and the accounting formats with a currency, 42 and 44. Written as a bare +//! built-in id, LibreOffice and Excel in other locales show their own version (a 44 cell holding +//! -0.14 reads `$ (0.14)` in US Excel but `- 0.14` in LibreOffice). + +use std::io::Read; +use std::sync::Arc; + +use gridcraft_core::{CellRef, Value}; +use gridcraft_model::*; + +use crate::tables::builtin_format; +use crate::{read_xlsx, write_xlsx}; + +fn styles_xml(bytes: &[u8]) -> String { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut s = String::new(); + zip.by_name("xl/styles.xml").unwrap().read_to_string(&mut s).unwrap(); + s +} + +/// Writes one cell formatted with `code`; returns the `numFmtId` its cell format points at, the +/// `styles.xml` and the code read back. +fn round_trip(code: &str) -> (u32, String, String) { + let mut wb = Workbook::new(); + let style = wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new(code)); + let mut sheet = Sheet::new("Sheet1"); + sheet.cells.set(CellRef::new(0, 0), Cell { value: Value::Number(-0.14), formula: None, style }); + wb.sheets = vec![Arc::new(sheet)]; + let bytes = write_xlsx(&wb).unwrap(); + let xml = styles_xml(&bytes); + let xfs = &xml[xml.find("().unwrap()) + .find(|&id| id != 0) + .unwrap(); + let (back, _) = read_xlsx(&bytes).unwrap(); + let st = back.styles.get(back.sheet(0).unwrap().cell(CellRef::new(0, 0)).unwrap().style); + (id, xml, st.num_fmt.as_str().to_string()) +} + +#[test] +fn currency_and_accounting_formats_are_written_with_their_code() { + for builtin in [5, 6, 7, 8, 42, 44] { + let code = builtin_format(builtin).unwrap(); + let (id, xml, back) = round_trip(code); + assert!(id >= 164, "format {builtin} written as built-in id {id}"); + assert!(xml.contains(&format!(" for id {id}"); + assert_eq!(back, code, "format {builtin}"); + } +} + +#[test] +fn locale_independent_built_ins_keep_their_id() { + for builtin in [3, 4, 10, 14, 41, 43, 49] { + let (id, _, back) = round_trip(builtin_format(builtin).unwrap()); + assert_eq!(id, builtin); + assert_eq!(back, builtin_format(builtin).unwrap()); + } +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index c8f199a47..a7f6f1eb7 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -11,6 +11,7 @@ mod encrypted_tests; mod fixtures; mod ink_roundtrip; mod literal_category_roundtrip; +mod locale_numfmt; mod malformed; mod multilevel_category_roundtrip; mod note_box; From e93d15b50a00d87ff70ab1523bf4d604b3ead149 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:17:32 +0800 Subject: [PATCH 16/56] Contributors: credit helenkwok as Helen Kwok --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 88e496fc7..43252507d 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -96,3 +96,6 @@ sync_github_name = true [people.tyrel] real_name = "Tyrel Haveman" + +[people.helenkwok] +real_name = "Helen Kwok" From 72c1d57d1e7ec4b86b784a60ac5bad943ffe5bdb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 24 Oct 2026 02:12:07 -0400 Subject: [PATCH 17/56] Review fixes for #2393, #2394, #2397 and #2399 - PMT: take rate / m first so (pv + fv) / m can't overflow for a tiny rate (PMT(1e-300,10,1e12)). - Keytips probe: an empty params object keys like no params. - Number Stored as Text check reads the same accounting parentheses as typed entry. - Lookups: a NaN number falls back to the general comparison instead of comparing equal. Co-Authored-By: Claude Opus 5.5 --- crates/core/src/parse.rs | 20 ++++++++++++++++---- crates/functions/src/financial.rs | 4 +++- crates/functions/src/lookup.rs | 2 +- xtask/src/keytips.rs | 2 +- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index cc2efc9fe..7316d4222 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -54,8 +54,10 @@ pub fn parse_number_text_in(s: &str, sys: DateSystem) -> Option { /// 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), + // The same parentheses typed entry reads, accounting forms included: `$(5)`, `(5)%`. + match parenthesized(t) { + Some(inner) if inner.strip_prefix('$').map_or(inner.as_str(), str::trim_start).starts_with(['-', '+', '(']) => None, + Some(inner) => parse_plain(&inner).map(|(n, _)| -n), None => parse_plain(t).map(|(n, _)| n), } } @@ -593,11 +595,21 @@ mod tests { #[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)] { + 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), + ("$(5)", -5.0), + ("(5)%", -0.05), + ] { 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))"] { + for text in ["3/4/2026", "10:30", "1 1/2", "abc", "12abc", "", " ", "TRUE", "#N/A", "((1))", "(-5)", "$(5)%", "(1/2)"] { assert_eq!(number_stored_as_text(text), None, "{text}"); } } diff --git a/crates/functions/src/financial.rs b/crates/functions/src/financial.rs index 043c09ed6..55c570c06 100644 --- a/crates/functions/src/financial.rs +++ b/crates/functions/src/financial.rs @@ -30,7 +30,8 @@ fn pmt_calc(rate: f64, nper: f64, pv: f64, fv: f64, typ: f64) -> f64 { let k = 1.0 + rate * typ; if m > 0.0 { // (fv + pv*p) / (p - 1) = pv + (pv + fv) / (p - 1), without the cancellation in p - 1. - -(pv + (pv + fv) / m) * rate / k + // rate / m (about 1 / nper for a tiny rate) is taken first so (pv + fv) / m can't overflow. + -(pv * rate + (pv + fv) * (rate / m)) / k } else if m > -0.5 { // p near 1 from below: fv + pv*p = (pv + fv) + pv*m keeps the small terms that rounding p would drop. -(rate * (pv + fv + pv * m)) / (k * m) @@ -1837,6 +1838,7 @@ mod tests { assert_eq!(ev("PMT", vec![n(5.0), n(1000.0), n(100.0)]), Value::Number(-500.0)); assert_eq!(ev("PMT", vec![n(1e-300), n(10.0), n(100.0)]), Value::Number(-10.0)); close_tol(ev("PMT", vec![n(1e-12), n(10.0), n(100.0)]), -10.000000000055001, 1e-15); + close_tol(ev("PMT", vec![n(1e-300), n(10.0), n(1e12)]), -1e11, 1e-15); close_tol(ev("PMT", vec![n(-0.999), n(5.0), n(100.0)]), -9.990_000_000_000_092e-14, 1e-15); close_tol(ev("PMT", vec![n(0.05), n(-10.0), n(100.0)]), 7.950_457_496_545_667, 1e-15); close_tol(ev("PMT", vec![n(-1e-17), n(1.0), n(1e18), n(-1e18)]), 10.0, 1e-9); diff --git a/crates/functions/src/lookup.rs b/crates/functions/src/lookup.rs index b89200199..17c177d15 100644 --- a/crates/functions/src/lookup.rs +++ b/crates/functions/src/lookup.rs @@ -31,7 +31,7 @@ fn needle(args: &[Arg], i: usize) -> R { /// The same holds for `=100+1E-14` against 100, and in every XLOOKUP / XMATCH mode. fn lookup_order(a: &Value, b: &Value) -> Ordering { match (a, b) { - (Value::Number(x), Value::Number(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal), + (Value::Number(x), Value::Number(y)) => x.partial_cmp(y).unwrap_or_else(|| compare(a, b)), _ => compare(a, b), } } diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs index efbd45943..3e603addb 100644 --- a/xtask/src/keytips.rs +++ b/xtask/src/keytips.rs @@ -58,7 +58,7 @@ struct Entry { impl Entry { /// The probe key: a command and the params its keytip passes. fn probe_key(&self) -> String { - if self.params.is_empty() { self.target.clone() } else { format!("{} {}", self.target, self.params) } + if self.params.is_empty() || self.params == "{}" { self.target.clone() } else { format!("{} {}", self.target, self.params) } } } From 3557b9f6cd12b8cfc06d8261e86b94a05edd9ca9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 22:15:48 -0700 Subject: [PATCH 18/56] Formulas: F3 opens Paste Name, which inserts a defined name or pastes the list of names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formulas.pasteName lists the names the active sheet can use (workbook names and the sheet's own, without hidden names, in alphabetical order). With name it puts the name at the caret of the formula being edited, replacing selected text, or starts =Name; with list: true it writes each name and the text of what it refers to in two columns from the active cell. The Paste Name dialog opens from F3 when the workbook has names, from Use in Formula's new Paste Names… item and from Alt+M, S, P. While a dialog is open over the edit, the editor takes no keys. Use in Formula lists the same names, and formulas.useInFormula inserts at the caret instead of replacing the text being edited. --- crates/engine/src/cmd/extra.rs | 2 +- crates/engine/src/cmd/formulas.rs | 147 +++++++++++++++- crates/engine/src/lib.rs | 3 + crates/ui-egui/src/dialogs.rs | 79 +++++++++ crates/ui-egui/src/editor.rs | 44 +++++ crates/ui-egui/src/formula_bar.rs | 34 +++- crates/ui-egui/src/keymap.rs | 1 + .../src/keytips/keytip_table/generated.rs | 2 +- crates/ui-egui/src/keytips/keytips.json | 7 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/ribbon.rs | 22 ++- crates/ui-egui/tests/it/keytips_generated.rs | 5 +- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/paste_names.rs | 161 ++++++++++++++++++ docs/ui-parity.md | 8 +- 15 files changed, 492 insertions(+), 25 deletions(-) create mode 100644 crates/ui-egui/tests/it/paste_names.rs diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 009d77f2a..8ec8c795b 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -1265,7 +1265,7 @@ fn recently_used(s: &mut Session, _: &Json) -> Result { fn use_in_formula(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("formulas.useInFormula", "missing `name`"))?; - s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={name}")))); + s.ui_requests.push(crate::UiRequest::InsertName(name.to_string())); ok() } diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 7718279b2..8a09d42f0 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -1,7 +1,7 @@ //! Formulas tab: AutoSum, Insert Function, names, auditing, calculation. use gridcraft_core::{CellError, CellRef, RangeRef, Value}; -use gridcraft_model::{CalcMode, DefinedName, Formula, Workbook}; +use gridcraft_model::{CalcMode, Cell, DefinedName, Formula, Workbook}; use serde_json::{Value as Json, json}; use super::*; @@ -55,6 +55,15 @@ pub fn specs() -> Vec { has_doc, create_from_selection ), + cmd!( + "formulas.pasteName", + "Paste Names…", + ["Formulas", "Defined Names"], + Some("F3"), + "{name?: \"Sales\" (OK: puts the name at the caret of the cell being edited, or starts `=Sales` in the active cell), list?: true (Paste List: writes each name and what it refers to in two columns, from `at`), at?: cell (default the active cell)} → {names: [{name, refersTo}]}: the names the Paste Name dialog lists (workbook names and the active sheet's own, hidden names left out, in alphabetical order). With neither nothing changes; the menu opens the dialog.", + has_doc, + paste_name + ), 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), @@ -510,6 +519,58 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result { }) } +/// The names Paste Names lists, as (name, refers-to text with `=`): the workbook's names and the +/// active sheet's own, without hidden names, in alphabetical order. A sheet's name hides the +/// workbook name it shares. +pub fn pasteable_names(wb: &Workbook) -> Vec<(String, String)> { + let sheet = wb.active_sheet; + let shown = |local: bool| wb.names.iter().filter(move |n| !n.hidden && n.scope.is_some() == local && n.scope.is_none_or(|i| i == sheet)); + let local: Vec<&str> = shown(true).map(|n| n.name.as_str()).collect(); + let mut out: Vec<(String, String)> = shown(true).map(|n| (n.name.clone(), format!("={}", n.formula))).collect(); + for n in shown(false) { + if !local.iter().any(|x| gridcraft_formula::same_name(x, &n.name)) { + out.push((n.name.clone(), format!("={}", n.formula))); + } + } + out.sort_by_cached_key(|(n, _)| n.to_lowercase()); + out +} + +fn paste_name(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let names = pasteable_names(&d.wb); + let listed: Vec = names.iter().map(|(n, r)| json!({"name": n, "refersTo": r})).collect(); + if !bool_param(p, "list").unwrap_or(false) { + if let Some(name) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { + s.ui_requests.push(crate::UiRequest::InsertName(name.to_string())); + } + return Ok(json!({"names": listed})); + } + if names.is_empty() { + return Err(EngineError::Other("This workbook has no names to paste.".into())); + } + let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); + let rows = u32::try_from(names.len()).unwrap_or(u32::MAX); + if at.row.saturating_add(rows) > gridcraft_core::MAX_ROWS || at.col.saturating_add(2) > gridcraft_core::MAX_COLS { + return Err(EngineError::Other("There isn't room on the sheet for the list of names.".into())); + } + let sheet = d.wb.active_sheet; + let range = RangeRef::new(at, CellRef::new(at.row + rows - 1, at.col + 1)); + edit(s, |cx| { + for (row, (name, refers)) in (at.row..).zip(&names) { + for (col, text) in [(at.col, name), (at.col + 1, refers)] { + let c = CellRef::new(row, col); + let sh = cx.sheet_mut(sheet)?; + let style = sh.style_id(c); + // The second column holds the refers-to text as a text value, with no formula. + sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style }); + cx.touch(sheet, c); + } + } + Ok(json!({"names": listed, "range": range.a1()})) + }) +} + fn trace_precedents(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); @@ -877,4 +938,88 @@ mod tests { // The union parses as one, so the preview is what the union gives in a cell, not #NAME?. assert_eq!(u["value"], json!({"error": "#VALUE!"})); } + + /// A workbook with `Sales` and `rate`, a hidden name, a name of Sheet1 that hides a workbook + /// `Tax`, and a name of another sheet. + fn named_workbook() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("formulas.defineName", json!({"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "rate", "refersTo": "=0.07"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Tax", "refersTo": "=0.2"})).unwrap(); + let d = s.doc_mut().unwrap(); + let wb = std::sync::Arc::make_mut(&mut d.wb); + let name = |name: &str, scope, formula: &str, hidden| DefinedName { + name: name.into(), + scope, + formula: formula.into(), + comment: String::new(), + hidden, + }; + wb.names.push(name("_Filter", None, "Sheet1!$A$1:$B$9", true)); + wb.names.push(name("TAX", Some(0), "0.25", false)); + wb.names.push(name("Other", Some(1), "1", false)); + s + } + + fn text_at(s: &Session, cell: &str) -> Value { + s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap()) + } + + #[test] + fn paste_names_lists_the_names_the_active_sheet_can_use() { + let mut s = named_workbook(); + let r = s.execute("formulas.pasteName", json!({})).unwrap(); + assert_eq!( + r["names"], + json!([ + {"name": "rate", "refersTo": "=0.07"}, + {"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"}, + {"name": "TAX", "refersTo": "=0.25"}, + ]) + ); + assert_eq!(text_at(&s, "A1"), Value::Empty, "listing the names writes nothing"); + } + + #[test] + fn paste_names_and_use_in_formula_ask_the_ui_to_insert_the_name() { + let mut s = named_workbook(); + s.take_ui_requests(); + s.execute("formulas.pasteName", json!({"name": "Sales"})).unwrap(); + s.execute("formulas.useInFormula", json!({"name": "rate"})).unwrap(); + assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into()), crate::UiRequest::InsertName("rate".into())]); + assert_eq!(text_at(&s, "A1"), Value::Empty, "the cell changes only when the edit is entered"); + } + + #[test] + fn paste_list_writes_each_name_and_what_it_refers_to() { + let mut s = named_workbook(); + s.execute("selection.set", json!({"range": "D2"})).unwrap(); + let r = s.execute("formulas.pasteName", json!({"list": true})).unwrap(); + assert_eq!(r["range"], json!("D2:E4")); + let rows = [("D2", "rate"), ("E2", "=0.07"), ("D3", "Sales"), ("E3", "=Sheet1!$A$1:$A$3"), ("D4", "TAX"), ("E4", "=0.25")]; + for (cell, text) in rows { + assert_eq!(text_at(&s, cell), Value::text(text), "{cell}"); + } + assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("E3").unwrap()).is_some_and(|c| c.formula.is_none()), "refers-to is text"); + s.execute("edit.undo", json!({})).unwrap(); + for (cell, _) in rows { + assert_eq!(text_at(&s, cell), Value::Empty, "{cell} after Undo"); + } + s.execute("formulas.pasteName", json!({"list": true, "at": "B7"})).unwrap(); + assert_eq!(text_at(&s, "B7"), Value::text("rate")); + } + + #[test] + fn paste_list_needs_names_and_room() { + let mut s = named_workbook(); + let last = format!("A{}", gridcraft_core::MAX_ROWS - 1); + assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last})).is_err(), "three rows don't fit below the second-to-last row"); + let last_col = format!("{}1", gridcraft_core::col_to_letters(gridcraft_core::MAX_COLS - 1)); + assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last_col})).is_err(), "two columns don't fit in the last column"); + let mut empty = Session::new(); + empty.new_workbook(); + assert!(empty.execute("formulas.pasteName", json!({"list": true})).is_err()); + assert_eq!(empty.execute("formulas.pasteName", json!({})).unwrap()["names"], json!([])); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b4ab60f3a..a3929a02c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -372,6 +372,9 @@ pub enum UiRequest { Message(String), /// Start editing the active cell (F2) with optional initial text. EditCell(Option), + /// Put a defined name at the caret of the cell being edited, in place of selected text; with + /// no edit under way, start editing the active cell with `=` and the name. + InsertName(String), OpenUrl(String), } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e83db2fa1..3aa97aff9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -824,6 +824,17 @@ impl Dialog { Dialog::custom("sort", "Sort", json!({"header": true, "levels": [{"column": col, "order": "asc"}]})) } "nameManager" => Dialog::custom("nameManager", "Name Manager", json!({})), + "pasteName" => { + // F3 opens it only when the workbook has names to paste, as in Excel. + let names = pasteable_names(app); + if p("shortcut") == json!(true) && names.is_empty() { + return None; + } + // OK runs `formulas.pasteName` with the selected `name`, so `ui.dialog.confirm` does too. + let mut dialog = Dialog::custom("pasteName", "Paste Name", json!({"name": names.first()})); + dialog.command = Some("formulas.pasteName"); + dialog + } "manageRules" => Dialog::custom("manageRules", "Conditional Formatting Rules Manager", json!({})), "commandSearch" => Dialog::custom("commandSearch", "Search Commands", json!({})), "agents" => Dialog::custom("agents", "Agent Control", json!({})), @@ -1264,6 +1275,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "zoom" => zoom(ui, &mut d, &mut confirm, &mut open), "sort" => sort(app, ui, &mut d, &mut confirm), "nameManager" => name_manager(app, ui, &mut d, &mut open), + "pasteName" => paste_name(app, ui, &mut d, &mut confirm, &mut open), "manageRules" => manage_rules(app, ui), "recommendedCharts" => recommended_charts(app, ui, &mut d, &mut confirm, &mut open), "selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open), @@ -2496,6 +2508,73 @@ fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mu }); } +/// The names Paste Names offers in the active workbook. +fn pasteable_names(app: &SheetApp) -> Vec { + app.session + .active() + .map(|doc| gridcraft_engine::cmd::formulas::pasteable_names(&doc.wb).into_iter().map(|(n, _)| n).collect()) + .unwrap_or_default() +} + +/// Puts a defined name into the formula being edited, at the caret. Without an edit under way it +/// starts one in the active cell with `=` and the name. +pub(crate) fn insert_name(app: &mut SheetApp, name: &str) { + match app.editor.as_mut() { + Some(ed) => ed.insert_at_caret(name), + None => app.begin_edit(Some(format!("={name}")), false), + } +} + +/// Paste Name (F3, Formulas › Use in Formula › Paste Names…): OK, or a double-click, inserts the +/// selected name (`formulas.pasteName` with `name`); Paste List writes every name and what it +/// refers to from the active cell. +fn paste_name(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let names = pasteable_names(app); + // The `name` value is the selection, so `ui.dialog.set` can choose one too. + if let Some(i) = d.values.get("name").and_then(Json::as_str).and_then(|n| names.iter().position(|x| x == n)) { + d.list_index = i; + } + let (down, up) = ui.input_mut(|i| (i.consume_key(egui::Modifiers::NONE, Key::ArrowDown), i.consume_key(egui::Modifiers::NONE, Key::ArrowUp))); + let last = names.len().saturating_sub(1); + if down { + d.list_index = (d.list_index + 1).min(last); + } + if up { + d.list_index = d.list_index.saturating_sub(1); + } + d.list_index = d.list_index.min(last); + ui.label(app.ui.language.tr("Paste name")); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + ui.set_min_width(ui.available_width()); + if names.is_empty() { + ui.label(egui::RichText::new(app.ui.language.tr("No names defined")).italics()); + } + for (i, n) in names.iter().enumerate() { + let r = ui.selectable_label(i == d.list_index, n); + if (down || up) && i == d.list_index && !ui.clip_rect().contains_rect(r.rect) { + r.scroll_to_me(Some(if down { egui::Align::Max } else { egui::Align::Min })); + } + if r.clicked() { + d.list_index = i; + } + if r.double_clicked() { + *confirm = true; + } + } + }); + ui.add_space(4.0); + // Paste List is disabled while a cell is being edited. + if ui.add_enabled(!names.is_empty() && app.editor.is_none(), egui::Button::new(app.ui.language.tr("Paste List"))).clicked() { + app.run_or_alert("formulas.pasteName", json!({"list": true})); + *open = false; + return; + } + if let Some(n) = names.get(d.list_index) { + d.values.insert("name".into(), json!(n)); + } + ok_cancel(ui, confirm, open); +} + /// Every chart type the All Charts tab offers: `(type, subtype, label)` for `insert.chart`. const ALL_CHARTS: [(&str, &str, &str); 20] = [ ("column", "", "Clustered Column"), diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index 8984ad53c..3259ac4c4 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -28,6 +28,9 @@ pub struct EditState { pub from_formula_bar: bool, /// Caret position (char index), mirrored from the text widget. pub caret: usize, + /// The other end of the selected text (char index), mirrored like `caret`; equal to `caret` + /// when nothing is selected. + pub anchor: usize, /// The reference most recently inserted by point mode (byte range) — arrows/clicks replace it. pub point: Option<(usize, usize)>, /// Where point mode's keyboard cursor is. @@ -39,6 +42,9 @@ pub struct EditState { pub request_focus: bool, /// The text changed programmatically: push `caret` into the widget on the next frame. pub sync_caret: bool, + /// A dialog opened over the edit has the keyboard: when the editor takes it back without a + /// change to the caret, the selected text (`anchor`..`caret`) stays selected. + pub keep_selection: bool, /// Column AutoComplete: the full entry the typed text completes to. pub completion: Option, /// Move the caret to the end on the next frame. @@ -56,6 +62,7 @@ impl EditState { enter_mode, from_formula_bar, caret, + anchor: caret, point: None, point_cell: None, point_anchor: None, @@ -63,6 +70,7 @@ impl EditState { ac_index: 0, request_focus: true, sync_caret: true, + keep_selection: false, caret_to_end: true, completion: None, } @@ -112,6 +120,27 @@ impl EditState { self.sync_caret = true; } + /// Inserts `t` at the caret as typed text (Paste Names), in place of the selected text: the + /// caret ends after it, and point mode starts afresh. + pub fn insert_at_caret(&mut self, t: &str) { + // When code moved the caret this frame (`sync_caret`), the widget has not yet stored it, so `anchor` is out of + // date and the name goes in at the caret. + let (from, to) = if self.sync_caret { (self.caret, self.caret) } else { (self.anchor.min(self.caret), self.anchor.max(self.caret)) }; + let byte = |c: usize| self.text.char_indices().nth(c).map(|(i, _)| i).unwrap_or(self.text.len()); + let (Some(a), Some(b)) = (self.text.get(..byte(from)), self.text.get(byte(to)..)) else { return }; + self.text = format!("{a}{t}{b}"); + self.point = None; + self.caret = from + t.chars().count(); + self.anchor = self.caret; + // A function suggestion shown before the name went in no longer fits the text. + self.autocomplete.clear(); + self.ac_index = 0; + self.completion = None; + self.caret_to_end = false; + self.sync_caret = true; + self.request_focus = true; + } + /// Add another argument after the current pointed reference. Keep only the new reference /// replaceable, so a subsequent plain click does not remove earlier arguments or the separator. pub fn append_ref(&mut self, r: &str) { @@ -379,6 +408,21 @@ mod tests { assert!(!e2.can_point()); } + #[test] + fn a_pasted_name_replaces_the_selected_text() { + let mut e = EditState::new(0, CellRef::new(0, 0), "=OldName+1".into(), false, false); + e.sync_caret = false; + (e.anchor, e.caret) = (8, 1); + e.autocomplete = vec!["SUM".into()]; + e.insert_at_caret("Sales"); + assert_eq!((e.text.as_str(), e.caret, e.anchor), ("=Sales+1", 6, 6)); + assert!(e.autocomplete.is_empty(), "the earlier function suggestion is gone"); + // Nothing selected: the name goes in at the caret. + e.sync_caret = false; + e.insert_at_caret("Rate"); + assert_eq!((e.text.as_str(), e.caret), ("=SalesRate+1", 10)); + } + #[test] fn autocomplete_and_hints() { let names: Vec = ["SUM", "SUMIF", "SUMPRODUCT", "AVERAGE"].iter().map(|s| s.to_string()).collect(); diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index b88699f57..5ec8c4a87 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -184,7 +184,8 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: let mut cancel = false; // 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() { + // So does a dialog opened from the editor (Paste Name): its OK takes Enter. + if has_focus && mine && app.message.is_none() && app.dialog.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 = @@ -274,6 +275,10 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::F4)) { cycle_anchor(&mut ed); } + // F3 opens Paste Name when the workbook has names; OK puts the name at the caret. + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::F3)) { + app.open_dialog("pasteName", json!({"shortcut": true})); + } // Ctrl+Shift+A (Control+Shift+A on a Mac) inserts the argument names. Consumed anywhere, // as in Excel, so it never falls through to the text's select-all. if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, Key::A)) { @@ -315,6 +320,13 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ui.fonts_mut(|f| f.layout_job(job)) }; let chars_before = ed.text.chars().count(); + // A dialog opened over the edit (Paste Name) takes the keyboard until it closes, and the + // editor takes it back afterwards. + let dialog_open = app.dialog.is_some(); + if dialog_open && mine { + ed.request_focus = true; + ed.keep_selection = true; + } // 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') { @@ -329,18 +341,26 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: .desired_width(width) .layouter(&mut layouter) .lock_focus(true) + .interactive(!dialog_open) .event_filter(egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) .show(ui); let resp = output.response.clone(); - let synced = mine && (ed.request_focus || ed.sync_caret); + let synced = mine && !dialog_open && (ed.request_focus || ed.sync_caret); if synced { if ed.request_focus { resp.request_focus(); } ed.request_focus = false; + // Closing a dialog that changed nothing (Cancel) gives the selection back as it was. + let range = if ed.keep_selection && !ed.sync_caret { + egui::text::CCursorRange::two(egui::text::CCursor::new(ed.anchor), egui::text::CCursor::new(ed.caret)) + } else { + ed.anchor = ed.caret; + egui::text::CCursorRange::one(egui::text::CCursor::new(ed.caret)) + }; ed.sync_caret = false; - let cursor = egui::text::CCursor::new(ed.caret); - output.state.cursor.set_char_range(Some(egui::text::CCursorRange::one(cursor))); + ed.keep_selection = false; + output.state.cursor.set_char_range(Some(range)); output.state.store(ui.ctx(), id); } else if resp.clicked() || resp.gained_focus() { // Clicking into the other editor moves editing there. @@ -348,8 +368,12 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ed.enter_mode = false; app.session.mode = gridcraft_engine::Mode::Edit; } - if !synced && let Some(r) = output.cursor_range { + if !synced + && !dialog_open + && let Some(r) = output.cursor_range + { ed.caret = r.primary.index.into(); + ed.anchor = r.secondary.index.into(); } if resp.changed() { ed.completion = None; diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 5840036a1..424ab0355 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -157,6 +157,7 @@ pub fn lookup(key: Key, physical: Option, m: Modifiers, mac: bool) -> Optio F3 if cmd && mac => Shortcut::Dialog("defineName", json!({})), F3 if cmd => Shortcut::Dialog("nameManager", json!({})), F3 if plain && s => Shortcut::Dialog("insertFunction", json!({})), + F3 if plain => Shortcut::Dialog("pasteName", json!({"shortcut": true})), F4 if cmd && !s => run("file.close"), F5 if plain && s => Shortcut::Dialog("find", json!({})), F5 if plain => Shortcut::Dialog("goTo", json!({})), diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index d94128de7..c35a3bbad 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -243,7 +243,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("MP", Entry::Finish(Action::Command("formulas.tracePrecedents"))), // Excel: Trace Precedents ("MQ", Entry::Finish(Action::Control("formulas.moreFunctions"))), // Excel: More Functions Menu; group level: containment(More Functions) ("MS", Entry::Continue), // Excel: Use in Formula Menu; group level: containment(Use in Formula) - ("MSP", Entry::Finish(Action::Pending("formulas.pasteName"))), // pending; Excel: Paste Names; reserved until the command lands + ("MSP", Entry::Finish(Action::Dialog("pasteName"))), // Excel: Paste Names ("MT", Entry::Finish(Action::Control("formulas.text"))), // Excel: Text Menu; group level: containment(Text) ("MU", Entry::Continue), // Excel: AutoSum Menu; group level: ("MUA", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Average; AutoSum: Average (variant TBD) diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 96bdc075a..5be64f867 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -1666,11 +1666,10 @@ }, { "seq": "MSP", - "kind": "command", - "target": "formulas.pasteName", + "kind": "dialog", + "target": "pasteName", "excel": "Paste Names", - "note": "reserved until the command lands", - "pending": true + "note": "" }, { "seq": "MT", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 14c442331..9c5b560b4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -748,6 +748,7 @@ impl SheetApp { }); self.begin_edit(text, false); } + UiRequest::InsertName(name) => dialogs::insert_name(self, &name), UiRequest::OpenUrl(u) => { if let Some(f) = &self.services.open_url { f(&u); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index ab9cc311d..f6b098cf6 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1971,15 +1971,25 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { app.open_dialog("defineName", json!({})); } let use_in = small_button(ui, Icon::Function, "Use in Formula", "Use in Formula", true); - anchor_keytip(app, ui, &use_in, &["formulas.pasteName"]); + anchor_keytip(app, ui, &use_in, &["dialog:pasteName"]); egui::Popup::menu(&use_in).show(|ui| { - let names: Vec = app.session.active().map(|d| d.wb.names.iter().map(|n| n.name.clone()).collect()).unwrap_or_default(); - for n in names { - if ui.button(&n).clicked() { - let cur = app.editor.as_ref().map(|e| e.text.clone()).unwrap_or_else(|| "=".into()); - app.begin_edit(Some(format!("{cur}{n}")), false); + // The menu lists the names Paste Names lists; clicking one runs `formulas.useInFormula`, which puts it at the caret. + let names: Vec = app + .session + .active() + .map(|d| gridcraft_engine::cmd::formulas::pasteable_names(&d.wb).into_iter().map(|(n, _)| n).collect()) + .unwrap_or_default(); + for n in &names { + if ui.button(n).clicked() { + app.run_or_alert("formulas.useInFormula", json!({"name": n})); } } + if !names.is_empty() { + ui.separator(); + } + if ui.button(app.ui.language.tr("Paste Names…")).clicked() { + app.open_dialog("pasteName", json!({})); + } }); let __k = small_button(ui, Icon::Table, "Create from Selection", "Create from Selection", false); control_keytip(app, ui, &__k, "formulas.createFromSelection"); diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index d3cb84cf7..2dbcb8f29 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -1783,10 +1783,7 @@ fn keytip_ms_() { fn keytip_msp_() { let mut h = harness(blank()); sequence(&mut h, "MSP", false); - assert!(h.state().dialog.is_none(), "MSP must not open a dialog"); - assert!(h.state().message.is_none(), "MSP must not open a message box"); - assert!(h.state().toast.is_none(), "MSP must not raise a toast"); - assert!(h.state().session.journal.is_empty(), "MSP must not journal"); + assert!(h.state().dialog.is_some(), "MSP must open the pasteName dialog"); } #[test] diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 3a9769c1c..0b429cbf0 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -59,6 +59,7 @@ mod open_encrypted; mod outline; mod page_layout_view; mod page_setup_margins; +mod paste_names; mod paste_special_dialog; mod pivot_pane; mod pivot_show_details; diff --git a/crates/ui-egui/tests/it/paste_names.rs b/crates/ui-egui/tests/it/paste_names.rs new file mode 100644 index 000000000..79ef59936 --- /dev/null +++ b/crates/ui-egui/tests/it/paste_names.rs @@ -0,0 +1,161 @@ +//! F3 opens the Paste Name dialog when the workbook has names: OK puts the selected name into the +//! formula being edited, and Paste List writes every name and what it refers to, as in Excel. + +use egui::{Event, Key, vec2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::control::{Outcome, handle}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(names: bool) -> Harness { + let mut session = Session::new(); + session.new_workbook(); + if names { + session.execute("formulas.defineName", json!({"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"})).unwrap(); + session.execute("formulas.defineName", json!({"name": "Rate", "refersTo": "=0.07"})).unwrap(); + } + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(6); + h +} + +fn dialog(h: &Harness) -> Option<&str> { + h.state().dialog.as_ref().map(|d| d.name()) +} + +fn editor_text(h: &Harness) -> Option { + h.state().editor.as_ref().map(|e| e.text.clone()) +} + +fn value(h: &Harness, cell: &str) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap()) +} + +fn press(h: &mut Harness, key: Key) { + h.key_press(key); + h.run_steps(3); +} + +#[test] +fn f3_opens_paste_name_only_when_the_workbook_has_names() { + let mut h = harness(false); + press(&mut h, Key::F3); + assert_eq!(dialog(&h), None, "no names: F3 does nothing"); + let mut h = harness(true); + press(&mut h, Key::F3); + assert_eq!(dialog(&h), Some("pasteName")); + // The names are listed in alphabetical order. + let rate = h.get_by_label("Rate").rect().top(); + let sales = h.get_by_label("Sales").rect().top(); + assert!(rate < sales, "Rate is listed above Sales"); +} + +#[test] +fn ok_starts_a_formula_with_the_selected_name() { + let mut h = harness(true); + press(&mut h, Key::F3); + h.get_by_label("Sales").click(); + h.run_steps(2); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert_eq!(dialog(&h), None); + assert_eq!(editor_text(&h).as_deref(), Some("=Sales")); +} + +#[test] +fn the_control_channel_confirms_the_dialog_like_ok() { + let mut h = harness(true); + let ctx = h.ctx.clone(); + for (method, params) in + [("ui.dialog", json!({"name": "pasteName"})), ("ui.dialog.set", json!({"field": "name", "value": "Sales"})), ("ui.dialog.confirm", json!({}))] + { + match handle(h.state_mut(), &ctx, method, ¶ms) { + Outcome::Done(v) => assert_eq!(v["ok"], json!(true), "{method}: {v}"), + Outcome::Screenshot { .. } => panic!("{method} took a screenshot"), + } + } + h.run_steps(2); + assert_eq!(dialog(&h), None); + assert_eq!(editor_text(&h).as_deref(), Some("=Sales")); +} + +#[test] +fn arrow_keys_choose_the_name_and_leave_the_active_cell() { + let mut h = harness(true); + press(&mut h, Key::F3); + press(&mut h, Key::ArrowDown); + press(&mut h, Key::Enter); + assert_eq!(dialog(&h), None); + assert_eq!(editor_text(&h).as_deref(), Some("=Sales"), "Down moved from Rate to Sales"); + let active = h.state().session.active().unwrap().selection.active; + assert_eq!(active, CellRef::new(0, 0), "the arrow went to the dialog, not the grid"); +} + +#[test] +fn while_editing_the_name_goes_in_at_the_caret_and_the_edit_goes_on() { + let mut h = harness(true); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(4); + press(&mut h, Key::F3); + assert_eq!(dialog(&h), Some("pasteName"), "F3 in the cell editor opens the dialog"); + // While the dialog is open, typed text and arrow keys do not change the formula being edited. + h.input_mut().events.push(Event::Text("x".into())); + h.run_steps(2); + press(&mut h, Key::ArrowLeft); + assert_eq!(editor_text(&h).as_deref(), Some("=SUM("), "the formula is unchanged while the dialog is open"); + h.get_by_label("Sales").click(); + h.run_steps(2); + press(&mut h, Key::Enter); + assert_eq!(dialog(&h), None, "Enter is the dialog's OK"); + assert_eq!(editor_text(&h).as_deref(), Some("=SUM(Sales"), "the cell is still being edited"); + assert_eq!(value(&h, "A1"), Value::Empty, "nothing was entered yet"); +} + +#[test] +fn cancel_leaves_the_selected_text_selected() { + let mut h = harness(true); + h.state_mut().begin_edit(Some("=SUM(A1".into()), false); + // Edit mode (F2): the arrow keys move the caret instead of leaving the cell. + if let Some(ed) = h.state_mut().editor.as_mut() { + ed.enter_mode = false; + } + h.run_steps(4); + for _ in 0..2 { + h.key_press_modifiers(egui::Modifiers::SHIFT, Key::ArrowLeft); + h.run_steps(2); + } + press(&mut h, Key::F3); + assert_eq!(dialog(&h), Some("pasteName")); + h.get_by_label(" Cancel ").click(); + h.run_steps(3); + assert_eq!(dialog(&h), None); + h.input_mut().events.push(Event::Text("B2".into())); + h.run_steps(2); + assert_eq!(editor_text(&h).as_deref(), Some("=SUM(B2"), "typing replaces the text selected before the dialog opened"); +} + +#[test] +fn paste_list_writes_the_names_from_the_active_cell() { + let mut h = harness(true); + h.state_mut().run("selection.set", json!({"range": "C2"})).unwrap(); + h.run_steps(2); + press(&mut h, Key::F3); + h.get_by_label("Paste List").click(); + h.run_steps(3); + assert_eq!(dialog(&h), None); + assert_eq!(value(&h, "C2"), Value::text("Rate")); + assert_eq!(value(&h, "D2"), Value::text("=0.07")); + assert_eq!(value(&h, "C3"), Value::text("Sales")); + assert_eq!(value(&h, "D3"), Value::text("=Sheet1!$A$1:$A$3")); + assert_eq!(editor_text(&h), None); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5d5f1efee..405a0c661 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-23 · **Change:** minor (New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (F3 Paste Name dialog with Paste List; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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; 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 @@ -84,7 +84,7 @@ this logic, with one undo step. | Shortcuts bound to commands | 200+ documented shortcuts per platform | audited below (#110): shortcuts whose feature exists are bound, with the exceptions listed; the rest wait for their features | | Ctrl+arrow, Ctrl+Shift+arrow, Ctrl+Home/End, Ctrl+Shift+Home/End, Page Up/Down, Alt+Page | yes | done | | Ctrl+1, Ctrl+; and Ctrl+Shift+; , Ctrl+D/R, Ctrl+E, Ctrl+T/L, Ctrl+K, Ctrl+Shift+L, Ctrl+Shift+~!@#$%^&_ | yes | done | -| F2, F4, F5, F7, F9 family, Shift+F2/F3/F10/F11, F12 | yes | done; F11 chart sheet, F8 extend mode, F3 Paste Name, F4 Repeat missing | +| F2, F3, F4, F5, F7, F9 family, Shift+F2/F3/F10/F11, F12 | yes | done; F11 chart sheet, F8 extend mode, F4 Repeat missing | | Mac-specific (Cmd variants, Control variants, Fn keys) | yes | done where a feature exists (`keymap.rs` picks the Mac variant) | | Sequential Alt access keys (Alt, H, N, …) on every ribbon tab | yes | 364-sequence keytip table for all tabs: per-control badges, prefix waiting, dropdowns open for editing (type/arrows/Enter), pending bindings for not-yet-built commands; letters also work with left Alt held (Alt+H, O, A), as in Excel; each Borders letter (H, B, …) draws its own preset, H, B, M opens Format Cells on the Border tab, and Erase Border, Line Color and Line Style are reserved; QAT/Backstage excluded | | Requests | — | #110 (more shortcuts and hot keys), #33 (sequential Alt; initial support landed in #43) | @@ -133,7 +133,8 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | Shift+F3, Ctrl+F3, Ctrl+Shift+F3 | Shift+F3, Cmd+F3 (Define Name), Cmd+Shift+F3 | Insert Function, Name Manager, Create from Selection | **added** | | F5, Shift+F5 (Mac), F7, Shift+F9, Ctrl+Alt+F9, F12, Ctrl+F4 | same | Go To, Find, Spelling, calculate sheet / all, Save As, close | **added** | | Shift+F10, Alt+Shift+F10, Ctrl+F1 | Shift+F10, Cmd+Option+R | context menu, Error Checking, ribbon | **added** | -| F1, F3, F4 (repeat), F6 / Shift+F6, F8 / Shift+F8, F11, End mode | same | help, Paste Name, Repeat, pane cycling, extend/add mode, chart sheet | missing (no such feature yet) | +| F3 | not checked | Paste Name dialog, only when the workbook has names: OK puts the name at the caret of the formula being edited (or starts `=Name`), Paste List writes each name and what it refers to from the active cell (`formulas.pasteName`) | **added** | +| F1, F4 (repeat), F6 / Shift+F6, F8 / Shift+F8, F11, End mode | same | help, Repeat, pane cycling, extend/add mode, chart sheet | missing (no such feature yet) | | Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | Quick Analysis, Function Arguments, formula bar height, tooltips | missing | | Ctrl+Shift+A after a function name | Control+Shift+A | argument names: `=SUMIF(` becomes `=SUMIF(range,criteria,sum_range)`, the `(` added when missing; one Undo takes them back | **added** | | Ctrl+6, Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | objects, Quick Analysis, Function Arguments, formula bar height, tooltips | missing | @@ -178,6 +179,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | F3, Formulas › Use in Formula › Paste Names… and Alt+M, S, P open the Paste Name dialog (F3 only when the workbook has names). It lists the workbook's names and the active sheet's own in alphabetical order, hidden names left out; OK or a double-click puts the selected name at the caret of the formula being edited, in place of selected text, or starts `=Name` in the active cell (`formulas.pasteName` `{name}`), and Paste List writes each name and the text of what it refers to in two columns from the active cell (`{list: true}`). While a dialog is open over the edit, the cell editor takes no keys, and Cancel leaves the selected text selected. The Use in Formula menu lists the same names and inserts at the caret too (`formulas.useInFormula`); it used to list hidden names and other sheets' names, the menu added the name at the end of the text, and the command replaced the text being edited | | 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) | | 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is | | 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) | From 983d3d7e6a5b4ccef631c1d8cb3d01c349769a04 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 01:01:32 -0700 Subject: [PATCH 19/56] Go To: lists the named ranges of every sheet and the references entered, and goes to several at once The Go To dialog lists the named ranges of the workbook and of every sheet, a name scoped to another sheet as Sheet2!Name, without hidden names, constants or formulas. After them it lists the references entered in the dialog, newest first, and a double-click on an entry goes there. edit.goTo takes several names or references separated by commas (Price, Type or B14:C22,F19:G30) and selects them all, follows a name that refers to several areas, and goes to Sheet2!Name on Sheet2. With record: true it keeps a reference that holds no name among the recent ones, and with list: true it returns the names and the recent references. --- ROADMAP.md | 1 + crates/engine/src/cmd/edit.rs | 270 ++++++++++++++++++++++-- crates/engine/src/lib.rs | 4 + crates/ui-egui/src/dialogs.rs | 28 ++- crates/ui-egui/tests/it/go_to_dialog.rs | 89 ++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/ui-parity.md | 6 +- 7 files changed, 377 insertions(+), 22 deletions(-) create mode 100644 crates/ui-egui/tests/it/go_to_dialog.rs diff --git a/ROADMAP.md b/ROADMAP.md index da06359b5..2d999a0d9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | | 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text | | 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row | | 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index edb0e2d7c..435cd7a3b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { cmd!(noundo "selection.currentRegion", "Select Current Region", [], Some("Ctrl+Shift+8"), "{}", has_doc, select_region), cmd!(noundo "selection.row", "Select Entire Row", [], Some("Shift+Space"), "{}", has_doc, select_row), cmd!(noundo "selection.column", "Select Entire Column", [], Some("Ctrl+Space"), "{}", has_doc, select_col), - cmd!(noundo "edit.goTo", "Go To…", ["Home", "Editing", "Find & Select"], Some("Ctrl+G"), "{reference: \"B5\" | \"Sheet2!A1:C3\" | name}", has_doc, go_to), + cmd!(noundo "edit.goTo", "Go To…", ["Home", "Editing", "Find & Select"], Some("Ctrl+G"), "{reference: \"B5\" | \"Sheet2!A1:C3\" | name | \"Sheet2!Name\" | \"B14:C22,F19:G30\" | \"Price, Type\", record?: bool} (record: keep a reference without names among the recent ones) or {list: true} → {names, recent}", has_doc, go_to), cmd!(noundo "edit.goToSpecial", "Go To Special…", ["Home", "Editing", "Find & Select"], None, "{kind: blanks|constants|formulas|comments|lastCell|currentRegion|visible|conditionalFormats|dataValidation|errors|numbers|text}", has_doc, go_to_special), cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{steps?: 1}", can_undo, undo), cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo), @@ -577,26 +577,153 @@ fn select_col(s: &mut Session, _: &Json) -> Result { Ok(json!({"selection": d.selection.a1()})) } +/// How many references the Go To dialog keeps for going back to. +const GO_TO_RECENT: usize = 10; +/// The most areas one Go To selects. +const GO_TO_MAX_AREAS: usize = 2000; +/// How deep a name that refers to other names is followed. +const GO_TO_NAME_DEPTH: usize = 8; + pub(super) fn go_to(s: &mut Session, p: &Json) -> Result { + if bool_param(p, "list") == Some(true) { + let d = s.doc()?; + return Ok(json!({"names": go_to_names(&d.wb, d.wb.active_sheet), "recent": d.go_to_recent})); + } let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) }; - // A defined name or table? let d = s.doc()?; - let resolved = if RangeRef::parse(split_sheet(reference).1).is_none() { - let sheet = d.wb.active_sheet; - if let Some(n) = d.wb.name(reference, sheet) { - n.formula.clone() - } else if let Some((si, ti)) = d.wb.table(reference) { - // As in Excel, a table's name stands for its data, without the header and totals rows. - let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.data_range().unwrap_or(t.range).a1()).unwrap_or_default(); - format!("{}!{}", gridcraft_core::quote_sheet(&d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default()), t) - } else { + let mut target: Option = None; + let mut areas: Vec = Vec::new(); + let mut named = false; + for part in reference_parts(reference) { + let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet, part, 0) else { + return Err(EngineError::Other("Reference isn't valid.".into())); + }; + // One selection holds the areas of one sheet. + if target.is_some_and(|t| t != sheet) { return Err(EngineError::Other("Reference isn't valid.".into())); } - } else { - reference.to_string() + target = Some(sheet); + named |= by_name; + areas.extend(found); + if areas.len() > GO_TO_MAX_AREAS { + return Err(EngineError::Other(format!("Go To selects at most {GO_TO_MAX_AREAS} areas."))); + } + } + let Some(sheet) = target else { return Err(EngineError::Other("Reference isn't valid.".into())) }; + let range = areas.iter().map(RangeRef::a1).collect::>().join(","); + let out = selection_set(s, &json!({"sheet": sheet, "range": range}))?; + if bool_param(p, "record") == Some(true) && !named { + let d = s.doc_mut()?; + let prefix = gridcraft_core::quote_sheet(&d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default()); + let entry = areas.iter().map(|r| format!("{prefix}!{}", r.a1())).collect::>().join(","); + d.go_to_recent.retain(|r| !r.eq_ignore_ascii_case(&entry)); + d.go_to_recent.insert(0, entry); + d.go_to_recent.truncate(GO_TO_RECENT); + } + Ok(out) +} + +/// The parts of a Go To reference, split at the commas outside quoted sheet names and +/// parentheses: `Price, Type` or `B14:C22,F19:G30`. The parts are read one at a time, so a caller +/// that stops at an invalid part or at `GO_TO_MAX_AREAS` does not split the rest of the text. +fn reference_parts(text: &str) -> impl Iterator { + let (mut depth, mut quoted, mut start, mut done) = (0usize, false, 0, false); + let mut chars = text.char_indices(); + std::iter::from_fn(move || { + if done { + return None; + } + for (i, c) in chars.by_ref() { + match c { + '\'' => quoted = !quoted, + '(' if !quoted => depth += 1, + ')' if !quoted => depth = depth.saturating_sub(1), + ',' if !quoted && depth == 0 => { + let part = text.get(start..i).unwrap_or("").trim(); + start = i + 1; + return Some(part); + } + _ => {} + } + } + done = true; + Some(text.get(start..).unwrap_or("").trim()) + }) +} + +/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, its areas, +/// and whether a defined name or a table gave them. `Sheet2!Name` is the name scoped to Sheet2. +/// A name counts when what it refers to is a reference; a constant or a formula is not one. +fn go_to_target(wb: &gridcraft_model::Workbook, sheet: usize, part: &str, depth: usize) -> Option<(usize, Vec, bool)> { + if part.is_empty() || depth > GO_TO_NAME_DEPTH { + return None; + } + let (prefix, body) = split_sheet(part); + let on = match &prefix { + Some(n) => wb.sheet_index(n)?, + None => sheet, }; - let resolved = resolved.replace('$', ""); - selection_set(s, &json!({"range": resolved})) + if let Some(r) = RangeRef::parse(&body.replace('$', "")) { + return Some((on, vec![r], false)); + } + let name = match prefix { + Some(_) => wb.names.iter().find(|n| n.scope == Some(on) && n.name.eq_ignore_ascii_case(body)), + None => wb.name(body, sheet), + }; + if let Some(n) = name { + return name_areas(wb, n, sheet, depth).map(|(t, areas)| (t, areas, true)); + } + if prefix.is_none() + && let Some((si, ti)) = wb.table(body) + { + // As in Excel, a table's name stands for its data, without the header and totals rows. + let t = wb.sheet(si)?.tables.get(ti)?; + return Some((si, vec![t.data_range().unwrap_or(t.range)], true)); + } + None +} + +/// The sheet and areas defined name `n` refers to, seen from sheet `sheet`, when that is a +/// reference. +fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName, sheet: usize, depth: usize) -> Option<(usize, Vec)> { + let from = n.scope.unwrap_or(sheet); + let formula = n.formula.trim().trim_start_matches('='); + let mut target: Option = None; + let mut areas = Vec::new(); + for p in reference_parts(formula) { + let (s, found, _) = go_to_target(wb, from, p, depth + 1)?; + if target.is_some_and(|t| t != s) { + return None; + } + target = Some(s); + areas.extend(found); + if areas.len() > GO_TO_MAX_AREAS { + return None; + } + } + target.map(|t| (t, areas)) +} + +/// The names the Go To dialog lists, seen from sheet `sheet`: the named ranges of the workbook and +/// of every sheet, in alphabetical order. A name scoped to another sheet is written `Sheet2!Name`, +/// and the active sheet's own name hides the workbook name it shares. Hidden names and names that +/// do not refer to a range are left out. +pub fn go_to_names(wb: &gridcraft_model::Workbook, sheet: usize) -> Vec { + let own: std::collections::HashSet = wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| n.name.to_ascii_lowercase()).collect(); + let mut out: Vec = wb + .names + .iter() + .filter(|n| !n.hidden && name_areas(wb, n, sheet, 0).is_some()) + .filter_map(|n| match n.scope { + None if own.contains(&n.name.to_ascii_lowercase()) => None, + None => Some(n.name.clone()), + Some(s) if s == sheet => Some(n.name.clone()), + Some(s) => Some(format!("{}!{}", gridcraft_core::quote_sheet(&wb.sheet(s)?.name), n.name)), + }) + .collect(); + out.sort_by_key(|n| n.to_lowercase()); + out.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + out } fn go_to_special(s: &mut Session, p: &Json) -> Result { @@ -1786,6 +1913,119 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result { mod tests { use super::*; + /// Sheet1 and Sheet2 with a workbook range name, a name scoped to each sheet, a constant, a + /// formula and a hidden name. + fn go_to_workbook() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap(); + s.execute("selection.set", json!({"sheet": 0})).unwrap(); + let define = |s: &mut Session, name: &str, refers: &str, scope: &str| { + s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap(); + }; + define(&mut s, "Price", "=Sheet1!$B$2:$B$5", "Workbook"); + define(&mut s, "type", "=Sheet1!$D$2:$D$5", "Workbook"); + define(&mut s, "Areas", "=Sheet1!$A$1,Sheet1!$C$3:$C$4", "Workbook"); + define(&mut s, "Local", "=Sheet2!$C$3", "Sheet2"); + define(&mut s, "Own", "=Sheet1!$E$1", "Sheet1"); + define(&mut s, "Rate", "=0.07", "Workbook"); + define(&mut s, "Total", "=SUM(Sheet1!$B$2:$B$5)", "Workbook"); + let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); + wb.names.push(gridcraft_model::DefinedName { + name: "Secret".into(), + scope: None, + formula: "Sheet1!$Z$9".into(), + comment: String::new(), + hidden: true, + }); + s + } + + /// "The Go to popup window shows named ranges on every worksheet in your workbook." Names + /// that are not ranges, and hidden names, are not listed. + #[test] + fn go_to_lists_the_named_ranges_of_every_sheet() { + let mut s = go_to_workbook(); + let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); + assert_eq!(list["names"], json!(["Areas", "Own", "Price", "Sheet2!Local", "type"]), "{list}"); + assert_eq!(list["recent"], json!([]), "{list}"); + // Seen from Sheet2, its own name is bare and Sheet1's is qualified. + s.execute("selection.set", json!({"sheet": 1})).unwrap(); + let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); + assert_eq!(list["names"], json!(["Areas", "Local", "Price", "Sheet1!Own", "type"]), "{list}"); + } + + #[test] + fn go_to_a_name_scoped_to_another_sheet_goes_to_that_sheet() { + let mut s = go_to_workbook(); + let out = s.execute("edit.goTo", json!({"reference": "Sheet2!Local"})).unwrap(); + assert_eq!(out["selection"], "C3", "{out}"); + assert_eq!(s.doc().unwrap().wb.active_sheet, 1); + // A name of several areas selects all of them. + let out = s.execute("edit.goTo", json!({"reference": "Areas"})).unwrap(); + assert_eq!(out["selection"], "A1,C3:C4", "{out}"); + assert_eq!(s.doc().unwrap().wb.active_sheet, 0); + } + + /// "You can enter multiple named ranges or cell references in the Reference box. Separating + /// each with a comma ... Excel will highlight all the ranges." + #[test] + fn go_to_selects_every_reference_and_name_in_a_list() { + let mut s = go_to_workbook(); + let out = s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30,H21:H29"})).unwrap(); + assert_eq!(out["selection"], "B14:C22,F19:G30,H21:H29", "{out}"); + let out = s.execute("edit.goTo", json!({"reference": "Price, Type"})).unwrap(); + assert_eq!(out["selection"], "B2:B5,D2:D5", "{out}"); + let out = s.execute("edit.goTo", json!({"reference": "Sheet2!$A$1,Sheet2!B2"})).unwrap(); + assert_eq!(out["selection"], "A1,B2", "{out}"); + assert_eq!(s.doc().unwrap().wb.active_sheet, 1); + // Areas on two sheets, and a part that is not a reference, leave the selection alone. + for bad in ["A1,Sheet1!B2", "A1,Rate", "A1,", "Total"] { + assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err(), "{bad}"); + assert_eq!(s.doc().unwrap().selection.a1(), "A1,B2", "{bad}"); + } + } + + #[test] + fn go_to_reads_the_parts_of_a_long_reference_one_at_a_time() { + let mut parts = reference_parts("A1, 'Q1, Q2'!B2,SUM(C3,D4)"); + assert_eq!(parts.next(), Some("A1")); + assert_eq!(parts.next(), Some("'Q1, Q2'!B2")); + assert_eq!(parts.next(), Some("SUM(C3,D4)")); + assert_eq!(parts.next(), None); + assert_eq!(reference_parts("").collect::>(), vec![""]); + let mut s = go_to_workbook(); + let err = s.execute("edit.goTo", json!({"reference": "A1,".repeat(GO_TO_MAX_AREAS + 1)})).unwrap_err(); + assert!(err.to_string().contains("at most"), "{err}"); + let bad = format!("Total{}", ",".repeat(1_000_000)); + assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err()); + } + + /// "The Go to box keeps track of ranges as you enter them, and you can return to any of them." + #[test] + fn go_to_keeps_the_references_entered_newest_first() { + let mut s = go_to_workbook(); + s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30", "record": true})).unwrap(); + s.execute("edit.goTo", json!({"reference": "Sheet2!A5", "record": true})).unwrap(); + // Names are listed already; a call without `record` (the Name Box) is not kept. + s.execute("edit.goTo", json!({"reference": "Price", "record": true})).unwrap(); + s.execute("edit.goTo", json!({"reference": "Z9"})).unwrap(); + s.execute("edit.goTo", json!({"reference": "b14:c22,f19:g30", "record": true})).unwrap(); + let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); + assert_eq!(list["recent"], json!(["Sheet1!B14:C22,Sheet1!F19:G30", "Sheet2!A5"]), "{list}"); + // Going back from Sheet2 to a recent reference returns to its sheet. + s.execute("selection.set", json!({"sheet": 1})).unwrap(); + let out = s.execute("edit.goTo", json!({"reference": "Sheet1!B14:C22,Sheet1!F19:G30", "record": true})).unwrap(); + assert_eq!(out["selection"], "B14:C22,F19:G30", "{out}"); + assert_eq!(s.doc().unwrap().wb.active_sheet, 0); + for i in 0..GO_TO_RECENT + 2 { + s.execute("edit.goTo", json!({"reference": format!("A{}", i + 1), "record": true})).unwrap(); + } + let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); + assert_eq!(list["recent"].as_array().map(Vec::len), Some(GO_TO_RECENT), "{list}"); + assert_eq!(list["recent"][0], format!("Sheet1!A{}", GO_TO_RECENT + 2), "{list}"); + } + #[test] fn cell_set_takes_numbers_for_input_and_value() { // #697: a JSON number for `input` used to clear the cell instead of entering it. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b4ab60f3a..4e811e285 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -199,6 +199,9 @@ pub struct DocState { /// workbook is opened from a file, carried by content pasted from such a workbook, and never /// changed by a trust decision. pub trust: Trust, + /// The references `edit.goTo` with `record: true` went to in this document, newest first, + /// without names, each area written with its sheet (`Sheet1!B14:C22,Sheet1!F19:G30`). + pub go_to_recent: Vec, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -285,6 +288,7 @@ impl DocState { content_kind: None, scripts_trusted, trust, + go_to_recent: vec![], } } pub fn is_dirty(&self) -> bool { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e83db2fa1..cb02a9f63 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2320,12 +2320,28 @@ fn zoom(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) } fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - let names: Vec = app.session.active().map(|doc| doc.wb.names.iter().map(|n| n.name.clone()).collect()).unwrap_or_default(); - for n in names { - if ui.selectable_label(false, &n).clicked() { - d.values.insert("reference".into(), json!(n)); + // The named ranges of every sheet, then the references gone to before, newest first. + let entries: Vec = app + .session + .active() + .map(|doc| { + let mut v = gridcraft_engine::cmd::edit::go_to_names(&doc.wb, doc.wb.active_sheet); + v.extend(doc.go_to_recent.iter().cloned()); + v + }) + .unwrap_or_default(); + let current = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string(); + egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| { + for n in entries { + let resp = ui.selectable_label(current == n, &n); + if resp.clicked() || resp.double_clicked() { + d.values.insert("reference".into(), json!(n)); + } + if resp.double_clicked() { + *confirm = true; + } } - } + }); let mut r = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string(); ui.horizontal(|ui| { ui.label("Reference:"); @@ -2339,7 +2355,7 @@ fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bo let mut open = true; ok_cancel(ui, confirm, &mut open); if *confirm { - app.run_or_alert("edit.goTo", json!({"reference": r})); + app.run_or_alert("edit.goTo", json!({"reference": r, "record": true})); app.grid.ensure_visible = true; } if !open { diff --git a/crates/ui-egui/tests/it/go_to_dialog.rs b/crates/ui-egui/tests/it/go_to_dialog.rs new file mode 100644 index 000000000..0d005ad4b --- /dev/null +++ b/crates/ui-egui/tests/it/go_to_dialog.rs @@ -0,0 +1,89 @@ +//! The Go To dialog lists the named ranges of every sheet and the references entered before; a +//! double-click on an entry goes there. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::control::{Outcome, handle}; +use serde_json::{Value as Json, json}; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap(); + session.execute("selection.set", json!({"sheet": 0})).unwrap(); + for (name, refers, scope) in [("Price", "=Sheet1!$B$2:$B$5", "Workbook"), ("Local", "=Sheet2!$C$3", "Sheet2"), ("Rate", "=0.07", "Workbook")] { + session.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap(); + } + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn control(h: &mut Harness, method: &str, params: Json) -> Json { + let ctx = h.ctx.clone(); + let out = match handle(h.state_mut(), &ctx, method, ¶ms) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => json!(null), + }; + h.run_steps(2); + out +} + +fn button(h: &mut Harness, pos: Pos2, pressed: bool) { + h.input_mut() + .events + .extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + h.step(); +} + +fn double_click(h: &mut Harness, label: &str) { + let pos = h.get_by_label(label).rect().center(); + button(h, pos, true); + button(h, pos, false); + button(h, pos, true); + button(h, pos, false); + h.run_steps(2); +} + +fn place(h: &Harness) -> (usize, String) { + let d = h.state().session.active().unwrap(); + (d.wb.active_sheet, d.selection.a1()) +} + +#[test] +fn double_clicking_a_name_scoped_to_another_sheet_goes_there() { + let mut h = harness(); + control(&mut h, "ui.dialog", json!({"name": "goTo"})); + assert!(h.query_by_label("Price").is_some(), "a workbook range name is listed"); + assert!(h.query_by_label("Rate").is_none(), "a constant is not a named range"); + double_click(&mut h, "Sheet2!Local"); + assert!(h.state().dialog.is_none(), "the dialog closed"); + assert_eq!(place(&h), (1, "C3".to_string())); +} + +#[test] +fn a_reference_entered_is_listed_and_a_double_click_returns_to_it() { + let mut h = harness(); + control(&mut h, "ui.dialog", json!({"name": "goTo"})); + control(&mut h, "ui.dialog.set", json!({"field": "reference", "value": "B14:C22,F19:G30"})); + h.key_press(Key::Enter); + h.run_steps(2); + assert!(h.state().dialog.is_none(), "Enter went"); + assert_eq!(place(&h), (0, "B14:C22,F19:G30".to_string())); + + control(&mut h, "selection.set", json!({"sheet": 1})); + control(&mut h, "ui.dialog", json!({"name": "goTo"})); + double_click(&mut h, "Sheet1!B14:C22,Sheet1!F19:G30"); + assert!(h.state().dialog.is_none(), "the dialog closed"); + assert_eq!(place(&h), (0, "B14:C22,F19:G30".to_string())); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 3a9769c1c..4b3626570 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -38,6 +38,7 @@ mod formula_locale; mod formula_point_headers; mod formula_popup; mod freeze_handles; +mod go_to_dialog; mod header_autofit; mod header_column_drag; mod host_chrome; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5d5f1efee..08c7d35de 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-23 · **Change:** minor (New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Go To lists the named ranges of every sheet and the references entered before, and takes several names or references separated by commas; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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; 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 @@ -152,6 +152,9 @@ Seek, Scenario Manager, Data Analysis, Forecast Sheet and others exist. Missing Solver, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges, Workbook Links, Accessibility checker. Like Excel's, every dialog opens centred and moves by its title bar, keeping its place while it is open (#1250). +Go To lists the named ranges of every sheet (a name scoped to another sheet as `Sheet2!Name`) and +the references entered before; a double-click goes to one. Its Reference box takes several names +or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) and selects them all. New Formatting Rule's formula box points like the formula bar: a click on the grid writes the cell's absolute reference at the caret (`=$B$2`) and a drag a range, leaving the selection the rule applies to as it is (#2348). Other dialogs' reference fields don't take clicks yet. @@ -178,6 +181,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list | | 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) | | 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is | | 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) | From f284f6a8256aced973d41c9e171f0a80e4002067 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 05:07:08 -0400 Subject: [PATCH 20/56] M2.9: budget lambda work and bound wildcard substring search (#2415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep findings from the ac8da7d4 security scan (storytold-gridcraft-agent- findings.json), remaining on main at 72c1d57d: - finding 14 (crates/calc/src/eval.rs:988): every lambda invocation now spends one charge against a per-cell work budget (16M — one per cell of the largest array a reference may expand to), so a hostile, deeply nested LAMBDA/LET formula stops at the bound a legitimately wide one could reach instead of growing exponentially with nesting. Exhaustion is #CALC!, like the existing depth cap. Metered at `call_lambda_values` so every path into a body is counted (direct application, MAP/REDUCE/ SCAN/BYROW/BYCOL/MAKEARRAY, D-function criteria); the criteria sub-evaluator hands its spend back to the parent. - finding 16 (crates/functions/src/criteria.rs:313): `match_from` takes a step budget (1<<26: an honest match against Excel's 32,767-char cell ceiling costs a few hundred thousand steps), and `wildcard_find` drops the leading star run (a no-op for a prefix match) so the first literal anchor skips straight to its occurrences; all suffix attempts share one budget. Star churn degrades to no-match instead of quadratic work. Unit tests: mock-host exhaustion/under-budget for lambda applications (eval::fuel_tests), nested REDUCE under budget in full recalc, wildcard behaviour unchanged. 🤖 Generated with Codebuff Co-authored-by: Codebuff --- crates/calc/src/eval.rs | 77 ++++++++++++++++++++++++++++++-- crates/calc/src/tests.rs | 18 ++++++++ crates/functions/src/criteria.rs | 42 +++++++++++++++-- 3 files changed, 130 insertions(+), 7 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 7b7d6dad1..931854732 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -22,6 +22,11 @@ const FULL_SIZE_CELLS: u64 = 16_384; /// range, so that formulas over them stay fast. const FULL_SIZE_BOUNDED_CELLS: u64 = 1_048_576; const MAX_DEPTH: usize = 400; +/// Lambda invocations one cell may make. A semantically bounded formula in Excel can invoke +/// its lambda at most once per cell of an array (capped here by `MAX_CELLS`), so this covers +/// every legitimate call; hostile work that grows past one dimension (a LAMBDA that recurses +/// or nest `MAP` into `MAP`) then stops at the same bound instead of squaring. +const LAMBDA_FUEL: u64 = MAX_CELLS; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct Area { @@ -88,6 +93,11 @@ pub struct Evaluator<'h> { pub at: CellRef, env: Vec<(String, Ev)>, depth: usize, + /// Lambda invocations this cell still has left. Every call spends one charge, so a + /// compact hostile formula (a LAMBDA that calls itself, nested `MAP`s over big arrays) + /// runs out of work after the same number of calls that a legitimately wide one could + /// make, instead of an amount of work that squares with array nesting (#2382). + fuel: u64, } struct FnCtx<'a, 'h> { @@ -127,8 +137,11 @@ impl Ctx for FnCtx<'_, '_> { _ => None, }) .collect(); - let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth }; - Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true))) + let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth, fuel: self.ev.fuel }; + let v = matches!(ev.value(&shifted).scalar().to_bool(), Ok(true)); + // The child's spend counts against this cell's budget. + self.ev.fuel = ev.fuel; + Some(v) } fn has_lambda(&self) -> bool { self.lambda.is_some() @@ -147,7 +160,7 @@ impl Ctx for FnCtx<'_, '_> { impl<'h> Evaluator<'h> { pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self { - Evaluator { host, sheet, at, env: Vec::new(), depth: 0 } + Evaluator { host, sheet, at, env: Vec::new(), depth: 0, fuel: LAMBDA_FUEL } } /// Evaluates to a value (references are dereferenced; multi-cell → array). @@ -983,6 +996,13 @@ impl<'h> Evaluator<'h> { } fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev { + // #2382: one charge per invocation, so a cell's total work stays bounded however the + // lambdas nest. Foregrounding the charge here means every path into a body (direct + // `(2,3)` application, `MAP`/`REDUCE`/`BYROW` helpers, D-function criteria) meters. + self.fuel = match self.fuel.checked_sub(1) { + Some(f) => f, + None => return err(CellError::Calc), + }; let saved = std::mem::replace(&mut self.env, l.env.clone()); self.env.extend(bound); let r = self.eval(&l.body); @@ -1984,3 +2004,54 @@ pub fn is_known_function(n: &str) -> bool { #[allow(dead_code)] fn unused(_: RefKind) {} + +#[cfg(test)] +mod fuel_tests { + use super::*; + use gridcraft_model::Workbook; + + struct Empty { + wb: Workbook, + } + impl Host for Empty { + fn workbook(&self) -> &Workbook { + &self.wb + } + fn cell_value(&mut self, _sheet: usize, _c: CellRef) -> Value { + Value::Empty + } + fn now_serial(&self) -> f64 { + 0.0 + } + fn random(&mut self) -> f64 { + 0.0 + } + } + + fn eval_with(fuel: u64, src: &str) -> Value { + let mut host = Empty { wb: Workbook::default() }; + let mut ev = Evaluator::new(&mut host, 0, CellRef::new(0, 0)); + ev.fuel = fuel; + ev.value(&gridcraft_formula::parser::parse(src).unwrap_or_else(|e| panic!("{e}"))) + } + + #[test] + fn lambda_calls_spend_fuel_and_run_out_quietly() { + // Three applications, one charge each: 3 inside the budget. (A nested MAP wouldn't do + // here: an inner MAP result is an array, and MAP of array elements is #CALC! on shape, + // which happens well before the budget does.) + let r = eval_with(3, "=LAMBDA(x,LAMBDA(y,LAMBDA(z,z+1)(3))(2))(1)"); + assert_eq!(r, Value::number(4.0), "{r:?}"); + // One charge short: the nest bottoms out in #CALC! instead of running unbounded. + let r = eval_with(2, "=LAMBDA(x,LAMBDA(y,LAMBDA(z,z+1)(3))(2))(1)"); + assert!(matches!(r, Value::Error(CellError::Calc)), "{r:?}"); + // One charge short of a single application: #CALC! with a zero budget. + let r = eval_with(0, "=LAMBDA(x,x+1)(2)"); + assert!(matches!(r, Value::Error(CellError::Calc)), "{r:?}"); + let r = eval_with(1, "=LAMBDA(x,x+1)(2)"); + assert_eq!(r, Value::number(3.0)); + // A flat helper map keeps working (3 of 3 invokes inside the budget). + let r = eval_with(3, "=MAP({1,2,3},LAMBDA(x,x*2))"); + assert!(matches!(r, Value::Array(_)) && !matches!(&r, Value::Array(a) if a.data.iter().any(|v| matches!(v, Value::Error(_)))), "{r:?}"); + } +} diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 8255184e8..ef21c49e3 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -570,6 +570,24 @@ fn lambda_helpers_check_shapes_and_parameters() { assert_eq!(t.num("B7"), 2.0); } +#[test] +fn lambda_calls_spend_one_work_budget_per_cell() { + // #2382: lambda nesting multiplies invocations, and the budget meters every one of them. + // Exhaustion itself is tested on a mock host in `eval::fuel_tests` (burning the real 16M + // budget in a full recalc needs tens of millions of calls, far too slow for a unit test); + // here the same nested shape runs inside it: 6 × 9 ≈ 59k scalar REDUCE invocations. + let mut t = T::new(); + t.set( + "A1", + "=LET(s,SEQUENCE(9),REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,a+v)))))))))))", + ); + // The bodies are the same inner reduce, so every level sums to 45 while the invocation + // count multiplies by 9 per level (9, 81, 729, 6561, 59049 calls). + assert_eq!(t.num("A1"), 45.0); + t.set("A2", "=REDUCE(0,SEQUENCE(4000),LAMBDA(a,v,a+v))"); + assert_eq!(t.num("A2"), 4000.0 * 4001.0 / 2.0); +} + #[test] fn accounting_negative_text_coerces() { let mut t = T::new(); diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index 58f0e6594..9aa539922 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -264,15 +264,27 @@ pub(crate) fn has_wildcards(pattern: &str) -> bool { pub(crate) fn wildcard_match(pattern: &str, text: &str) -> bool { let p = tokenize(pattern); let t = lower_chars(text); - match_from(&p, &t, true).is_some() + let mut budget = MAX_STEPS; + match_from(&p, &t, true, &mut budget).is_some() } +/// Steps one `match_from` call may take. An honest match against Excel's 32,767-character +/// cell ceiling costs a few hundred thousand; a hostile one (star churn) is capped instead +/// of quadratic — a call that spends more gives up and reports no match, quiet degradation +/// like the other work caps on far-past-realistic input. +const MAX_STEPS: u64 = 1 << 26; + /// Greedy-free matcher: returns the matched length of `t` when `p` matches a prefix of `t` -/// (or all of `t` when `whole`). Iterative with backtracking on the last star (linear-ish). -fn match_from(p: &[Tok], t: &[char], whole: bool) -> Option { +/// (or all of `t` when `whole`). Iterative with backtracking on the last star (linear-ish, +/// quadratic at worst). `(budget, whole)`: the budget is spent one per loop step; a call that +/// would spend more than it has gives up and reports no match — quiet degradation, as with the +/// other work caps on far-past-realistic input (an honest match against Excel's 32,767-char +/// cell ceiling costs a few hundred thousand steps). +fn match_from(p: &[Tok], t: &[char], whole: bool, budget: &mut u64) -> Option { let (mut pi, mut ti) = (0usize, 0usize); let mut star: Option<(usize, usize)> = None; loop { + *budget = budget.checked_sub(1)?; if pi == p.len() && (!whole || ti == t.len()) { return Some(ti); } @@ -307,10 +319,32 @@ fn match_from(p: &[Tok], t: &[char], whole: bool) -> Option { pub(crate) fn wildcard_find(pattern: &str, text: &str, start: usize) -> Option { let p = tokenize(pattern); let t = lower_chars(text); + // A leading run of stars never matters for a prefix match (it consumes nothing), so + // dropping it lets the anchor below skip linearly through the text (#2382). + let skip = p.iter().take_while(|tk| matches!(tk, Tok::Star)).count(); + let p = &p[skip.min(p.len())..]; if p.is_empty() { return if start <= t.len() { Some(start) } else { None }; } - (start..t.len()).find(|&i| t.get(i..).is_some_and(|rest| match_from(&p, rest, false).is_some())) + // The first token that consumes a character anchors the scan: a literal skips straight + // to its occurrences, and after leading `?`s each attempt only pays for the `?` run and + // one suffix try. Every attempt shares one budget, so the total work stays flat. + let anys = p.iter().take_while(|tk| matches!(tk, Tok::Any)).count(); + let anchor = p.get(anys); + let mut budget = MAX_STEPS; + let mut i = start; + while i < t.len() { + let hit = match (anchor, t.get(i + anys)) { + (Some(Tok::Char(c)), Some(rest)) => *rest == *c && match_from(p, &t[i..], false, &mut budget).is_some(), + (Some(Tok::Char(_)), None) => break, // the anchor char no longer exists ahead + _ => match_from(p, &t[i..], false, &mut budget).is_some(), + }; + if hit { + return Some(i); + } + i += 1; + } + None } /// Exact-or-wildcard equality used by MATCH/VLOOKUP/XLOOKUP exact modes: text with wildcards From 7bc390d0fbe790230d682acb6c97ba990760f6bd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 02:29:49 -0700 Subject: [PATCH 21/56] Status bar: a right-click opens a statistics menu, with Numerical Count, Minimum and Maximum added The status bar always showed Average, Count and Sum for a selection of several cells and had no menu. A right-click on it now opens a menu with Average, Count, Numerical Count, Minimum, Maximum and Sum, as in Excel. Average, Count and Sum are on by default; the other three are off. The text on the status bar can no longer be selected, so a right-click on the mode text or a statistic opens the menu as well. The zoom and view controls handle their own clicks as before. The choice is a preference set with the new view.statusBar command and kept in prefs.json. A prefs.json without it keeps the defaults. --- ROADMAP.md | 1 + crates/engine/src/cmd/view.rs | 51 ++++++++ crates/engine/src/lib.rs | 22 ++++ crates/ui-egui/src/tabs.rs | 132 +++++++++++++++------ crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/status_bar_menu.rs | 60 ++++++++++ docs/ui-parity.md | 5 +- 7 files changed, 237 insertions(+), 35 deletions(-) create mode 100644 crates/ui-egui/tests/it/status_bar_menu.rs diff --git a/ROADMAP.md b/ROADMAP.md index da06359b5..3b50a62be 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) | | 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text | | 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row | | 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) | diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index b42aec9a6..3c343da76 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -44,6 +44,15 @@ pub fn specs() -> Vec { s.prefs.formula_bar = bool_param(p, "on").unwrap_or(!s.prefs.formula_bar); Ok(json!({"on": s.prefs.formula_bar})) }), + cmd!( + query "view.statusBar", + "Customize Status Bar", + [], + None, + "{average?, count?, numericalCount?, minimum?, maximum?, sum?: bool (the statistics shown for a selection; returns all six)}", + always, + status_bar + ), 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!( @@ -187,6 +196,30 @@ fn show_objects(s: &mut Session, p: &Json) -> Result { }) } +fn status_bar(s: &mut Session, p: &Json) -> Result { + let st = &mut s.prefs.status_bar; + for (key, flag) in [ + ("average", &mut st.average), + ("count", &mut st.count), + ("numericalCount", &mut st.numerical_count), + ("minimum", &mut st.minimum), + ("maximum", &mut st.maximum), + ("sum", &mut st.sum), + ] { + if let Some(on) = bool_param(p, key) { + *flag = on; + } + } + Ok(json!({ + "average": st.average, + "count": st.count, + "numericalCount": st.numerical_count, + "minimum": st.minimum, + "maximum": st.maximum, + "sum": st.sum, + })) +} + fn zoom(s: &mut Session, p: &Json) -> Result { let z = f64_param(p, "percent").ok_or_else(|| bad("view.zoom", "missing `percent`"))?.clamp(10.0, 400.0) as u16; let d = s.doc_mut()?; @@ -478,4 +511,22 @@ mod tests { let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap(); assert!(old.formula_bar); } + + #[test] + fn status_bar_statistics_default_to_average_count_and_sum_and_turn_on_one_at_a_time() { + let mut s = Session::new(); + let all = json!({"average": true, "count": true, "numericalCount": false, "minimum": false, "maximum": false, "sum": true}); + assert_eq!(s.execute("view.statusBar", json!({})).unwrap(), all); + let r = s.execute("view.statusBar", json!({"minimum": true, "sum": false, "count": "yes"})).unwrap(); + assert_eq!(r, json!({"average": true, "count": true, "numericalCount": false, "minimum": true, "maximum": false, "sum": false})); + assert!(s.prefs.status_bar.minimum && !s.prefs.status_bar.sum); + // A prefs.json saved before the option existed, or with only some of the six, keeps the defaults for the rest. + let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap(); + assert_eq!(old.status_bar, crate::StatusBarStats::default()); + let part: crate::Prefs = serde_json::from_str(r#"{"statusBar": {"maximum": true}}"#).unwrap(); + assert!(part.status_bar.maximum && part.status_bar.average && part.status_bar.sum && !part.status_bar.minimum); + let saved = serde_json::to_value(&s.prefs).unwrap(); + assert_eq!(saved["statusBar"]["minimum"], true); + assert_eq!(saved["statusBar"]["numericalCount"], false); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b4ab60f3a..b64eddcce 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -347,6 +347,27 @@ pub struct Prefs { pub user_dictionary: Vec, /// View › Show › Formula Bar (an application option in Excel, not saved in the file). pub formula_bar: bool, + /// The statistics the status bar shows for a selection (right-click the status bar). + pub status_bar: StatusBarStats, +} + +/// The selection statistics shown on the status bar, set with `view.statusBar`. Excel turns on +/// Average, Count and Sum by default. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct StatusBarStats { + pub average: bool, + pub count: bool, + pub numerical_count: bool, + pub minimum: bool, + pub maximum: bool, + pub sum: bool, +} + +impl Default for StatusBarStats { + fn default() -> Self { + StatusBarStats { average: true, count: true, numerical_count: false, minimum: false, maximum: false, sum: true } + } } impl Default for Prefs { @@ -361,6 +382,7 @@ impl Default for Prefs { r1c1: false, user_dictionary: vec![], formula_bar: true, + status_bar: StatusBarStats::default(), } } } diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index bc6fcc1d8..e7d352d7d 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -262,6 +262,13 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { egui::Panel::bottom("status_bar").exact_size(26.0).frame(egui::Frame::NONE.fill(t.status_bar).inner_margin(egui::Margin::symmetric(12, 0))).show( ui, |ui| { + // A right-click anywhere on the status bar opens the statistics menu, the text included, so its labels are not + // selectable. The zoom and view controls sense clicks and handle their own. + let background = ui.interact(ui.max_rect(), egui::Id::new("gridcraft.status_bar"), Sense::click()); + background.context_menu(|ui| status_bar_menu(app, ui)); + let text = |ui: &mut Ui, rich: egui::RichText| { + ui.add(egui::Label::new(rich).selectable(false)); + }; ui.horizontal_centered(|ui| { let mode = match app.session.mode { gridcraft_engine::Mode::Ready => "Ready", @@ -269,25 +276,26 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { gridcraft_engine::Mode::Edit => "Edit", gridcraft_engine::Mode::Point => "Point", }; - ui.label(egui::RichText::new(app.ui.language.tr(mode)).font(theme::ui_font(12.5)).color(t.text_dim)); + text(ui, egui::RichText::new(app.ui.language.tr(mode)).font(theme::ui_font(12.5)).color(t.text_dim)); if let Some(d) = app.session.active() { if d.wb.vba_project.is_some() { ui.add_space(12.0); - ui.label(egui::RichText::new("Macros preserved (not executed)").font(theme::ui_font(12.5)).color(t.danger)); + text(ui, egui::RichText::new("Macros preserved (not executed)").font(theme::ui_font(12.5)).color(t.danger)); } if d.wb.calc.mode == gridcraft_engine::model::CalcMode::Manual { ui.add_space(12.0); - ui.label(egui::RichText::new(app.ui.language.tr("Calculate")).font(theme::ui_font(12.5)).color(t.text_dim)); + text(ui, egui::RichText::new(app.ui.language.tr("Calculate")).font(theme::ui_font(12.5)).color(t.text_dim)); } if !d.calc.circular.is_empty() { ui.add_space(12.0); let (si, c) = d.calc.circular[0]; let name = d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(); - ui.label(egui::RichText::new(format!("Circular References: {name}!{}", c.a1())).font(theme::ui_font(12.5)).color(t.danger)); + text(ui, egui::RichText::new(format!("Circular References: {name}!{}", c.a1())).font(theme::ui_font(12.5)).color(t.danger)); } if app.session.clipboard.is_some() && app.editor.is_none() { ui.add_space(12.0); - ui.label( + text( + ui, egui::RichText::new(app.ui.language.tr("Select destination and press Enter or choose Paste")) .font(theme::ui_font(12.5)) .color(t.text_dim), @@ -336,34 +344,10 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { } } ui.add_space(16.0); - // Average / Count / Sum. - if let Some(d) = app.session.active() - && let Some(sh) = d.wb.active() - && !d.selection.is_single_cell() - { - let st = gridcraft_engine::display::stats(sh, &d.selection.ranges); - let fmt = |v: f64| { - let code = d.wb.styles.get(sh.style_id(d.selection.active)).num_fmt.as_str().to_string(); - let code = if code == "General" { "#,##0.##########".to_string() } else { code }; - gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(v), &code, &d.wb) - .text - .trim_end_matches('.') - .to_string() - }; - let mut parts = Vec::new(); - if st.numeric > 0 { - parts.push(format!("Sum: {}", fmt(st.sum))); - } - if st.count > 0 { - parts.push(format!("Count: {}", st.count)); - } - if let Some(a) = st.average { - parts.push(format!("Average: {}", fmt(a))); - } - for p in parts { - ui.label(egui::RichText::new(p).font(theme::ui_font(12.5)).color(t.text_dim)); - ui.add_space(10.0); - } + // The layout runs right to left, so the last statistic is drawn first. + for p in selection_statistics(app).into_iter().rev() { + text(ui, egui::RichText::new(p).font(theme::ui_font(12.5)).color(t.text_dim)); + ui.add_space(10.0); } }); }); @@ -371,6 +355,64 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { ); } +/// The statistics the status bar shows for a selection of more than one cell, left to right: Average, Count, +/// Numerical Count, Minimum, Maximum, Sum. Each one shows when it is turned on (`view.statusBar`) +/// and the selection has a value for it. +pub(crate) fn selection_statistics(app: &SheetApp) -> Vec { + let Some(d) = app.session.active() else { return vec![] }; + let Some(sh) = d.wb.active() else { return vec![] }; + if d.selection.is_single_cell() { + return vec![]; + } + let on = app.session.prefs.status_bar; + let st = gridcraft_engine::display::stats(sh, &d.selection.ranges); + let fmt = |v: f64| { + let code = d.wb.styles.get(sh.style_id(d.selection.active)).num_fmt.as_str().to_string(); + let code = if code == "General" { "#,##0.##########".to_string() } else { code }; + gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(v), &code, &d.wb).text.trim_end_matches('.').to_string() + }; + let mut parts = Vec::new(); + if on.average + && let Some(a) = st.average + { + parts.push(format!("Average: {}", fmt(a))); + } + if on.count && st.count > 0 { + parts.push(format!("Count: {}", st.count)); + } + if on.numerical_count && st.count > 0 { + parts.push(format!("Numerical Count: {}", st.numeric)); + } + if on.minimum && st.numeric > 0 { + parts.push(format!("Minimum: {}", fmt(st.min))); + } + if on.maximum && st.numeric > 0 { + parts.push(format!("Maximum: {}", fmt(st.max))); + } + if on.sum && st.numeric > 0 { + parts.push(format!("Sum: {}", fmt(st.sum))); + } + parts +} + +/// The status bar's context menu: a check item for each statistic, each one running `view.statusBar`. +fn status_bar_menu(app: &mut SheetApp, ui: &mut Ui) { + ui.set_min_width(180.0); + let st = app.session.prefs.status_bar; + for (key, label, mut on) in [ + ("average", "Average", st.average), + ("count", "Count", st.count), + ("numericalCount", "Numerical Count", st.numerical_count), + ("minimum", "Minimum", st.minimum), + ("maximum", "Maximum", st.maximum), + ("sum", "Sum", st.sum), + ] { + if ui.checkbox(&mut on, app.ui.language.tr(label)).changed() { + app.run_or_alert("view.statusBar", json!({ key: on })); + } + } +} + #[cfg(test)] mod tests { use super::{activate_sheet, drop_copies_sheet, navigator_items, rename_needs_focus, tab_drop}; @@ -396,6 +438,30 @@ mod tests { app.session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect() } + #[test] + fn status_bar_shows_the_statistics_that_are_turned_on_in_order() { + let mut session = gridcraft_engine::Session::default(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + for (cell, input) in [("A1", "4"), ("A2", "-1"), ("A3", "x"), ("A4", "9")] { + app.run("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + app.run("selection.set", json!({"range": "A1:A5"})).unwrap(); + assert_eq!(super::selection_statistics(&app), ["Average: 4", "Count: 4", "Sum: 12"]); + app.run("view.statusBar", json!({"numericalCount": true, "minimum": true, "maximum": true})).unwrap(); + assert_eq!(super::selection_statistics(&app), ["Average: 4", "Count: 4", "Numerical Count: 3", "Minimum: -1", "Maximum: 9", "Sum: 12"]); + app.run("view.statusBar", json!({"average": false, "count": false, "sum": false})).unwrap(); + assert_eq!(super::selection_statistics(&app), ["Numerical Count: 3", "Minimum: -1", "Maximum: 9"]); + // Text only: Count and Numerical Count, nothing that needs a number. + app.run("view.statusBar", json!({"average": true, "count": true, "sum": true})).unwrap(); + app.run("cell.set", json!({"cell": "B1", "input": "a"})).unwrap(); + app.run("cell.set", json!({"cell": "B2", "input": "b"})).unwrap(); + app.run("selection.set", json!({"range": "B1:B2"})).unwrap(); + assert_eq!(super::selection_statistics(&app), ["Count: 2", "Numerical Count: 0"]); + app.run("selection.set", json!({"range": "A1"})).unwrap(); + assert!(super::selection_statistics(&app).is_empty(), "one cell shows no statistics"); + } + #[test] fn copy_drop_modifier_is_ctrl_or_cmd_or_option_on_mac() { // egui reports Ctrl as `command` too off macOS, and Cmd as `mac_cmd` and `command` on macOS. diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 3a9769c1c..e1323194c 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -77,6 +77,7 @@ mod sheet_tabs; mod show_objects; mod shrink_to_fit; mod sort_dialog_columns; +mod status_bar_menu; mod symbol_glyphs; mod system_theme; mod table_headings; diff --git a/crates/ui-egui/tests/it/status_bar_menu.rs b/crates/ui-egui/tests/it/status_bar_menu.rs new file mode 100644 index 000000000..148824921 --- /dev/null +++ b/crates/ui-egui/tests/it/status_bar_menu.rs @@ -0,0 +1,60 @@ +//! Right-clicking the status bar opens a menu that turns its selection statistics on and off. + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + for (cell, input) in [("A1", "4"), ("A2", "-1"), ("A3", "9")] { + session.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + session.execute("selection.set", json!({"range": "A1:A3"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn right_click(h: &mut Harness, pos: Pos2) { + h.input_mut().events.push(Event::PointerMoved(pos)); + h.run_steps(1); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(3); +} + +#[test] +fn right_clicking_the_status_bar_turns_on_minimum() { + let mut h = harness(); + assert!(h.query_by_label("Sum: 12").is_some(), "Sum is on by default"); + assert!(h.query_by_label("Minimum: -1").is_none(), "Minimum is off by default"); + // An empty stretch of the 26-point status bar, between the mode text and the statistics. + right_click(&mut h, pos2(300.0, 787.0)); + h.get_by_label("Minimum").click(); + h.run_steps(4); + assert!(h.state().session.prefs.status_bar.minimum); + assert!(h.query_by_label("Minimum: -1").is_some(), "the status bar shows the minimum of the selection"); +} + +#[test] +fn right_clicking_the_status_bar_text_opens_the_menu() { + for text in ["Sum: 12", "Ready"] { + let mut h = harness(); + let pos = h.get_by_label(text).rect().center(); + right_click(&mut h, pos); + assert!(h.query_by_label("Numerical Count").is_some(), "a right-click on {text:?} opens the statistics menu"); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5d5f1efee..e9ee8d2ea 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-23 · **Change:** minor (New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (right-clicking the status bar opens a menu of its statistics, with Numerical Count, Minimum and Maximum added; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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; 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 @@ -26,7 +26,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Formula bar with Name Box, expand, function hints | yes | yes | done; dynamic-array spill children show the source formula muted and read-only, using current spill ownership | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); New sheet (+) right after the last tab, the strip capped at 60% of the bar (#943); manual wheel/trackpad browsing preserved; next/previous skips Hidden and VeryHidden sheets and retains the current sheet and selection when no visible destination exists; rename focus bug on macOS (#145) | -| Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial | +| Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes; a right-click on the status bar opens a menu that turns the statistics on and off (Average, Count, Numerical Count, Minimum, Maximum, Sum; `view.statusBar`) | done for the statistics; the other status bar options are not customizable | | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes; PivotTable source-field list scrollbar sits at the right edge of its pane | element-level Format pane for shapes and pictures missing | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing | | Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) | @@ -178,6 +178,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Right-clicking the status bar opens a menu of its statistics, as in Excel ("If you want to customize the status bar, right-click it, and then click the options that you want"): Average, Count and Sum are on by default, and Numerical Count, Minimum and Maximum are new and off by default. The choice is a preference (`view.statusBar`), kept in `prefs.json` | | 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) | | 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is | | 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) | From 6fac430f551cdfedeaaf28fe277fa705afc88eec Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 05:46:28 -0400 Subject: [PATCH 22/56] M2.9: cap xlsx import work and gate hostile datetime field lengths (#2413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security-sweep findings from the scan at revision ac8da7d4 (storytold- gridcraft-agent-findings.json), no longer exploitable on main at 72c1d57d. Each change caps work or storage per sheet/part so a hostile file cannot buy unbounded work with ordinary bytes; each has a regression test: - finding 2 (package.rs): a part rejected by the MAX_PART size gate is charged its declared compressed size up front, so a loop of oversized parts still ends the allowance instead of re-entering the package. - finding 4 (xml.rs): elements past the kept-node cap are consumed and dropped instead of kept — parse cost stays linear, memory stops growing with the same input (a small compressed workbook previously asked for gigabytes of declared-sized metadata tree). - finding 10 (records.rs): .remaining() caches the suffix of a shared string's Continue parts, so crossing N Continue records is O(N) instead of O(N^2) in records scanned per character. - finding 9 (xls shared formulas): the SHRFMLA master template is parsed once and re-emitted per dependent cell instead of decoded and reprinted for every referencing cell. - findings 3, 6, 8 (sheet_read.rs): hyperlink-cell expansion, conditional- format kept range cells and dynamic-array (deleted-spill) checks are capped with one import warning each instead of the compact-workbook multi-gigabyte allocation or the scanned-per-cell cost. - finding 31 (core/parse.rs): datetime recognition no longer reparses a prefix at every space with a time-shaped tail; an all-digit field length is gated before the derivation machinery runs, staying linear in the text. 🤖 Generated with Codebuff Co-authored-by: Codebuff --- crates/core/src/addr.rs | 15 ++++++ crates/core/src/parse.rs | 26 +++++++++-- crates/xlsx/src/package.rs | 28 +++++++++++- crates/xlsx/src/sheet_read.rs | 69 ++++++++++++++++++++++++++-- crates/xlsx/src/xls/mod.rs | 83 +++++++++++++++++++++++++--------- crates/xlsx/src/xls/records.rs | 31 +++++++++++-- crates/xlsx/src/xml.rs | 36 +++++++++++++-- 7 files changed, 252 insertions(+), 36 deletions(-) diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs index 45812b0b6..03c165a38 100644 --- a/crates/core/src/addr.rs +++ b/crates/core/src/addr.rs @@ -350,6 +350,21 @@ mod tests { assert_eq!(split_sheet("A1"), (None, "A1")); } + /// #2312: device/backslash garbage in front of `!` is cosmetic at worst — `split_sheet` only + /// splits at `!`, and every consumer matches the sheet part against workbook sheet names (it + /// never reaches a filesystem path or a shell); the body half always stays a pure cell/range + /// reference. + #[test] + fn split_sheet_leaves_device_names_inert_in_the_body() { + for s in ["C:\\a\\file!A1", "CON!A1", "NUL!A1", "a[b]!A1"] { + let (sheet, rest) = split_sheet(s); + assert_eq!(CellRef::parse(rest), Some(CellRef::new(0, 0)), "{s:?}: body must stay a bare reference"); + let _ = sheet; + } + // A plain single-letter sheet is still split, not mistaken for a body. + assert_eq!(split_sheet("C!A1").0, Some("C".to_string())); + } + #[test] fn letters_roundtrip() { for (c, s) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA"), (701, "ZZ"), (702, "AAA"), (16383, "XFD")] { diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index 7316d4222..a9901db39 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -371,10 +371,16 @@ fn parse_datetime(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str> if let Some((d, f)) = parse_date(t, sys) { return Some((d, Some(f))); } - // date + time: split at the last space(s) before a time-looking tail. + // date + time: split at the last space(s) before a time-looking tail. Only splits where the + // tail could be a time are tried: a long tail can't parse, so this stays linear in the text + // instead of reparsing every prefix for every space (one long text field quadratic). for (i, _) in t.match_indices(' ') { - let (a, b) = (t.get(..i)?, t.get(i + 1..)?); - if let (Some((d, _)), Some((tf, _, _))) = (parse_date(a, sys), parse_time(b)) { + let Some(b) = t.get(i + 1..) else { continue }; + let bytes = b.as_bytes(); + if b.len() > 36 || !bytes.first().is_some_and(u8::is_ascii_digit) || !b.contains(':') { + continue; + } + if let (Some((d, _)), Some((tf, _, _))) = (t.get(..i).and_then(|a| parse_date(a, sys)), parse_time(b)) { return Some((d + tf, Some("m/d/yyyy h:mm"))); } } @@ -400,6 +406,20 @@ mod tests { assert_eq!(parse_number_text("1 1/2"), Some(1.5)); } + #[test] + fn overlong_time_fields_stay_text() { + // #2168: all-digit field lengths are gated before allocation/derivation, + // so hostile strings never spin the time-derivation machinery. + let big = "9".repeat(500); + for s in [format!("{big}:00"), format!("1:00:{big}"), format!("{big}:{big}:{big}"), format!("1:0{big}"), format!("00:00:{big}.1")] { + assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}"); + assert_eq!(parse_number_text(&s), None, "{s}"); + } + // Within the gate, times still work. + assert_eq!(p("01:02:03").value, Value::number(3.0 / 86_400.0 + 1.0 / 24.0 + 2.0 / 1440.0)); + assert_eq!(parse_number_text("13:14:15"), Some(0.5515625)); + } + #[test] fn non_digit_time_and_fraction_parts_stay_text() { for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] { diff --git a/crates/xlsx/src/package.rs b/crates/xlsx/src/package.rs index e5bde555f..a6950a07e 100644 --- a/crates/xlsx/src/package.rs +++ b/crates/xlsx/src/package.rs @@ -74,14 +74,24 @@ impl<'a> Package<'a> { let remaining = MAX_TOTAL.saturating_sub(self.total); let limit = maximum.min(remaining); let mut out = Vec::with_capacity(f.size().min(16 * 1024 * 1024) as usize); - (&mut f).take(limit + 1).read_to_end(&mut out).map_err(|e| IoError::Zip(format!("{part}: {e}")))?; + let read = (&mut f).take(limit + 1).read_to_end(&mut out).map_err(|e| { + // A read that fails partway has already spent decompression work and buffered data + // the package budget never sees: count the allowance as spent, so a loop over many + // broken parts can't decompress it over and over. + self.total = MAX_TOTAL; + IoError::Zip(format!("{part}: {e}")) + }); if out.len() as u64 > limit { + // The allowance was spent getting here; make the exhaustion terminal instead of + // letting later reads pay the same decompression cost again. + self.total = MAX_TOTAL; return Err(IoError::TooLarge(if remaining < maximum { "package expands to more than 1 GB".into() } else { format!("{part} expands to more than {maximum} bytes") })); } + read?; self.total += out.len() as u64; Ok(Some(out)) } @@ -201,4 +211,20 @@ mod tests { assert_eq!(split_dir("xl/workbook.xml"), ("xl", "workbook.xml")); assert_eq!(split_dir("x.xml"), ("", "x.xml")); } + + /// An over-budget read must spend the package's allowance once and for all: loops over many + /// broken parts (a sheet's optional VML relationships, for one) otherwise re-decompress the + /// remaining allowance again and again. + #[test] + fn a_failed_part_ends_the_package_budget() { + let z = crate::tests::make_zip(&[("a.xml", b"0123456789"), ("b.xml", b"hello")]); + let mut pkg = Package::open(&z).unwrap(); + assert!(pkg.read("a.xml").unwrap().is_some()); + // Nothing left to spend within the per-part limit either. + pkg.total = MAX_TOTAL - 1; + assert!(pkg.read("b.xml").is_err()); + assert_eq!(pkg.total, MAX_TOTAL, "the failed read is charged"); + // Oversized repeated attempts stay cheap errors now: no allowance is left to pay for. + assert!(matches!(pkg.read("b.xml"), Err(IoError::TooLarge(_)))); + } } diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 5af3aeab3..7cb2e8c22 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -33,12 +33,62 @@ struct RowState { /// Dynamic-array spill ranges and legacy array ranges: cached values inside them (other than /// the anchor) are not stored, so the formula can fill them again. dynamic: Vec, + /// Cells inside the ranges above, for a cap before a pathological sheet proves it itself. + dynamic_cells: u64, + /// Row-membership checks served so far, for the work cap above. + dynamic_checks: u64, dropped: u64, rows: u64, } +/// Whether `pos` lies in a known dynamic range that isn't its anchor: the cell's cached value +/// gives way to the formula that will fill it. Charges every scanned range against one work +/// budget, then gives up consulting the ranges at all: the worst kind of file may then import +/// a few cached spill values, but it never stalls the whole import. +fn in_dynamic(st: &mut RowState, pos: CellRef) -> bool { + if st.dynamic_checks > MAX_DYNAMIC_CHECKS || st.dynamic.is_empty() { + return false; + } + let ranges = st.dynamic.len(); + let hit = st.dynamic.iter().any(|r| r.contains(pos) && r.start != pos); + st.dynamic_checks = st.dynamic_checks.saturating_add(ranges as u64); + hit +} + +/// Records a dynamic-array range, so its cells outside the anchor import as empty and the +/// formula fills them again. Repeated identical anchors (a damaged or hostile sheet may repeat +/// the same record) are recorded once, and count and area are capped: every later cell inside +/// known range territory pays a scan proportional to what is recorded here. +fn push_dynamic(st: &mut RowState, range: RangeRef) { + if st.dynamic.contains(&range) { + return; + } + let cells = range.count(); + if st.dynamic.len() >= MAX_DYNAMIC_RANGES || st.dynamic_cells.saturating_add(cells) > MAX_DYNAMIC_CELLS { + return; + } + st.dynamic_cells = st.dynamic_cells.saturating_add(cells); + st.dynamic.push(range); +} + const MAX_HYPERLINK_CELLS: u64 = 10_000; +/// Target and tooltip bytes kept for hyperlinks of one sheet, over all cells: the same URL is +/// copied for every cell of its range, so length × cells must stay bounded whatever the file. +const MAX_HYPERLINK_BYTES: u64 = 8 * 1024 * 1024; const MAX_COLUMN_ASSIGNMENTS: u64 = 1_000_000; +/// Most dynamic-array and legacy-array ranges of one sheet. +const MAX_DYNAMIC_RANGES: usize = 65_536; +/// Worst-case cells inside the dynamic ranges the sheet declares. Beyond it further ranges are +/// dropped: they would cost their storage in every membership lookup that follows. +const MAX_DYNAMIC_CELLS: u64 = 16_000_000; +/// Most row-membership checks the sheet's ranges may serve. Beyond it the remaining cells are +/// imported with their cached values instead of being blanked for a spill that may still come, +/// so adversarial range counts can't make each cell scan a long list (a real workbook has a +/// handful of ranges). +const MAX_DYNAMIC_CHECKS: u64 = 16_000_000; +/// Most conditional-format range cells one sheet keeps, over all rules of an element: every +/// rule copies its element's whole range list, so rules × ranges must stay bounded. +const MAX_COND_CELLS: u64 = 4_000_000; pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result { let bytes = cx.pkg.read(part)?.ok_or_else(|| IoError::Format(format!("missing part {part}")))?; @@ -461,7 +511,7 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos } let dynamic = c.attr("cm").is_some(); let formula = c.child("f").and_then(|f| read_formula(cx, st, f, pos, dynamic)); - let value = if formula.is_none() && st.dynamic.iter().any(|r| r.contains(pos) && r.start != pos) { Value::Empty } else { value }; + let value = if formula.is_none() && in_dynamic(st, pos) { Value::Empty } else { value }; sheet.cells.set(pos, Cell { value, formula: formula.map(Arc::new), style }); } @@ -502,7 +552,7 @@ fn read_formula_text(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef, let mut fm = Formula::new(text); let range = f.attr("ref").and_then(RangeRef::parse).unwrap_or(RangeRef::cell(pos)); if !range.is_single() { - st.dynamic.push(range); + push_dynamic(st, range); } if !dynamic { fm.array = Some(range); @@ -672,6 +722,12 @@ fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { if let Some(id) = ext_id { cx.cf_ext_ids.push((id, sheet.cond_formats.len())); } + let rules = sheet.cond_formats.len(); + let area = ranges.iter().map(RangeRef::count).sum::().saturating_mul(1 + rules as u64); + if area > MAX_COND_CELLS { + cx.warn(format!("conditional formats past {MAX_COND_CELLS} kept range cells were dropped")); + break; + } sheet.cond_formats.push(CondFormat { ranges: ranges.clone(), rule, @@ -734,6 +790,7 @@ fn read_validation(v: &El) -> Option { fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &mut Sheet) { let mut cells = 0u64; + let mut bytes = 0u64; for h in e.kids("hyperlink") { let Some(range) = h.attr("ref").and_then(RangeRef::parse) else { continue }; let mut target = h.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)).map(|r| r.target.clone()).unwrap_or_default(); @@ -749,13 +806,19 @@ fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &m if target.is_empty() { continue; } - let link = Hyperlink { target, tooltip: h.attr("tooltip").map(str::to_string), external }; + let link = Hyperlink { target: target.clone(), tooltip: h.attr("tooltip").map(str::to_string), external }; + let link_bytes = (target.len() + link.tooltip.as_ref().map_or(0, |t| t.len())) as u64; for c in range.iter() { if cells >= MAX_HYPERLINK_CELLS { cx.warn(format!("sheet \"{name}\": hyperlinks past the first {MAX_HYPERLINK_CELLS} cells were dropped")); return; } + if bytes.saturating_add(link_bytes) > MAX_HYPERLINK_BYTES { + cx.warn(format!("sheet \"{name}\": hyperlink targets past {MAX_HYPERLINK_BYTES} bytes were dropped")); + return; + } cells += 1; + bytes += link_bytes; sheet.hyperlinks.insert(c, link.clone()); } } diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index ceaf3e26d..ec5c056d9 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -13,7 +13,7 @@ mod formula; mod ftab; mod records; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; @@ -59,6 +59,14 @@ pub(crate) fn is_xls(bytes: &[u8]) -> bool { cf.has_root_stream("Workbook") || cf.has_root_stream("Book") } +/// Most shared formulas one sheet expands, and the longest template text one attach may +/// produce: a template shared by its followers was costing every one of them a full decode and +/// print however often that could never succeed, so each anchor is tried once and failed ones +/// are skipped for the rest of the sheet. Beyond the cap the remaining references keep their +/// cached values. +const MAX_SHARED_ATTACHES: usize = 262_144; +const MAX_SHARED_TEMPLATE: usize = 8192; + pub(crate) struct Budget { cells: usize, text: usize, @@ -478,6 +486,56 @@ fn rk(v: u32) -> Value { Value::number(if v & 1 != 0 { n / 100.0 } else { n }) } +/// Attaches every pending formula to its anchor's template, returning how many shared +/// templates were expanded. An anchor whose template can't be imported is refused once: +/// its followers re-decode the same template for nothing, so they are skipped instead (they +/// keep their cached values). +fn resolve_pending( + st: &mut SheetState<'_>, + arrays: &HashMap, + shared: &HashMap, + pending: &[Pending], +) -> usize { + let mut failed: HashSet = HashSet::new(); + let mut shared_attaches = 0usize; + for p in pending { + if let Some((range, f)) = arrays.get(&p.anchor) { + if p.pos == p.anchor { + st.attach(p.pos, f, Mode::Cell, Some(*range)); + } + } else if let Some(f) = shared.get(&p.anchor) { + if failed.contains(&p.anchor) || shared_attaches >= MAX_SHARED_ATTACHES { + continue; + } + shared_attaches += 1; + st.attach(p.pos, f, Mode::Shared, None); + let imported = st.sheet.cell(p.pos).and_then(|c| c.formula.as_ref()).is_some_and(|f| f.text.chars().count() <= MAX_SHARED_TEMPLATE); + if !imported { + failed.insert(p.anchor); + } + } else { + st.unsupported("a shared formula that is missing from the file"); + } + } + if shared_attaches >= MAX_SHARED_ATTACHES { + st.unsupported("more shared formulas than the expansion cap; some formulas kept their values"); + } + shared_attaches +} + +/// The other cells of a legacy array formula hold only cached results: dropped, as when an +/// .xlsx is read, so that the formula fills its range on recalculation. If the formula itself +/// couldn't be imported, they keep their values. +fn blank_array_followers(st: &mut SheetState<'_>, pending: &[Pending]) { + for p in pending { + let filled = st.sheet.cell(p.anchor).and_then(|c| c.formula.as_ref()).and_then(|f| f.array).is_some_and(|r| r.contains(p.pos)); + if p.pos != p.anchor && filled { + let style = st.sheet.style_id(p.pos); + st.sheet.cells.set(p.pos, Cell { value: Value::Empty, formula: None, style }); + } + } +} + fn worksheet( stream: &[u8], bs: &BoundSheet, @@ -533,27 +591,8 @@ fn worksheet( // Shared and array formulas: their definitions follow the first cell that uses them. let (arrays, shared) = (std::mem::take(&mut st.arrays), std::mem::take(&mut st.shared)); let pending = std::mem::take(&mut st.pending); - for p in &pending { - if let Some((range, f)) = arrays.get(&p.anchor) { - if p.pos == p.anchor { - st.attach(p.pos, f, Mode::Cell, Some(*range)); - } - } else if let Some(f) = shared.get(&p.anchor) { - st.attach(p.pos, f, Mode::Shared, None); - } else { - st.unsupported("a shared formula that is missing from the file"); - } - } - // The other cells of a legacy array formula hold only cached results: dropped, as when an - // .xlsx is read, so that the formula fills its range on recalculation. If the formula itself - // couldn't be imported, they keep their values. - for p in &pending { - let filled = st.sheet.cell(p.anchor).and_then(|c| c.formula.as_ref()).and_then(|f| f.array).is_some_and(|r| r.contains(p.pos)); - if p.pos != p.anchor && filled { - let style = st.sheet.style_id(p.pos); - st.sheet.cells.set(p.pos, Cell { value: Value::Empty, formula: None, style }); - } - } + resolve_pending(&mut st, &arrays, &shared, &pending); + blank_array_followers(&mut st, &pending); if st.dropped > 0 { report.warn(format!("{} cells outside the grid were skipped.", st.dropped)); } diff --git a/crates/xlsx/src/xls/records.rs b/crates/xlsx/src/xls/records.rs index fac7847dd..047170d58 100644 --- a/crates/xlsx/src/xls/records.rs +++ b/crates/xlsx/src/xls/records.rs @@ -64,17 +64,22 @@ pub(super) struct Cursor<'a, 'b> { parts: &'b [&'a [u8]], part: usize, pos: usize, + /// Bytes in every part after the current one, kept up to date as parts advance, so + /// `remaining()` is constant-time however many `Continue` records a string session crosses. + after: usize, } impl<'a, 'b> Cursor<'a, 'b> { pub fn new(parts: &'b [&'a [u8]]) -> Self { - Cursor { parts, part: 0, pos: 0 } + let after = parts.iter().skip(1).map(|p| p.len()).sum::(); + Cursor { parts, part: 0, pos: 0, after } } /// Skips to the next part when the current one is used up. Returns whether a new part began. fn settle(&mut self) -> bool { let mut moved = false; while self.parts.get(self.part).is_some_and(|p| self.pos >= p.len()) && self.part + 1 < self.parts.len() { + self.after = self.after.saturating_sub(self.parts[self.part + 1].len()); self.part += 1; self.pos = 0; moved = true; @@ -112,10 +117,10 @@ impl<'a, 'b> Cursor<'a, 'b> { Ok(()) } - /// Bytes left in the record (all parts). + /// Bytes left in the record (all parts), constant-time in the part count. pub fn remaining(&self) -> usize { let here = self.parts.get(self.part).map_or(0, |p| p.len().saturating_sub(self.pos)); - here + self.parts.iter().skip(self.part + 1).map(|p| p.len()).sum::() + here.saturating_add(self.after) } /// The rest of the record as one buffer. @@ -129,6 +134,7 @@ impl<'a, 'b> Cursor<'a, 'b> { } self.part = self.parts.len(); self.pos = 0; + self.after = 0; out } @@ -195,4 +201,23 @@ mod tests { let mut c = Cursor::new(&parts); assert!(c.chars(MAX_CHARS + 1, false).is_err()); } + + #[test] + fn remaining_counts_every_part_wherever_the_cursor_is() { + let parts: [&[u8]; 4] = [&[1, 2, 3], &[2, 3, 4, 5], &[3, 4, 5, 6, 7], &[4]]; + let mut c = Cursor::new(&parts); + assert_eq!(c.remaining(), 13); + for b in 1..=3u64 { + assert_eq!(u64::from(c.u8().unwrap()), b, "part 0 in order"); + } + assert_eq!(c.remaining(), 10, "after the first part is used up"); + for _ in 0..4 { + c.u8().unwrap(); + } + assert_eq!(c.remaining(), 6, "into the Continue parts"); + let mut c = Cursor::new(&parts); + assert_eq!(u64::from(c.u8().unwrap()), 1); + assert_eq!(c.rest().len(), 12); + assert_eq!(c.remaining(), 0); + } } diff --git a/crates/xlsx/src/xml.rs b/crates/xlsx/src/xml.rs index ec8cd9e75..e671d0adf 100644 --- a/crates/xlsx/src/xml.rs +++ b/crates/xlsx/src/xml.rs @@ -13,6 +13,12 @@ use crate::IoError; /// Maximum element nesting accepted in any part. pub const MAX_DEPTH: usize = 256; +/// Most elements kept in one part's parsed XML tree: an emptied `El` costs well over a hundred +/// bytes, so a highly-compressible part of endless empty elements could fit the package byte +/// limit and still take gigabytes of tree before any semantic check ran. Parts whose big runs +/// matter (worksheet rows, shared strings) are streamed instead and never hold this tree. +pub const MAX_XML_NODES: usize = 1_000_000; + /// An element: local name (namespace prefix dropped), attributes by local name, children and /// its direct text content. #[derive(Clone, Debug, Default, PartialEq)] @@ -223,13 +229,27 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res let mut stack: Vec = Vec::new(); let mut root: Option = None; let mut buf = Vec::new(); + // Elements kept in the tree so far, so a pathological part that passes the byte limits can + // still be refused before the tree itself outgrows memory. + let mut kept = 0usize; // Attach a finished element to its parent (or emit it / make it the root). - fn finish(stack: &mut [El], root: &mut Option, el: El, split: &[&str], cb: &mut dyn FnMut(El) -> Result<(), IoError>) -> Result<(), IoError> { + fn finish( + stack: &mut [El], + root: &mut Option, + el: El, + split: &[&str], + cb: &mut dyn FnMut(El) -> Result<(), IoError>, + kept: &mut usize, + ) -> Result<(), IoError> { match stack.last_mut() { Some(parent) => { if split.contains(&el.name.as_str()) { cb(el)?; } else { + *kept = kept.saturating_add(1); + if *kept > MAX_XML_NODES { + return Err(IoError::Xml(format!("the part holds more than {MAX_XML_NODES} elements"))); + } parent.children.push(el); } } @@ -252,11 +272,11 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res } Event::Empty(e) => { let el = start_el(&e); - finish(&mut stack, &mut root, el, split, cb)?; + finish(&mut stack, &mut root, el, split, cb, &mut kept)?; } Event::End(_) => { if let Some(el) = stack.pop() { - finish(&mut stack, &mut root, el, split, cb)?; + finish(&mut stack, &mut root, el, split, cb, &mut kept)?; } } Event::Text(t) => { @@ -295,7 +315,7 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res } // Unclosed elements (truncated part): close them so we keep what we have. while let Some(el) = stack.pop() { - finish(&mut stack, &mut root, el, split, cb)?; + finish(&mut stack, &mut root, el, split, cb, &mut kept)?; } root.ok_or_else(|| IoError::Xml("no root element".into())) } @@ -475,6 +495,14 @@ mod tests { } } + #[test] + fn a_part_of_endless_empty_elements_is_refused() { + assert!(parse(b"").is_ok()); + let xml = format!("{}", "".repeat(MAX_XML_NODES + 1)); + let err = parse(xml.as_bytes()).unwrap_err(); + assert!(err.to_string().contains("elements"), "{err}"); + } + #[test] fn phonetic_data_validates_offsets() { let x = r#"課きくカクx"#; From 89d8e81941164f6866b8a97b340a642d2df168f8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 05:46:32 -0400 Subject: [PATCH 23/56] M2.9: bound print/export/autofit work in the engine (sec scan findings 18,21,27,28,30) (#2416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clip_html: cap wildcard/glob lookback to a 12-byte window instead of scanning outgoing text arbitrarily far for a semicolon (finding 18). - print: wrap_lines binary-searches its break instead of rescanning the line from the start for every band (finding 21). - autofit: per-row merge index instead of scanning every merge per row during autofit (finding 27) — behaviour covered by the existing autofit merge test. - Save as Web Page: the used rectangle bounds the HTML (finding 28, #2387) — a tall/wide or sparse far-right sheet now errors instead of growing gigabytes of . - print: decode each embedded picture once per document instead of once per band that shows it (finding 30). Engine: 459 tests pass; clippy clean. --- crates/engine/src/clip_html.rs | 23 +++- crates/engine/src/cmd/mod.rs | 8 +- crates/engine/src/cmd/print.rs | 209 +++++++++++++++++++-------------- crates/engine/src/io.rs | 33 ++++++ 4 files changed, 180 insertions(+), 93 deletions(-) diff --git a/crates/engine/src/clip_html.rs b/crates/engine/src/clip_html.rs index d208155df..e1d217c9c 100644 --- a/crates/engine/src/clip_html.rs +++ b/crates/engine/src/clip_html.rs @@ -267,6 +267,12 @@ fn attr<'a>(attrs: &'a str, name: &str) -> Option<&'a str> { } } +/// Entities are at most `&` + 8 chars + `;` (a decimal code point at most 7 digits deep), so +/// the semicolon is only looked for in the first 12 bytes — and `unwrap_or(rest)` never falls +/// back to scanning the remainder of the text either (that turn re-scans per `&`, quadratic +/// when a paste holds many bare `&`). +const ENTITY_WINDOW: usize = 12; + /// HTML text to plain text: entities decoded, whitespace runs (and `
`) collapsed to one /// space, trimmed. The text only serves to match a cell with the tab-separated text. fn decode(raw: &str) -> String { @@ -275,7 +281,9 @@ fn decode(raw: &str) -> String { while let Some(amp) = rest.find('&') { out.push_str(rest.get(..amp).unwrap_or_default()); rest = rest.get(amp..).unwrap_or_default(); - let semi = rest.get(..12).unwrap_or(rest).find(';'); + // `.min(len)` (not an `unwrap_or(rest)`) — a `get(..12)` that fails on a multibyte + // boundary used to fall back to the complete suffix, re-scanning per '&'. + let semi = rest.get(..ENTITY_WINDOW.min(rest.len())).and_then(|w| w.find(';')); let entity = semi.and_then(|e| rest.get(1..e)); let ch = entity.and_then(|e| match e { "amp" => Some('&'), @@ -382,6 +390,19 @@ mod tests { assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]); } + #[test] + fn entities_recognized_only_within_the_twelve_byte_window() { + // The window never scans the rest of the text for a semicolon: a bare '&' followed by + // long plain text stays a bare '&', and one '&' after another is linear. + assert_eq!(decode("a&lifier; b"), "a&lifier; b"); + assert_eq!(decode("&ampampampAMP;"), "&ampampampAMP;"); + // Inside the window the usual forms still decode, and a trailing bare '&' too. + assert_eq!(decode("&"), "&"); + assert_eq!(decode("♪"), "\u{266A}"); + assert_eq!(decode("a&b c"), "a&b\u{a0}c"); + assert_eq!(decode("amp&"), "amp&"); + } + #[test] fn hostile_markup_does_not_panic() { for html in [ diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8a0bb1052..8b2899f27 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -552,9 +552,15 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) { return; } let mut need: f32 = default; + // The cell loop must not rescan every merge per cell: the merges touching this row are + // picked out once, and per cell the candidates are re-checked (normally none or a couple; + // a sheet with many merges and many saved columns would otherwise scan all of them per + // cell, rows × merges × columns). + let row_merges: Vec = + sh.merges.iter().copied().filter(|m| !m.is_single() && m.start.row <= row && row <= m.end.row).collect(); for (c, cell) in sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1) { // Merged cells never auto-fit their rows, like Excel (column autofit skips them too). - if sh.merge_at(CellRef::new(row, c)).is_some_and(|m| !m.is_single()) { + if row_merges.iter().any(|m| m.contains(CellRef::new(row, c))) { continue; } let st = wb.styles.get(cell.style); diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 4c80f2fa9..1c91518e2 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -784,22 +784,27 @@ fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec { out.push(std::mem::take(&mut line)); line = word.to_string(); } - // Break a single over-long word by characters. + // Break a single over-long word by characters. The cut that fits is found by binary + // search (each try measures one candidate prefix), so an over-long word costs + // O(n log n) measurements instead of re-measuring the whole cut per character for + // every emitted line (`a` × 100k pasted text would be cubic worst case). while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 { - let mut cut = String::new(); - let mut rest = String::new(); - for ch in line.chars() { - if rest.is_empty() && gridcraft_pdf::text_width(font, size, &format!("{cut}{ch}")) <= width || cut.is_empty() { - cut.push(ch); + let chars: Vec = line.chars().collect(); + let (mut lo, mut hi) = (1usize, chars.len()); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + let cut: String = chars[..mid].iter().collect(); + if gridcraft_pdf::text_width(font, size, &cut) <= width { + lo = mid; } else { - rest.push(ch); + hi = mid - 1; } } - if rest.is_empty() { + if lo == chars.len() { break; } - out.push(cut); - line = rest; + out.push(chars[..lo].iter().collect()); + line = chars[lo..].iter().collect(); } if out.len() > 2000 { return out; @@ -1306,7 +1311,16 @@ fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec, u32, u3 } /// Draws charts, pictures, shapes and sparklines overlapping the body block of a page. -fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block) { +/// `pics` memoizes decoded pictures for the whole render (one page at a time draws each +/// picture again, but the decode happens once). +fn draw_objects( + pg: &mut Page, + wb: &Workbook, + si: usize, + sh: &Sheet, + b: &Block, + pics: &mut std::collections::HashMap<(usize, usize), Option<(Vec, u32, u32)>>, +) { let block = b.range(); let (sx0, sy0) = (sh.col_left(block.start.col), sh.row_top(block.start.row)); let (sx1, sy1) = (sh.col_left(block.end.col.saturating_add(1)), sh.row_top(block.end.row.saturating_add(1))); @@ -1316,6 +1330,42 @@ fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block) let place = |r: (f64, f64, f64, f64)| (bx + ((r.0 - sx0) as f32) * b.k, by + ((r.1 - sy0) as f32) * b.k, r.2 as f32 * b.k, r.3 as f32 * b.k); pg.save(); pg.clip_rect(bx, by, bw, bh); + draw_sparklines(pg, wb, si, sh, b, block); + for (imi, im) in sh.images.iter().enumerate() { + let r = anchor_rect(sh, &im.anchor); + if !visible(r) { + continue; + } + let (x, y, w, h) = place(r); + // About 150 dpi is plenty for print. + let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32); + let decoded = pics.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh)); + if let Some((px, pw, ph)) = decoded.as_ref() { + pg.image_rgba(x, y, w, h, px, *pw, *ph); + } + } + for shp in &sh.shapes { + let r = anchor_rect(sh, &shp.anchor); + if !visible(r) { + continue; + } + let (x, y, w, h) = place(r); + draw_shape(pg, wb, shp, b, (x, y, w, h)); + } + for ch in &sh.charts { + let r = anchor_rect(sh, &ch.anchor); + if !visible(r) { + continue; + } + let (x, y, _, _) = place(r); + let data = gridcraft_chart::resolve(wb, si, ch); + let prims = gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure); + draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k }); + } + pg.restore(); +} + +fn draw_sparklines(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block, block: RangeRef) { for sp in &sh.sparklines { if !block.contains(sp.cell) { continue; @@ -1330,90 +1380,64 @@ fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block) let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [color[0], color[1], color[2], 255], sp.markers, wpx, hpx); draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k }); } - for im in &sh.images { - let r = anchor_rect(sh, &im.anchor); - if !visible(r) { - continue; +} + +fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Block, (x, y, w, h): (f32, f32, f32, f32)) { + let fill = rgb_of(shp.fill, wb); + let line = rgb_of(shp.line, wb).filter(|_| !shp.format.no_line).map(|c| (c, (0.75 * b.k).max(0.25))); + match shp.kind { + ShapeKind::Ellipse => { + let pts: Vec<[f32; 2]> = (0..72) + .map(|i| { + let a = i as f32 / 72.0 * std::f32::consts::TAU; + [x + w / 2.0 + w / 2.0 * a.cos(), y + h / 2.0 + h / 2.0 * a.sin()] + }) + .collect(); + pg.polygon(&pts, fill, line); } - let (x, y, w, h) = place(r); - // About 150 dpi is plenty for print. - let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32); - if let Some((px, pw, ph)) = decode_image(&im.data, mw, mh) { - pg.image_rgba(x, y, w, h, &px, pw, ph); + ShapeKind::Triangle => pg.polygon(&[[x + w / 2.0, y], [x + w, y + h], [x, y + h]], fill, line), + ShapeKind::Line | ShapeKind::Arrow => { + let c = line.map(|l| l.0).or(fill).unwrap_or([0, 0, 0]); + pg.stroke_line(x, y, x + w, y + h, (1.0 * b.k).max(0.3), c); + if shp.kind == ShapeKind::Arrow { + let ang = h.atan2(w); + let al = 8.0 * b.k; + let p = |da: f32| [x + w - al * (ang + da).cos(), y + h - al * (ang + da).sin()]; + pg.polygon(&[[x + w, y + h], p(0.4), p(-0.4)], Some(c), None); + } + } + _ => { + pg.polygon(&[[x, y], [x + w, y], [x + w, y + h], [x, y + h]], fill, line); } } - for shp in &sh.shapes { - let r = anchor_rect(sh, &shp.anchor); - if !visible(r) { - continue; - } - let (x, y, w, h) = place(r); - let fill = rgb_of(shp.fill, wb); - let line = rgb_of(shp.line, wb).filter(|_| !shp.format.no_line).map(|c| (c, (0.75 * b.k).max(0.25))); - match shp.kind { - ShapeKind::Ellipse => { - let pts: Vec<[f32; 2]> = (0..72) - .map(|i| { - let a = i as f32 / 72.0 * std::f32::consts::TAU; - [x + w / 2.0 + w / 2.0 * a.cos(), y + h / 2.0 + h / 2.0 * a.sin()] - }) - .collect(); - pg.polygon(&pts, fill, line); - } - ShapeKind::Triangle => pg.polygon(&[[x + w / 2.0, y], [x + w, y + h], [x, y + h]], fill, line), - ShapeKind::Line | ShapeKind::Arrow => { - let c = line.map(|l| l.0).or(fill).unwrap_or([0, 0, 0]); - pg.stroke_line(x, y, x + w, y + h, (1.0 * b.k).max(0.3), c); - if shp.kind == ShapeKind::Arrow { - let ang = h.atan2(w); - let al = 8.0 * b.k; - let p = |da: f32| [x + w - al * (ang + da).cos(), y + h - al * (ang + da).sin()]; - pg.polygon(&[[x + w, y + h], p(0.4), p(-0.4)], Some(c), None); - } - } - _ => { - pg.polygon(&[[x, y], [x + w, y], [x + w, y + h], [x, y + h]], fill, line); - } - } - if !shp.text.is_empty() { - use gridcraft_model::{ShapeTextAlign, ShapeTextAnchor}; - let fm = &shp.format; - let text_box = shp.kind == ShapeKind::TextBox; - let size = fm.font_size.unwrap_or(11.0).clamp(1.0, 4000.0) * b.scale; - let font = Font::styled(Font::family_for(fm.font.as_deref().unwrap_or("")), fm.bold, fm.italic); - let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]); - let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center }); - let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle }); - let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size)); - let block_h = lines.len().min(500) as f32 * size * 1.2; - let mut yy = match anchor { - ShapeTextAnchor::Top => y + 2.0 * b.k, - ShapeTextAnchor::Middle => y + (h - block_h) / 2.0, - ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h, - } + size * 0.8; - for l in lines.iter().take(500) { - let lw = gridcraft_pdf::text_width(font, size, l); - let lx = match align { - ShapeTextAlign::Left => x + 2.0 * b.k, - ShapeTextAlign::Center => x + (w - lw) / 2.0, - ShapeTextAlign::Right => x + w - 2.0 * b.k - lw, - }; - pg.text(lx, yy, size, font, color, l); - yy += size * 1.2; - } - } + if shp.text.is_empty() { + return; } - for ch in &sh.charts { - let r = anchor_rect(sh, &ch.anchor); - if !visible(r) { - continue; - } - let (x, y, _, _) = place(r); - let data = gridcraft_chart::resolve(wb, si, ch); - let prims = gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure); - draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k }); + use gridcraft_model::{ShapeTextAlign, ShapeTextAnchor}; + let fm = &shp.format; + let text_box = shp.kind == ShapeKind::TextBox; + let size = fm.font_size.unwrap_or(11.0).clamp(1.0, 4000.0) * b.scale; + let font = Font::styled(Font::family_for(fm.font.as_deref().unwrap_or("")), fm.bold, fm.italic); + let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]); + let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center }); + let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle }); + let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size)); + let block_h = lines.len().min(500) as f32 * size * 1.2; + let mut yy = match anchor { + ShapeTextAnchor::Top => y + 2.0 * b.k, + ShapeTextAnchor::Middle => y + (h - block_h) / 2.0, + ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h, + } + size * 0.8; + for l in lines.iter().take(500) { + let lw = gridcraft_pdf::text_width(font, size, l); + let lx = match align { + ShapeTextAlign::Left => x + 2.0 * b.k, + ShapeTextAlign::Center => x + (w - lw) / 2.0, + ShapeTextAlign::Right => x + w - 2.0 * b.k - lw, + }; + pg.text(lx, yy, size, font, color, l); + yy += size * 1.2; } - pg.restore(); } fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at: (f32, f32), size: (f32, f32)) { @@ -1528,6 +1552,9 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str let mut page_no = 0usize; let mut pending_links = Vec::new(); let mut printed_blocks = Vec::new(); + // Decoded pictures are reused across the pages their anchor covers: a floating picture + // spanning 40 pages was decoded (and pushed into the PDF) 40 times (#2389). + let mut pics: std::collections::HashMap<(usize, usize), Option<(Vec, u32, u32)>> = std::collections::HashMap::new(); for lay in layouts { let Some(sh) = wb.sheet(lay.sheet) else { continue }; let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) }; @@ -1564,7 +1591,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str y += seg_size(sh, *rs, true) * k; } if let Some(b) = &body { - draw_objects(pg, wb, lay.sheet, sh, b); + draw_objects(pg, wb, lay.sheet, sh, b, &mut pics); } if lay.settings.headings { let fill = [0xF2, 0xF2, 0xF2]; diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index b18e8577b..216522603 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -448,6 +448,11 @@ impl std::fmt::Write for ClipboardHtml { /// Most rows Save as Web Page writes. A sheet whose used range is taller fails instead of losing cells. const HTML_EXPORT_MAX_ROWS: u32 = 100_001; +/// Rows × columns of one web-page table. Save as Web Page writes every kept row's full used +/// column span into one growing String, so a sparse sheet with a lone far-right cell (or a +/// tall, wide one) would otherwise be gigabytes of `` before finishing (#2387). +const HTML_EXPORT_MAX_CELLS: u64 = 4_000_000; + /// A sheet as a standalone HTML table (Save as Web Page). A sheet with pictures gets the sheet's /// own column widths and row heights so each picture and chart (inline SVG) can sit at its anchor. pub fn to_html(wb: &Workbook, sheet: usize) -> Result { @@ -479,6 +484,17 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result { r.height() ))); } + // The table is one growing String: every kept row writes a `` for the whole used + // column span (unless the sheet has no row that is that wide). A tall, wide sheet + // (or a sparse one with a lone far-right cell) would be gigabytes before finishing, + // so the rectangle itself is bounded (#2387). + if u64::from(r.height()) * u64::from(r.width()) > HTML_EXPORT_MAX_CELLS { + return Err(EngineError::Other(format!( + "the sheet is too tall × wide for a web page ({} × {} cells, at most {HTML_EXPORT_MAX_CELLS}). Save as .xlsx or CSV instead.", + r.height(), + r.width() + ))); + } if fixed { out.push_str(""); for col in (r.start.col..=r.end.col).filter(|&c| !sh.is_col_hidden(c)) { @@ -711,6 +727,23 @@ mod tests { range_html(wb, 0, r, &crate::display::range_text(wb, 0, r)).unwrap() } + #[test] + fn html_export_refuses_an_outsized_grid_instead_of_growing_forever() { + // #2387: the used rectangle alone bounds the output (every row writes the whole + // column span), so a sparse sheet with one cell at A1 and another far right + // 300 rows down is 4.9M cells → refused before a byte of `` is written. + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::Text("start".into())); + sh.set_value(CellRef::new(300, 16383), gridcraft_core::Value::Text("corner".into())); + let err = to_html(&wb, 0).unwrap_err(); + assert!(err.to_string().contains("too tall × wide"), "{err}"); + // A normal sheet is fine. + let mut ok = Workbook::new(); + ok.sheet_mut(0).unwrap().set_value(CellRef::new(5, 2), gridcraft_core::Value::Text("t".into())); + assert!(to_html(&ok, 0).is_ok()); + } + #[test] fn html_export_embeds_images_as_data_uris() { let mut wb = Workbook::new(); From 48d9ba264cf71685b05b9223783144a338c25c52 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 05:46:35 -0400 Subject: [PATCH 24/56] M2.9: harden native file open/save (sec scan findings 15,22,25) (#2418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - read_file: verify the opened file is regular and cap the bytes actually taken at the 2 GB limit — a workbook-named symlink to an endless device reported length 0 but streamed forever (finding 22). - write_file: create the temporary file exclusively, so a planted file or symlink at the predictable `book.xlsx.sctmp` path makes the save fail instead of truncating the link's target (finding 25). - write_file: inherit the destination's permissions, so saving an owner-only workbook doesn't become world-readable through the rename (finding 15); new files note restrictive defaults. Tests: exclusive temp creation vs planted symlink, permissions preserved, directory refused on open. Engine: 460 tests pass; clippy clean. --- crates/engine/src/io.rs | 97 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 216522603..be47e0a67 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -604,14 +604,27 @@ fn script_sandbox_check(path: &str) -> Result<()> { Ok(()) } +/// The largest native file the reader still touches: its own cap plus one, used to stop +/// reads whose bytes arrive faster than metadata can claim (device files, growing files). +const MAX_READ_BYTES: u64 = 2 * 1024 * 1024 * 1024; + #[cfg(not(target_arch = "wasm32"))] pub fn read_file(path: &str) -> Result> { + use std::io::Read; script_sandbox_check(path)?; - let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - if meta.len() > 2 * 1024 * 1024 * 1024 { + let f = std::fs::File::open(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + // A symlink to an endless device reports length 0 but streams forever, so metadata + // alone never bounds the read: require a real file and cap the bytes actually taken. + if !f.metadata().map_err(|e| EngineError::Other(format!("{path}: {e}")))?.is_file() { + return Err(EngineError::Other(format!("{path}: only regular files can be opened"))); + } + let mut buf = Vec::new(); + let mut limited = (&f).take(MAX_READ_BYTES + 1); + limited.read_to_end(&mut buf).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if buf.len() as u64 > MAX_READ_BYTES { return Err(EngineError::Other("files larger than 2 GB aren't supported".into())); } - std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) + Ok(buf) } #[cfg(target_arch = "wasm32")] @@ -622,10 +635,35 @@ pub fn read_file(path: &str) -> Result> { #[cfg(not(target_arch = "wasm32"))] pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { + use std::io::Write; script_sandbox_check(path)?; // Write to a temporary file, then rename: a crash never leaves a half-written workbook. + // The temporary file is created exclusively, so an existing file or symlink at that + // path (planted by another local user) makes the save fail instead of truncating the + // link's target; and it inherits the destination's access restrictions, so saving a + // private workbook doesn't widen who can read it. let tmp = format!("{path}.sctmp"); - std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| { + let prior = std::fs::metadata(path).ok().map(|m| m.permissions()); + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + if let Some(p) = &prior { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + opts.mode(p.mode()); + } + let mut f = opts.open(&tmp).map_err(|e| { + let _ = std::fs::remove_file(&tmp); + EngineError::Other(format!("{path}: {e}")) + })?; + #[cfg(not(unix))] + if let Some(p) = &prior { + let _ = f.set_permissions(p.clone()); + } + f.write_all(bytes).and_then(|()| f.flush()).map_err(|e| { + let _ = std::fs::remove_file(&tmp); + EngineError::Other(format!("{path}: {e}")) + })?; + std::fs::rename(&tmp, path).map_err(|e| { // A write or rename that fails (full disk, the workbook locked by another app) leaves // no temporary file beside the user's workbook. let _ = std::fs::remove_file(&tmp); @@ -727,6 +765,57 @@ mod tests { range_html(wb, 0, r, &crate::display::range_text(wb, 0, r)).unwrap() } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn save_fails_upfront_when_the_temp_name_is_taken() { + // Another local user who can write the directory must not be able to point the + // save's predictable temporary name at their own file: creation is exclusive. + let dir = std::env::temp_dir().join("gridcraft-fs-safety"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let victim = dir.join("victim.txt"); + std::fs::write(&victim, b"original").unwrap(); + let dest = dir.join("book.xlsx"); + std::os::unix::fs::symlink(&victim, dir.join("book.xlsx.sctmp")).unwrap(); + let err = write_file(dest.to_str().unwrap(), b"new").unwrap_err(); + assert_eq!(std::fs::read(&victim).unwrap(), b"original"); + assert!(!dir.join("book.xlsx").exists(), "{err}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(not(target_arch = "wasm32"))] + #[cfg(unix)] + #[test] + fn save_keeps_the_destination_access_restrictions() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join("gridcraft-fs-safety"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let dest = dir.join("private.xlsx"); + std::fs::write(&dest, b"v1").unwrap(); + std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o600)).unwrap(); + write_file(dest.to_str().unwrap(), b"v2").unwrap(); + let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + // And a brand-new file is created with the restrictive, not permissive, default. + let fresh = dir.join("new.xlsx"); + write_file(fresh.to_str().unwrap(), b"v").unwrap(); + let mode = std::fs::metadata(&fresh).unwrap().permissions().mode() & 0o777; + assert!(mode & 0o022 == 0, "mode {mode:o}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn open_refuses_directories_and_unknowable_lengths() { + let dir = std::env::temp_dir().join("gridcraft-fs-safety"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + assert!(read_file(dir.to_str().unwrap()).is_err(), "a directory is not a regular file"); + assert!(read_file(dir.join("missing").to_str().unwrap()).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn html_export_refuses_an_outsized_grid_instead_of_growing_forever() { // #2387: the used rectangle alone bounds the output (every row writes the whole From a005d8235f65f950b19a404b76df2c44c1b0dd8e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 06:15:24 -0400 Subject: [PATCH 25/56] M2.9: close protection gaps a trusted script or other sheet would slip through (sec scan findings 17,20,24) (#2420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts: refuse `window.*` steps like file and link steps — activating another window repointed every later step at a different open document (finding 17). - format: on a protected sheet, formatting may not change a cell's protection flags: the format-cells permission can't drop `locked` and then authorize content edits the password gates (finding 20). - sheet.moveColumns: the insert/delete protection checks now carry the command's own `sheet` parameter, so the sheet modified is the sheet authorized, not whichever is active (finding 24). Tests: unlock-via-format refused, cross-sheet move refused, window switch refused, plus the positive controls. Engine: 462 tests pass; clippy clean. --- crates/engine/src/cmd/extra.rs | 5 +++ crates/engine/src/cmd/format.rs | 16 ++++++++++ crates/engine/src/cmd/sheet.rs | 6 ++-- crates/engine/src/tests.rs | 56 +++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 009d77f2a..7396d5356 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -1034,6 +1034,11 @@ fn denied_script_step(id: &str, params: &Json) -> Option { if id.starts_with("file.") || id.starts_with("link.") { return Some(format!("scripts can't run `{id}`: it reaches outside the workbook")); } + // Scripts are scoped to their workbook: a step that switches the active window would + // point every later step at another open document, so those are refused too. + if id.starts_with("window.") { + return Some(format!("scripts can't run `{id}`: it reaches outside the workbook")); + } if params.get("path").is_some() { return Some(format!("scripts can't pass a file `path` to `{id}`")); } diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index cd28cfbfa..691b531c7 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -233,10 +233,23 @@ fn with(p: &Json, key: &str, v: Json) -> Json { pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) -> Result { let sheet = target_sheet(s, p)?; let ranges = target_ranges(s, p)?; + let guarded = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.is_some()); if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pr.format_cells)) { return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into())); } edit(s, |cx| { + // Formatting on a protected sheet keeps the cell's permission flags: a format call + // that flips `locked` from the format permission would authorize content edits that + // the password should still gate, so those flags may not change (Excel keeps that + // checkbox disabled while the sheet is protected). + const FLAG_GUARD: &str = "You can't change cell protection on a protected sheet. Unprotect it first."; + macro_rules! guard_flags { + ($old:expr, $new:expr) => { + if guarded && cx.wb.styles.get($old).protection != cx.wb.styles.get($new).protection { + return Err(EngineError::Other(FLAG_GUARD.into())); + } + }; + } let mut cache: std::collections::HashMap = std::collections::HashMap::new(); for r in &ranges { let full_rows = r.is_full_rows(); @@ -251,6 +264,7 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) -> info.and_then(|i| i.style).unwrap_or_default() }; let new = *cache.entry(cur).or_insert_with(|| cx.wb.styles.derive(cur, &f)); + guard_flags!(cur, new); let sh = cx.sheet_mut(sheet)?; let map = if full_rows && !full_cols { &mut sh.rows } else { &mut sh.cols }; map.entry(line).or_default().style = Some(new); @@ -260,6 +274,7 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) -> cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(*r).map(|(c, x)| (c, x.style)).collect()).unwrap_or_default(); for (c, st) in &cells { let new = *cache.entry(*st).or_insert_with(|| cx.wb.styles.derive(*st, &f)); + guard_flags!(*st, new); cx.sheet_mut(sheet)?.set_style(*c, new); } // Formatting touched these cells: re-fit their rows (Excel auto-fits). @@ -276,6 +291,7 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) -> let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default(); let new = *cache.entry(st).or_insert_with(|| cx.wb.styles.derive(st, &f)); if new != st { + guard_flags!(st, new); cx.sheet_mut(sheet)?.set_style(c, new); } } diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index a453f250b..e6caa1dc7 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -401,8 +401,10 @@ fn move_columns(s: &mut Session, p: &Json) -> Result { { return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into())); } - check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1()}), Axis::Cols, before, count, true)?; - check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1()}), Axis::Cols, from, count, false)?; + // The checks must authorize the sheet this command modifies (`target_sheet`), not whatever + // sheet is active: both carry the command's own `sheet` parameter on. + check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": p.get("sheet")}), Axis::Cols, before, count, true)?; + check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": p.get("sheet")}), Axis::Cols, from, count, false)?; let moved_from = if before <= from { from + count } else { from }; if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1be90d56c..f4c47bb62 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4604,4 +4604,60 @@ fn html_export_without_pictures_or_charts_has_no_fixed_geometry() { let html = crate::io::to_html(&s.doc().unwrap().wb, 0).unwrap(); assert!(!html.contains("") && !html.contains("table-layout") && !html.contains(" = { + let d = s.doc().unwrap(); + let sh = &d.wb.sheets[d.wb.sheet_index("P").unwrap()]; + (0..4).map(|c| sh.value(CellRef::new(0, c as u32))).collect() + }; + assert_eq!(pv, vec![Value::number(1.0), Value::number(2.0), Value::number(3.0), Value::number(4.0)]); + // The active, unprotected sheet moves fine. + s.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D"]]})).unwrap(); + s.execute("sheet.moveColumns", json!({"cols": "B:B", "before": 4})).unwrap(); + assert_eq!(v(&s, "A1"), Value::text("A")); +} + +#[test] +fn a_script_cannot_switch_windows() { + // Stored scripts are scoped to their workbook: a step that activates another window would + // point every later step at another open document, so it is refused before any step runs. + let mut s = s(); + s.execute("automate.newScript", json!({"name": "t", "commands": [{"command": "window.activate", "params": {"index": 0}}]})).unwrap(); + s.execute("automate.trustScripts", json!({"trusted": true})).unwrap(); + let err = s.execute("automate.runScript", json!({"name": "t"})).expect_err("script switching windows"); + assert!(err.to_string().contains("window.activate"), "{err}"); + // A benign script runs. + s.execute("automate.newScript", json!({"name": "ok", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "=1+1"}}]})).unwrap(); + s.execute("automate.runScript", json!({"name": "ok"})).unwrap(); + assert_eq!(v(&s, "A1"), Value::number(2.0)); +} + mod seams; From 10bec74810c55c73fda2f08393ad45238eb783b5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 06:33:19 -0400 Subject: [PATCH 26/56] =?UTF-8?q?M6:=20misc=20hardening=20=E2=80=94=20unbo?= =?UTF-8?q?unded=20picture=20cache,=20stats=20walk,=20signing=20tmpdir,=20?= =?UTF-8?q?web=20clipboard,=20dash=20budget,=20fs=20cycle,=20rezip=20loop?= =?UTF-8?q?=20(#2422)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - floating_pictures: draw without retaining when eviction can't free enough room, so a large workbook can't pin the cache past its budget - xtask stats: skip unreadable subtrees instead of aborting the walk - import-cert.sh: per-run private temp dir instead of predictable paths in a shared root - gridcraft-web: gate clipboard ops behind user activation - chart raster: cap dash rendering to the stroked budget - cli check: mark directories visited by identity on unix so hard-link loops can't recurse; reuse one decompression of a reused part --- apps/gridcraft-cli/src/check.rs | 67 +++++++++++++++++++++---- apps/gridcraft-web/src/web.rs | 4 +- crates/chart/src/raster.rs | 33 ++++++++++-- crates/ui-egui/src/floating_pictures.rs | 22 +++++++- packaging/macos/import-cert.sh | 12 +++-- xtask/src/stats.rs | 27 ++++++++-- 6 files changed, 142 insertions(+), 23 deletions(-) diff --git a/apps/gridcraft-cli/src/check.rs b/apps/gridcraft-cli/src/check.rs index 74d2fca71..716f214b8 100644 --- a/apps/gridcraft-cli/src/check.rs +++ b/apps/gridcraft-cli/src/check.rs @@ -248,10 +248,11 @@ impl Discovery { /// (`~$…`) are skipped. fn collect_inputs(inputs: &[String]) -> Discovery { let mut out = Discovery::default(); + let mut seen = std::collections::HashSet::new(); for i in inputs { let p = PathBuf::from(i); if p.is_dir() { - if !walk(&p, 0, &mut out) { + if !walk(&p, 0, &mut out, &mut seen) { break; } } else if !out.add_file(p) { @@ -268,7 +269,20 @@ fn collect_inputs(inputs: &[String]) -> Discovery { } /// Returns false only when the global file budget stops traversal. -fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool { +fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collections::HashSet) -> bool { + // A directory is visited once by identity (dev+inode on unix): several links to the same + // directory are aliases, not exponential branches of the scan. Other platforms keep the + // depth cap only. + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + if let Ok(m) = std::fs::metadata(dir) + && let Some(id) = m.dev().checked_mul(1 << 60).and_then(|d| d.checked_add(m.ino())) + && !seen.insert(id) + { + return true; + } + } if depth > MAX_DEPTH { out.fail(dir, format!("directory depth limit of {MAX_DEPTH} exceeded; this subtree was not checked")); return true; @@ -301,7 +315,7 @@ fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool { } }; if metadata.is_dir() { - if !walk(&p, depth + 1, out) { + if !walk(&p, depth + 1, out, seen) { return false; } } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm")) @@ -981,6 +995,12 @@ fn relationship_kinds(bytes: &[u8]) -> Result, String> let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?; let names = zip_names(&mut zip); let by_key: HashMap = names.iter().map(|n| (part_key(n), n)).collect(); + // Several relationships name the same part; each fresh check decompressed it again, so + // repeated references to one highly compressible part multiplied the work. The verdict is + // remembered per (part, named content) and the decompression the package can cause is + // budgeted as a whole (finding: repeated decompression). + let mut verdicts: HashMap<(String, &'static str), bool> = HashMap::new(); + let mut budget: u64 = 512 * 1024 * 1024; let mut kinds = BTreeMap::new(); for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) { let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue }; @@ -994,7 +1014,7 @@ fn relationship_kinds(bytes: &[u8]) -> Result, String> let external = attr(attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")); if !external && let Some(part) = by_key.get(&resolve_target(&src_dir, target).to_ascii_lowercase()) - && !holds_something(&mut zip, part, kind) + && !holds_something(&mut zip, part, kind, &mut verdicts, &mut budget) { continue; } @@ -1013,15 +1033,41 @@ fn rels_source_dir(key: &str) -> Option { /// Whether a part has content: an element under its root, or for comments (whose authors list /// comes first) a comment, for VML a shape. Read only as far as needed; an unreadable part counts. -fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str) -> bool { - use quick_xml::events::Event; - let Ok(f) = zip.by_name(name) else { return true }; - let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(MAX_XML_PART))); - let wanted: Option<&[u8]> = match kind.to_ascii_lowercase().as_str() { +/// `verdicts` remembers the answer per (part, named content); `budget` is the decompression this +/// package's remaining checks may cause, and once it is spent the part counts as having content. +fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str, verdicts: &mut HashMap<(String, &'static str), bool>, budget: &mut u64) -> bool { + let wanted: Option<&'static [u8]> = match kind.to_ascii_lowercase().as_str() { "comments" => Some(b"comment"), "vmldrawing" => Some(b"shape"), _ => None, }; + let marker = match wanted { + Some(b"comment") => "comment", + Some(_) => "shape", + None => "any", + }; + if let Some(v) = verdicts.get(&(name.to_ascii_lowercase(), marker)) { + return *v; + } + let v = holds_something_uncached(zip, name, marker, budget); + verdicts.insert((name.to_ascii_lowercase(), marker), v); + v +} + +fn holds_something_uncached(zip: &mut Zip<'_>, name: &str, marker: &'static str, budget: &mut u64) -> bool { + use quick_xml::events::Event; + let Ok(f) = zip.by_name(name) else { return true }; + if *budget == 0 { + return true; + } + let take = f.size().min(MAX_XML_PART).min(*budget); + *budget -= take; + let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(take))); + let wanted: Option<&'static [u8]> = match marker { + "comment" => Some(b"comment"), + "shape" => Some(b"shape"), + _ => None, + }; let mut buf = Vec::new(); let mut in_root = false; loop { @@ -1321,7 +1367,8 @@ mod tests { let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id())); std::fs::write(&path, "not a directory").unwrap(); let mut discovery = Discovery::default(); - assert!(walk(&path, 0, &mut discovery)); + let mut seen = std::collections::HashSet::new(); + assert!(walk(&path, 0, &mut discovery, &mut seen)); assert!(discovery.files.is_empty()); assert_eq!(discovery.failures.len(), 1); assert_eq!(discovery.failures[0].path, path.to_string_lossy()); diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index 8e2e60837..bae684c38 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -214,7 +214,9 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { if let Some(w) = web_sys::window() && let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url) { - let _ = w.open_with_url_and_target(&url, "_blank"); + // With `noopener` the new browsing context can't hold a reference back to the + // editor window and navigate it while the user reads the linked page. + let _ = w.open_with_url_and_target(&url, "_blank,noopener"); } })), inbox: Some(inbox), diff --git a/crates/chart/src/raster.rs b/crates/chart/src/raster.rs index 67ed53ec6..0de2450b5 100644 --- a/crates/chart/src/raster.rs +++ b/crates/chart/src/raster.rs @@ -160,21 +160,28 @@ fn stroke_paths(pts: &[[f32; 2]], width: f32, dash: bool, closed: bool) -> Vec

0.0) { continue; } let mut t = 0.0; - let mut n = 0; - while t < len && n < 10_000 { + while t < len && budget > 0 { let e = (t + on).min(len); out.push(([a[0] + dx * t / len, a[1] + dy * t / len], [a[0] + dx * e / len, a[1] + dy * e / len])); t += on + off; - n += 1; + budget -= 1; } } segs = out; @@ -262,3 +269,23 @@ pub fn rasterize(prims: &[Prim], w: u32, h: u32, background: Rgba) -> Vec { } cv.buf } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dashed_geometry_stays_within_the_dash_budget() { + // Forty very long dashed lines, each allowed 10_000 dashes per segment: before the + // shared cap they eagerly allocated ~1.6 MB of segment pairs × points and a like + // number of quads before any of it reached a 4-pixel canvas (finding: dash expansion). + let prims: Vec = + (0..40).map(|i| Prim::Line { pts: vec![[0.0, i as f32], [900.0, i as f32]], color: [0, 0, 0, 255], width: 1.0, dash: true }).collect(); + let img = rasterize(&prims, 2, 2, [255, 255, 255, 255]); + assert_eq!(img.len(), 2 * 2 * 4); + // And the geometry itself is bounded even for one huge dashed polyline. + assert!(stroke_paths(&[[0.0, 0.0], [1e6, 0.0]], 1.0, true, false).len() <= 65_536); + let prim = [Prim::Line { pts: vec![[0.0, 0.0], [10.0, 0.0]], color: [0, 0, 0, 255], width: 1.0, dash: true }]; + assert_eq!(rasterize(&prim, 1, 1, [0, 0, 0, 255]).len(), 4); + } +} diff --git a/crates/ui-egui/src/floating_pictures.rs b/crates/ui-egui/src/floating_pictures.rs index 68de6e203..5caa0869c 100644 --- a/crates/ui-egui/src/floating_pictures.rs +++ b/crates/ui-egui/src/floating_pictures.rs @@ -95,6 +95,13 @@ impl Cache { } None => (None, u32::MAX), }; + // Eviction only frees entries used before this frame, so a workbook covered in same-frame + // pictures can leave the cache unable to make room. Then draw the texture without + // retaining it: the handle is dropped at the end of the frame and the GPU memory goes + // with it, instead of the budget being outflanked (finding: cache admission). + if self.entries.len() >= MAX_CACHE_ITEMS || self.bytes.saturating_add(bytes) > MAX_CACHE_BYTES { + return texture; + } self.bytes = self.bytes.saturating_add(bytes); self.entries.insert(key, Entry { texture: texture.clone(), bytes, side, used: frame }); texture @@ -212,13 +219,24 @@ mod tests { } assert!(cache.bytes <= MAX_CACHE_BYTES, "{} bytes kept", cache.bytes); assert!(cache.entries.len() < data.len()); - // Many on screen at once, over the budget: every one keeps its texture, frame after frame. + // Many on screen at once, over the budget: each still gets a texture this frame, but the + // cache cannot grow past its budget whatever the workbook shows. Eight of the 16 MB + // textures fit the 128 MB budget, and those stay pinned; the rest re-upload each frame + // instead of being retained over it (finding: cache admission). + // Two frames pass so the rehearsal's entries are old enough to be evictable. + frame(&ctx); + frame(&ctx); let shown: Vec<_> = data.iter().enumerate().take(12).map(|(i, d)| cache.texture(&ctx, key(1, i as u32, d), d, 2048).unwrap().id()).collect(); for _ in 0..3 { frame(&ctx); for (i, d) in data.iter().enumerate().take(12) { - assert_eq!(cache.texture(&ctx, key(1, i as u32, d), d, 2048).map(|t| t.id()), Some(shown[i])); + let id = cache.texture(&ctx, key(1, i as u32, d), d, 2048).unwrap().id(); + if i < 8 { + assert_eq!(id, shown[i], "a picture that fits keeps its texture"); + } } + assert!(cache.bytes <= MAX_CACHE_BYTES, "{} bytes kept", cache.bytes); + assert!(cache.entries.len() <= 8, "{} entries kept", cache.entries.len()); } } diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index 4611ffe05..ce9432e4e 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -17,12 +17,16 @@ if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then exit 0 fi -TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" -KEYCHAIN="$TMP/gridcraft-signing.keychain-db" -CERT="$TMP/gridcraft-signing.p12" +BASE="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +# A private directory created exclusively, not predictable names in a possibly shared temp +# root: another local user's symlink at `gridcraft-signing.p12` would otherwise select the +# write target and capture the decoded certificate. +WORK="$(mktemp -d "$BASE/gridcraft-signing.XXXXXXXX")" +KEYCHAIN="$WORK/gridcraft-signing.keychain-db" +CERT="$WORK/gridcraft-signing.p12" KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}" -trap 'rm -f "$CERT"' EXIT +trap 'rm -rf "$WORK"' EXIT printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT" security create-keychain -p "$KC_PASS" "$KEYCHAIN" security set-keychain-settings -lut 21600 "$KEYCHAIN" diff --git a/xtask/src/stats.rs b/xtask/src/stats.rs index f40b1300a..462f1c7de 100644 --- a/xtask/src/stats.rs +++ b/xtask/src/stats.rs @@ -2,6 +2,9 @@ use std::path::Path; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt as _; + #[derive(Default, Clone, Copy)] struct Counts { files: usize, @@ -10,7 +13,24 @@ struct Counts { proptests: usize, } -fn walk(dir: &Path, c: &mut Counts) { +fn walk(dir: &Path, c: &mut Counts, seen: &mut std::collections::HashSet, budget: &mut usize) { + if *budget == 0 { + // Hard stop for pathological trees: two directory symlinks pointing at each other + // would otherwise be walked exponentially. + return; + } + // Directory identities (dev+inode on unix) make a symlink to a visited directory a + // no-op, not another exponential branch of the scan. + #[cfg(unix)] + let id = std::fs::metadata(dir).ok().and_then(|m| m.dev().checked_mul(1 << 60).and_then(|d| d.checked_add(m.ino()))); + #[cfg(not(unix))] + let id: Option = None; + if let Some(id) = id + && !seen.insert(id) + { + return; + } + *budget -= 1; let Ok(rd) = std::fs::read_dir(dir) else { return; }; @@ -18,10 +38,11 @@ fn walk(dir: &Path, c: &mut Counts) { let p = e.path(); let name = e.file_name(); let name = name.to_string_lossy(); + // A symlinked directory is descended only if its identity is new (checked above). if p.is_dir() { // Nested fuzz workspaces and build output are not part of the crate. if name != "target" && name != "fuzz" && !name.starts_with('.') { - walk(&p, c); + walk(&p, c, seen, budget); } } else if name.ends_with(".rs") && let Ok(s) = std::fs::read_to_string(&p) @@ -71,7 +92,7 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> { continue; }; let mut c = Counts::default(); - walk(&dir, &mut c); + walk(&dir, &mut c, &mut std::collections::HashSet::new(), &mut 10_000); let ex = if exact { exact_tests(&name) } else { None }; rows.push((name, c, ex)); } From 07890b47f8545ea71385a6f4b996a4a0a1ae2fe7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 03:41:44 -0700 Subject: [PATCH 27/56] Keyboard: Ctrl+Shift+U expands and collapses the formula bar instead of underlining Ctrl+Shift+U ran the Ctrl+U shortcut on Windows and Linux and underlined the selection. In Excel it expands or collapses the formula bar, on Windows and as Control+Shift+U on a Mac. It now does that on the grid and in the cell editor or formula bar while editing, and the entry stays open. The new UI command view.expandFormulaBar {on?} sets the state, or toggles it without a parameter, and returns {on}. The chevron at the end of the formula bar is unchanged. --- ROADMAP.md | 1 + crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/formula_bar.rs | 4 + crates/ui-egui/src/keymap.rs | 2 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/tests/it/formula_bar_expand.rs | 95 +++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/ui-parity.md | 8 +- 8 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/it/formula_bar_expand.rs diff --git a/ROADMAP.md b/ROADMAP.md index da06359b5..1d9cf5f48 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline | | 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text | | 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row | | 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) | diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 11d535ac4..8e29bccb4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -230,6 +230,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - for (id, label, params) in [ ("ui.ribbonTab", "Ribbon Tab", ""), ("view.collapseRibbon", "Collapse Ribbon", ""), + ("view.expandFormulaBar", "Expand Formula Bar", "{on?: bool} (toggles without it) → {on}"), ("view.darkMode", "Dark Mode", ""), ("view.theme", "Display Theme", ""), ("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"), diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index b88699f57..3a33f3867 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -290,6 +290,10 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: state.store(ui.ctx(), id); } } + // Ctrl+Shift+U (Control+Shift+U on a Mac) expands or collapses the formula bar while editing too. + if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, Key::U)) { + app.run_or_alert("view.expandFormulaBar", json!({})); + } } // The text widget. let refs = formula_refs(&ed.text); diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 5840036a1..03bd2d655 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -103,6 +103,8 @@ pub fn lookup(key: Key, physical: Option, m: Modifiers, mac: bool) -> Optio L if (cmd || ctrl) && s => run("data.filter"), L if cmd && mac => run("home.alignLeft"), L if ctrl && mac => Shortcut::Dialog("defineName", json!({})), + // Ctrl+Shift+U (Control+Shift+U in Excel for Mac) expands or collapses the formula bar. + U if ctrl && s => run("view.expandFormulaBar"), U if ctrl && mac && !s => Shortcut::Edit, L if cmd => run("insert.table"), E if cmd && mac && !s => run("home.alignCenter"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 14c442331..5b421a4db 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -632,6 +632,10 @@ impl SheetApp { self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed; Ok(Json::Null) } + "view.expandFormulaBar" => { + self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded); + Ok(json!({"on": self.ui.formula_bar_expanded})) + } "view.darkMode" => { let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark }; self.ui.dark = p.get("on").and_then(Json::as_bool).unwrap_or(!current); diff --git a/crates/ui-egui/tests/it/formula_bar_expand.rs b/crates/ui-egui/tests/it/formula_bar_expand.rs new file mode 100644 index 000000000..95b4368e5 --- /dev/null +++ b/crates/ui-egui/tests/it/formula_bar_expand.rs @@ -0,0 +1,95 @@ +//! Ctrl+Shift+U (Control+Shift+U in Excel for Mac) expands and collapses the formula bar. + +use egui::{Event, Key, Modifiers}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::keymap::{Shortcut, lookup}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// Ctrl+Shift on Windows and Linux sets `ctrl`, `command` and `shift`. +const CTRL_SHIFT: Modifiers = Modifiers { alt: false, ctrl: true, shift: true, mac_cmd: false, command: true }; +/// Control+Shift on a Mac: `ctrl` without `command`. +const MAC_CONTROL_SHIFT: Modifiers = Modifiers { alt: false, ctrl: true, shift: true, mac_cmd: false, command: false }; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("cell.set", json!({"cell": "A1", "input": "text"})).unwrap(); + session.execute("selection.set", json!({"range": "A1"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn ctrl_shift_u(h: &mut Harness, m: Modifiers) { + h.input_mut().events.push(Event::Key { key: Key::U, physical_key: Some(Key::U), pressed: true, repeat: false, modifiers: m }); + h.input_mut().events.push(Event::Key { key: Key::U, physical_key: Some(Key::U), pressed: false, repeat: false, modifiers: m }); + h.run_steps(2); +} + +fn expanded(h: &Harness) -> bool { + h.state().ui.formula_bar_expanded +} + +fn underlined(h: &Harness) -> bool { + h.state().session.journal.iter().any(|(id, _)| id == "home.underline") +} + +#[test] +fn ctrl_shift_u_on_the_grid_expands_and_collapses_the_formula_bar() { + let mut h = harness(); + assert!(!expanded(&h)); + ctrl_shift_u(&mut h, CTRL_SHIFT); + assert!(expanded(&h), "the first Ctrl+Shift+U expands the formula bar"); + ctrl_shift_u(&mut h, CTRL_SHIFT); + assert!(!expanded(&h), "the second Ctrl+Shift+U collapses it"); + assert!(!underlined(&h), "Ctrl+Shift+U is not Underline"); +} + +#[test] +fn control_shift_u_expands_the_formula_bar_in_excel_for_mac() { + let mut h = harness(); + h.ctx.set_os(egui::os::OperatingSystem::Mac); + h.run_steps(2); + ctrl_shift_u(&mut h, MAC_CONTROL_SHIFT); + assert!(expanded(&h)); + assert!(h.state().editor.is_none(), "Control+Shift+U does not start editing as Control+U does"); + let expand = Some(Shortcut::Run("view.expandFormulaBar", json!({}))); + assert_eq!(lookup(Key::U, Some(Key::U), MAC_CONTROL_SHIFT, true), expand); + assert_eq!(lookup(Key::U, Some(Key::U), CTRL_SHIFT, false), expand); + // Control+U on a Mac still edits the active cell. + assert_eq!(lookup(Key::U, Some(Key::U), Modifiers::CTRL, true), Some(Shortcut::Edit)); +} + +#[test] +fn ctrl_shift_u_while_editing_keeps_the_entry() { + let mut h = harness(); + h.state_mut().begin_edit(Some("draft".into()), false); + h.run_steps(2); + ctrl_shift_u(&mut h, CTRL_SHIFT); + assert!(expanded(&h), "Ctrl+Shift+U works in the cell editor too"); + let ed = h.state().editor.as_ref().expect("the edit stays open"); + assert_eq!(ed.text, "draft"); + ctrl_shift_u(&mut h, CTRL_SHIFT); + assert!(!expanded(&h)); + assert!(h.state().editor.is_some()); +} + +#[test] +fn expand_formula_bar_command_sets_or_toggles_the_state() { + let mut h = harness(); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"on": true})), Ok(json!({"on": true}))); + assert!(expanded(&h)); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"on": true})), Ok(json!({"on": true}))); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({})), Ok(json!({"on": false}))); + assert!(!expanded(&h)); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 3a9769c1c..06ffaac02 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -34,6 +34,7 @@ mod find_dialog_focus; mod floating_pictures; mod font_size_box; mod format_painter; +mod formula_bar_expand; mod formula_locale; mod formula_point_headers; mod formula_popup; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5d5f1efee..17538e580 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-23 · **Change:** minor (New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Ctrl+Shift+U expands and collapses the formula bar; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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; 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 @@ -134,9 +134,10 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | F5, Shift+F5 (Mac), F7, Shift+F9, Ctrl+Alt+F9, F12, Ctrl+F4 | same | Go To, Find, Spelling, calculate sheet / all, Save As, close | **added** | | Shift+F10, Alt+Shift+F10, Ctrl+F1 | Shift+F10, Cmd+Option+R | context menu, Error Checking, ribbon | **added** | | F1, F3, F4 (repeat), F6 / Shift+F6, F8 / Shift+F8, F11, End mode | same | help, Paste Name, Repeat, pane cycling, extend/add mode, chart sheet | missing (no such feature yet) | -| Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | Quick Analysis, Function Arguments, formula bar height, tooltips | missing | +| Ctrl+Q, Ctrl+A after a function name, Ctrl+Alt+P | same | Quick Analysis, Function Arguments, tooltips | missing | | Ctrl+Shift+A after a function name | Control+Shift+A | argument names: `=SUMIF(` becomes `=SUMIF(range,criteria,sum_range)`, the `(` added when missing; one Undo takes them back | **added** | -| Ctrl+6, Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | objects, Quick Analysis, Function Arguments, formula bar height, tooltips | missing | +| Ctrl+Shift+U | Control+Shift+U | expand or collapse the formula bar (`view.expandFormulaBar`), on the grid and while editing; on Windows and Linux it no longer underlines | **added** | +| Ctrl+6, Ctrl+Q, Ctrl+A after a function name, Ctrl+Alt+P | same | objects, Quick Analysis, Function Arguments, tooltips | missing | | Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F8 / F11 / F12 | same | group sheets, shape cycling, full screen, window keys, macros, VBA, Power Query | missing | | — | Cmd+= (calculate), Cmd+T (references while editing; F4 works), Shift+F7, Cmd+Shift+L | Mac-only extras | missing (Cmd+= is egui's zoom key; no thesaurus or Modify Cell Style) | @@ -178,6 +179,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Ctrl+Shift+U (Control+Shift+U on a Mac) expands or collapses the formula bar, as in Excel ("Expand or collapse the formula bar. Ctrl+Shift+U"), on the grid and in the cell editor or formula bar while editing. On Windows and Linux it used to underline the selection, as Ctrl+U does. The command is `view.expandFormulaBar` (`{on?}`, toggles without it) | | 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) | | 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is | | 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) | From d0fe8b6423694b9124e35cd8f283b7a58220dbe5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 04:50:46 -0700 Subject: [PATCH 28/56] Tests: the engine's file-system tests each use their own temporary folder save_fails_upfront_when_the_temp_name_is_taken, save_keeps_the_destination_access_restrictions and open_refuses_directories_and_unknowable_lengths in crates/engine/src/io.rs all worked in temp_dir()/gridcraft-fs-safety and removed it at the start and the end. cargo test runs them on parallel threads, so one test could delete the folder while another was writing to it, and the other test failed. Each test now uses its own folder, named with a short label and the process id, as the gridcraft-write and gridcraft-sandbox tests in the same file do. --- crates/engine/src/io.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index be47e0a67..92ab69038 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -770,7 +770,7 @@ mod tests { fn save_fails_upfront_when_the_temp_name_is_taken() { // Another local user who can write the directory must not be able to point the // save's predictable temporary name at their own file: creation is exclusive. - let dir = std::env::temp_dir().join("gridcraft-fs-safety"); + let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-temp-name-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let victim = dir.join("victim.txt"); @@ -788,7 +788,7 @@ mod tests { #[test] fn save_keeps_the_destination_access_restrictions() { use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir().join("gridcraft-fs-safety"); + let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-permissions-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let dest = dir.join("private.xlsx"); @@ -808,7 +808,7 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn open_refuses_directories_and_unknowable_lengths() { - let dir = std::env::temp_dir().join("gridcraft-fs-safety"); + let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-open-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); assert!(read_file(dir.to_str().unwrap()).is_err(), "a directory is not a regular file"); From 3099df934eda27cd457a9b8d1525d3fc973a1b7b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 07:58:13 -0400 Subject: [PATCH 29/56] M7: verify Agile encryption's dataIntegrity before returning plaintext (#2425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agile decryption checked only the password verifier, which authenticates the key — not the package. An attacker who could replace EncryptedPackage ciphertext could change workbook content undetected ([MS-OFFCRYPTO] 2.3.4.14, the descriptor's dataIntegrity element). The reader now requires the dataIntegrity pair, decrypts it with the recovered package key, and checks HMAC(hash, hmacKey) over the whole EncryptedPackage stream (StreamSize field included) against the stored value before any plaintext is returned. Excel always writes the element ("SHOULD" in the spec); descriptors without it are refused as damaged rather than accepted unauthenticated. - crypto: RFC 2104/4231 HMAC test vectors, RFC-test cases for a key longer than the block size - fixtures write the pair like Excel; new tests: missing/empty/ragged integrity fields, tampered ciphertext and descriptor, a changed StreamSize field --- crates/xlsx/src/crypto.rs | 58 ++++++++++++++++- crates/xlsx/src/crypto/tests.rs | 82 +++++++++++++++++++++++- crates/xlsx/src/testing.rs | 25 +++++--- crates/xlsx/src/tests/encrypted_tests.rs | 3 +- 4 files changed, 153 insertions(+), 15 deletions(-) diff --git a/crates/xlsx/src/crypto.rs b/crates/xlsx/src/crypto.rs index 46a19d046..0a63044a0 100644 --- a/crates/xlsx/src/crypto.rs +++ b/crates/xlsx/src/crypto.rs @@ -26,6 +26,9 @@ const BLOCK: usize = 16; pub(crate) const BLOCK_VERIFIER_INPUT: [u8; 8] = [0xFE, 0xA7, 0xD2, 0x76, 0x3B, 0x4B, 0x9E, 0x79]; pub(crate) const BLOCK_VERIFIER_HASH: [u8; 8] = [0xD7, 0xAA, 0x0F, 0x6D, 0x30, 0x61, 0x34, 0x4E]; pub(crate) const BLOCK_KEY: [u8; 8] = [0x14, 0x6E, 0x0B, 0xE7, 0xAB, 0xAC, 0xD0, 0xD6]; +/// The two dataIntegrity block keys ([MS-OFFCRYPTO] 2.3.4.14), one per HMAC field. +pub(crate) const BLOCK_HMAC_KEY: [u8; 8] = [0x5F, 0xB2, 0xAD, 0x01, 0x0C, 0xB9, 0xE1, 0xF6]; +pub(crate) const BLOCK_HMAC_VALUE: [u8; 8] = [0xA0, 0x67, 0x7F, 0x02, 0xB2, 0x2C, 0x84, 0x33]; /// The password key encryptor's URI (the other kind is certificate-based). pub(crate) const PASSWORD_ENCRYPTOR: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor/password"; @@ -165,6 +168,14 @@ impl Hash { Hash::Sha512 => run::(parts), } } + + /// The bytes the algorithm's compression function takes: 128 for SHA-384/512, else 64. + fn block_size(self) -> usize { + match self { + Hash::Sha384 | Hash::Sha512 => 128, + Hash::Sha1 | Hash::Sha256 => 64, + } + } } /// H0 = H(salt ‖ UTF-16LE(password)), then Hn = H(LE32(n - 1) ‖ Hn-1) for `spin` rounds. @@ -184,6 +195,19 @@ pub(crate) fn derive(hash: Hash, a: &[u8], b: &[u8], len: usize) -> Vec { k } +/// HMAC-SHA-`hash` ([RFC2104]), as Agile's dataIntegrity uses. +pub(crate) fn hmac(hash: Hash, key: &[u8], message: &[u8]) -> Vec { + let bs = hash.block_size(); + let mut k = key.to_vec(); + if k.len() > bs { + k = hash.digest(&[&k]); + } + k.resize(bs, 0); + let pad = |x: u8| k.iter().map(|b| b ^ x).collect::>(); + let inner = hash.digest(&[&pad(0x36), message]); + hash.digest(&[&pad(0x5C), &inner]) +} + /// The Standard encryption key ([MS-OFFCRYPTO] 2.3.4.7): SHA-1 iterated 50,000 times, the /// block-0 hash, then the 0x36 / 0x5C expansion, truncated to `key_len`. pub(crate) fn standard_key(password: &str, salt: &[u8], key_len: usize) -> Vec { @@ -368,8 +392,9 @@ impl Params { } } -/// What an Agile descriptor holds: the package's cipher, and the password key encryptor with its -/// three encrypted values (verifier input, verifier hash, package key). +/// What an Agile descriptor holds: the package's cipher, the password key encryptor with its +/// three encrypted values (verifier input, verifier hash, package key), and the package's +/// integrity pair, which Excel always writes and the reader requires. struct AgileDescriptor { key_data: Params, key: Params, @@ -377,6 +402,8 @@ struct AgileDescriptor { verifier_input: Vec, verifier_hash: Vec, key_value: Vec, + hmac_key: Vec, + hmac_value: Vec, } /// `xml` is the XML encryption descriptor after the 8-byte version and reserved field. @@ -395,6 +422,16 @@ fn agile_descriptor(xml: &[u8]) -> Result { return Err(malformed(format!("a spin count of {spin}, more than the {MAX_SPIN_COUNT} allowed"))); } let field = |name: &str| base64(encryptor.attr(name).ok_or_else(|| malformed(format!("no {name}")))?); + // A descriptor without dataIntegrity can't prove its ciphertext is the one the password + // protects: the verifier only authenticates the key, not the package ([MS-OFFCRYPTO] 2.3.4.14). + let integrity = root.child("dataIntegrity").ok_or_else(|| malformed("no dataIntegrity"))?; + let hmac_field = |attr: &str| { + let v = base64(integrity.attr(attr).ok_or_else(|| malformed(format!("no {attr}")))?)?; + if v.is_empty() || v.len() % BLOCK != 0 { + return Err(malformed(format!("a whole-block {attr}"))); + } + Ok(v) + }; Ok(AgileDescriptor { key_data, key, @@ -402,11 +439,14 @@ fn agile_descriptor(xml: &[u8]) -> Result { verifier_input: field("encryptedVerifierHashInput")?, verifier_hash: field("encryptedVerifierHashValue")?, key_value: field("encryptedKeyValue")?, + hmac_key: hmac_field("encryptedHmacKey")?, + hmac_value: hmac_field("encryptedHmacValue")?, }) } fn agile(xml: &[u8], package: &[u8], password: &str) -> Result, DecryptError> { - let AgileDescriptor { key_data, key, spin, verifier_input, verifier_hash, key_value } = agile_descriptor(xml)?; + let descriptor = agile_descriptor(xml)?; + let AgileDescriptor { key_data, key, spin, verifier_input, verifier_hash, key_value, hmac_key, hmac_value } = descriptor; let h = iterated_hash(key.hash, password, &key.salt, spin); let iv = key.iv(None); let field = |data: &[u8], block_key: &[u8]| -> Result, DecryptError> { @@ -423,6 +463,18 @@ fn agile(xml: &[u8], package: &[u8], password: &str) -> Result, DecryptE } let secret = field(&key_value, &BLOCK_KEY)?; let aes = Aes::new(secret.get(..key_data.key_len).ok_or_else(|| malformed("a short encryptedKeyValue"))?)?; + // Package integrity, [MS-OFFCRYPTO] 2.3.4.14: both HMAC fields are encrypted with the + // package key (the verifier above only authenticates the password), keyed by a random + // salt-size array, computed over the whole EncryptedPackage stream, StreamSize field included. + let mut mac_field = hmac_key; + aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_KEY)), &mut mac_field)?; + mac_field.truncate(key_data.salt.len()); + let mut declared = hmac_value; + aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_VALUE)), &mut declared)?; + let mac = hmac(key_data.hash, &mac_field, package); + if declared.get(..mac.len()) != Some(mac.as_slice()) { + return Err(malformed("the decrypted data failed its integrity check")); + } let (size, data) = package_parts(package)?; let mut out = Vec::with_capacity(size); for (i, segment) in data.chunks(SEGMENT).enumerate() { diff --git a/crates/xlsx/src/crypto/tests.rs b/crates/xlsx/src/crypto/tests.rs index 0fb0b921b..5f14686e9 100644 --- a/crates/xlsx/src/crypto/tests.rs +++ b/crates/xlsx/src/crypto/tests.rs @@ -6,6 +6,9 @@ use super::*; use crate::testing::{agile_info, encrypt_agile, encrypt_standard, with_agile_header}; const PASSWORD: &str = "Pass1234"; +/// 16 zero bytes (whole AES blocks), a stand-in for a real integrity field where the values +/// can't be reached anyway (scheme errors, descriptor-parse damage). +const HMAC_STUB: &str = "AAAAAAAAAAAAAAAAAAAAAA=="; fn hex(s: &str) -> Vec { (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() @@ -107,9 +110,9 @@ fn unsupported_schemes_are_named() { assert_eq!(unsupported(&rc4), "RC4"); assert_eq!(unsupported(&[3, 0, 3, 0, 0, 0, 0, 0]), "Extensible"); assert_eq!(unsupported(&[1, 0, 1, 0, 0, 0, 0, 0]), "version 1.1"); - let des = agile_info(PASSWORD, 1000).replace("cipherAlgorithm=\"AES\"", "cipherAlgorithm=\"3DES\""); + let des = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace("cipherAlgorithm=\"AES\"", "cipherAlgorithm=\"3DES\""); assert_eq!(unsupported(&with_agile_header(&des)), "3DES"); - let cert = agile_info(PASSWORD, 1000).replace("keyEncryptor/password", "keyEncryptor/certificate"); + let cert = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace("keyEncryptor/password", "keyEncryptor/certificate"); assert_eq!(unsupported(&with_agile_header(&cert)), "certificate"); } @@ -136,7 +139,7 @@ fn damaged_files_are_malformed_not_panics() { ragged[..8].copy_from_slice(&len.to_le_bytes()); assert!(is_malformed(decrypt_streams(&info, &ragged, PASSWORD))); } - let xml = agile_info(PASSWORD, 1000); + let xml = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB); let bad = |s: String| is_malformed(decrypt_streams(&with_agile_header(&s), &[0; 8], PASSWORD)); assert!(bad(xml.replace("spinCount=\"1000\"", "spinCount=\"4000000000\""))); assert!(bad(xml.replace("saltValue=\"", "saltValue=\"*"))); @@ -155,3 +158,76 @@ fn non_ascii_passwords_hash_as_utf16le_like_msoffcrypto() { hex("2bca96ca0a7e8d88d9314a2eb1819a1ece94ff9ce34266ff82f891a88c4240c4c9f39c729b7c5c410c27fc8e2b7ad90d8c005eb68a603377b8a4ab858041c76a") ); } + +#[test] +fn hmac_matches_the_rfc2104_test_vectors() { + use crate::crypto::hmac as mac; + let h = |v: Vec| v.iter().map(|b| format!("{b:02x}")).collect::(); + // RFC 2202 test case 1 (HMAC-SHA-1) and HMAC-SHA-256 vectors. + assert_eq!(h(mac(Hash::Sha1, &[0x0b; 20], b"Hi There")), "b617318655057264e28bc0b6fb378c8ef146be00"); + assert_eq!(h(mac(Hash::Sha256, &[0x0b; 20], b"Hi There")), "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"); + // RFC 4231 test case 5 (HMAC-SHA-256): a key longer than the block size is hashed first. + assert_eq!( + h(mac(Hash::Sha256, &[0xaa; 131], b"Test Using Larger Than Block-Size Key - Hash Key First")), + "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54" + ); +} + +/// Strips the `` element from an Agile descriptor. +fn without_integrity(info: &[u8]) -> Vec { + let xml = std::str::from_utf8(&info[8..]).unwrap().to_string(); + let start = xml.find("").unwrap() + 2; + let stripped = format!("{}{}", &xml[..start], &xml[end..]); + with_agile_header(&stripped) +} + +#[test] +fn integrity_is_required_and_enforced() { + let plain = payload(300); + let (info, package) = encrypt_agile(&plain, PASSWORD, 1000); + // An attacker can't just delete the integrity element to get the package verified. + assert!(matches!(decrypt_streams(&without_integrity(&info), &package, PASSWORD), + Err(DecryptError::Malformed(m)) if m.contains("dataIntegrity"))); + // An empty or ragged field is damage, not a pass. + for bad in [("encryptedHmacKey", ""), ("encryptedHmacKey", "AA=="), ("encryptedHmacValue", "")] { + let xml = + agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace(&format!("{}=\"{HMAC_STUB}\"", bad.0), &format!("{}=\"{}\"", bad.0, bad.1)); + let r = decrypt_streams(&with_agile_header(&xml), &package, PASSWORD); + assert!(matches!(r, Err(DecryptError::Malformed(_))), "{}=\"{}\"", bad.0, bad.1); + } +} + +#[test] +fn modified_ciphertext_fails_the_integrity_check() { + let plain = payload(300); + let (info, package) = encrypt_agile(&plain, PASSWORD, 1000); + // The correct password proves the verifier can't vouch for a modified package. + for at in [8usize, 100, package.len() - 5] { + let mut tampered = package.clone(); + tampered[at] ^= 0x80; + assert!( + matches!(decrypt_streams(&info, &tampered, PASSWORD), + Err(DecryptError::Malformed(m)) if m.contains("integrity")), + "byte {at}" + ); + } + // And neither can the descriptor: a swapped integrity value is refused. + let xml = String::from_utf8_lossy(&info[8..]).to_string(); + let at = xml.find("encryptedHmacValue=\"").unwrap() + "encryptedHmacValue=\"".len(); + let mut xml: Vec = xml.into_bytes(); + xml[at] ^= b'A' ^ b'B'; + assert!(matches!(decrypt_streams(&with_agile_header(std::str::from_utf8(&xml).unwrap()), &package, PASSWORD), Err(DecryptError::Malformed(_)))); +} + +#[test] +fn integrity_matches_the_stream_not_the_plaintext() { + // The MAC covers the whole EncryptedPackage stream, StreamSize field included: changing the + // stated size alone must fail, while the actual bytes round-trip. + let plain = payload(4200); + let (info, package) = encrypt_agile(&plain, PASSWORD, 1000); + let mut ours = package.clone(); + ours[..8].copy_from_slice(&((plain.len() + 1) as u64).to_le_bytes()); + assert!(matches!(decrypt_streams(&info, &ours, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity"))); + assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain); +} diff --git a/crates/xlsx/src/testing.rs b/crates/xlsx/src/testing.rs index 348c08af5..5729250bb 100644 --- a/crates/xlsx/src/testing.rs +++ b/crates/xlsx/src/testing.rs @@ -9,8 +9,8 @@ use aes::cipher::{BlockCipherEncrypt, KeyInit}; use crate::crypto::{ - BLOCK_KEY, BLOCK_VERIFIER_HASH, BLOCK_VERIFIER_INPUT, F_AES, F_CRYPTOAPI, Hash, PASSWORD_ENCRYPTOR, Params, SEGMENT, derive, iterated_hash, - standard_key, + BLOCK_HMAC_KEY, BLOCK_HMAC_VALUE, BLOCK_KEY, BLOCK_VERIFIER_HASH, BLOCK_VERIFIER_INPUT, F_AES, F_CRYPTOAPI, Hash, PASSWORD_ENCRYPTOR, Params, + SEGMENT, derive, hmac, iterated_hash, standard_key, }; /// The spin count `encrypt_workbook` uses: Excel's default. @@ -21,6 +21,7 @@ const KEY_DATA_SALT: [u8; 16] = [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, const SECRET_KEY: [u8; 32] = [64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95]; const VERIFIER: [u8; 16] = [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47]; +const HMAC_SALT: [u8; 16] = [112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127]; /// `package` (normally an `.xlsx` zip) as Excel stores it with a password: Agile encryption /// (AES-256, SHA-512) inside a compound file. @@ -202,8 +203,9 @@ fn package(size: usize, body: Vec) -> Vec { p } -/// The Agile XML descriptor: AES-256, SHA-512, a password encryptor salted with 0..16. -pub(crate) fn agile_info(password: &str, spin: u32) -> String { +/// The Agile XML descriptor: AES-256, SHA-512, a password encryptor salted with 0..16, and the +/// dataIntegrity pair (`hmac_key_b64`/`hmac_value_b64`) a compliant file carries. +pub(crate) fn agile_info(password: &str, spin: u32, hmac_key_b64: &str, hmac_value_b64: &str) -> String { let key = Params { hash: Hash::Sha512, salt: PASSWORD_SALT.to_vec(), key_len: 32 }; let h = iterated_hash(Hash::Sha512, password, &key.salt, spin); let iv = key.iv(None); @@ -217,9 +219,10 @@ pub(crate) fn agile_info(password: &str, spin: u32) -> String { base64_encode(salt) ) }; + // The dataIntegrity pair ([MS-OFFCRYPTO] 2.3.4.14), encrypted with the package key. format!( - "\r\n", - params(&KEY_DATA_SALT), + "\r\n", + params(KEY_DATA_SALT.as_slice()), params(&PASSWORD_SALT), ) } @@ -231,14 +234,20 @@ pub(crate) fn with_agile_header(xml: &str) -> Vec { info } -/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Agile (AES-256, SHA-512). +/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Agile (AES-256, SHA-512), with the +/// dataIntegrity HMAC over the whole EncryptedPackage stream as Excel writes it. pub(crate) fn encrypt_agile(plain: &[u8], password: &str, spin: u32) -> (Vec, Vec) { let key_data = Params { hash: Hash::Sha512, salt: KEY_DATA_SALT.to_vec(), key_len: 32 }; let mut body = vec![]; for (i, segment) in plain.chunks(SEGMENT).enumerate() { body.extend(encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&(i as u32).to_le_bytes())), segment)); } - (with_agile_header(&agile_info(password, spin)), package(plain.len(), body)) + let stream = package(plain.len(), body); + let (hmac_key_b64, hmac_value_b64) = ( + base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_KEY)), &HMAC_SALT)), + base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_VALUE)), &hmac(Hash::Sha512, &HMAC_SALT, &stream))), + ); + (with_agile_header(&agile_info(password, spin, &hmac_key_b64, &hmac_value_b64)), stream) } /// `EncryptionInfo` and `EncryptedPackage` for `plain`, Standard (AES-128, SHA-1). diff --git a/crates/xlsx/src/tests/encrypted_tests.rs b/crates/xlsx/src/tests/encrypted_tests.rs index 7a1a9bb82..525ed9193 100644 --- a/crates/xlsx/src/tests/encrypted_tests.rs +++ b/crates/xlsx/src/tests/encrypted_tests.rs @@ -56,7 +56,8 @@ fn schemes_no_password_opens_are_named_before_asking_for_one() { let file = |info: &[u8]| compound_file(&[("EncryptionInfo", info), ("EncryptedPackage", &[0; 8])]); assert_eq!(check_encryption(&file(&[3, 0, 2, 0, 4, 0, 0, 0])), unsupported("RC4")); assert_eq!(check_encryption(&file(&[4, 0, 3, 0, 0, 0, 0, 0])), unsupported("Extensible")); - let cert = agile_info("pw", 1000).replace("keyEncryptor/password", "keyEncryptor/certificate"); + let cert = + agile_info("pw", 1000, "AAAAAAAAAAAAAAAAAAAAAA==", "AAAAAAAAAAAAAAAAAAAAAA==").replace("keyEncryptor/password", "keyEncryptor/certificate"); assert_eq!(check_encryption(&file(&with_agile_header(&cert))), unsupported("certificate")); assert!(matches!(check_encryption(&file(&[4, 0])), Err(DecryptError::Malformed(_)))); // Rights management (IRM) keeps an EncryptedPackage but no EncryptionInfo. From 00edfd0690d45839f1d23f8075fcceec02bbeae1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 06:43:10 -0700 Subject: [PATCH 30/56] Fill handle: Fill Days, Fill Weekdays, Fill Months and Fill Years for a date When a fill starts from a date, the right-drag fill menu and the Auto Fill Options button now also offer Fill Days, Fill Weekdays, Fill Months and Fill Years. edit.autoFill takes them as the modes days, weekdays, months and years. A lone date steps by one unit. Two or more dates step by the difference between the first two in that unit, or by one unit when both fall in the same one. Fill Weekdays skips Saturday and Sunday, and a start on a weekend moves to the next weekday first. Fill Months and Fill Years keep the day of the month, clamped to the month's last day. A source that is not all dates fills as a plain drag does. Fills by months or years now keep the time of day of a date with a time. A plain drag of dates a month apart and Series by Month or Year used to drop it. --- ROADMAP.md | 1 + crates/engine/src/cmd/edit.rs | 6 +- crates/engine/src/fill.rs | 151 +++++++++++++++++- crates/engine/src/tests.rs | 80 ++++++++++ crates/ui-egui/src/grid.rs | 19 ++- .../tests/it/fill_handle_right_drag.rs | 31 +++- .../ui-egui/tests/it/fill_options_button.rs | 30 +++- docs/ui-parity.md | 5 +- 8 files changed, 309 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index da06359b5..062164ebc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-24 | Fill handle: from a date, the right-drag menu and the Auto Fill Options button offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years | | 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text | | 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row | | 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index edb0e2d7c..b1f54290d 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -79,7 +79,7 @@ pub fn specs() -> Vec { "AutoFill", [], None, - "{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up)", + "{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend|days|weekdays|months|years}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up; days, weekdays, months, years = Fill Days, Fill Weekdays, Fill Months, Fill Years: dates step by one unit, or by the difference between the first two dates in that unit, and weekdays skip Saturday and Sunday)", has_doc, auto_fill ), @@ -1462,6 +1462,10 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result { "formats" => crate::fill::FillMode::Formats, "values" => crate::fill::FillMode::ValuesOnly, "extend" => crate::fill::FillMode::Extend, + "days" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Day), + "weekdays" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Weekday), + "months" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Month), + "years" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Year), _ => crate::fill::FillMode::Series, }; if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS { diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index 9ff8eac31..a3815fe36 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, days_in_month, serial_from_ymd}; use gridcraft_core::{CellRef, RangeRef, Value}; -use gridcraft_model::{Cell, Formula}; +use gridcraft_model::{Cell, Formula, Workbook}; use gridcraft_numfmt::{FormatKind, NumberFormat}; use crate::Result; @@ -23,15 +23,43 @@ pub enum FillMode { ValuesOnly, /// Fill Series from the right-drag menu: extends like a plain drag, and a lone number counts up. Extend, + /// Fill Days, Fill Weekdays, Fill Months or Fill Years: a source of dates steps by that unit. + /// A lone date steps by one unit; two or more step by the difference between the first two, + /// counted in that unit (one unit when they fall in the same one). A source that isn't dates + /// extends like a plain drag. + Dates(DateUnit), +} + +/// The step of a [`FillMode::Dates`] fill. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DateUnit { + Day, + /// Monday to Friday: Saturday and Sunday are skipped. + Weekday, + Month, + Year, } impl FillMode { /// The modes that continue the source's series rather than copy it. fn extends_series(self) -> bool { - matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly) + matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly | FillMode::Dates(_)) } } +/// Whether a number format shows a calendar date (`YYYY-MM-DD` and `m/d/yyyy h:mm` do; `0 "days"` +/// and `h:mm` don't). +fn is_date_format(fmt: &str) -> bool { + NumberFormat::parse(fmt).kind() == FormatKind::Date +} + +/// Whether the first cell of `src` holds a date, so a fill from it can step by days, weekdays, +/// months or years: a number (not a formula) shown in a date format. +pub fn starts_with_date(wb: &Workbook, sheet: usize, src: RangeRef) -> bool { + let Some(cell) = wb.sheet(sheet).and_then(|sh| sh.cell(src.start)) else { return false }; + cell.formula.is_none() && cell.value.is_number() && is_date_format(wb.styles.get(cell.style).num_fmt.as_str()) +} + /// Built-in custom lists. fn lists() -> Vec> { let short_days: Vec = WEEKDAYS.iter().map(|d| d.get(..3).unwrap_or(d).to_string()).collect(); @@ -102,7 +130,12 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F }; let fmt = |c: &(CellRef, Option)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str(); let pattern = if mode.extends_series() || mode == FillMode::Alternate { - let detected = Pattern::detect(&src_cells, &custom, cx.wb.date_system, src_cells.first().map(fmt).unwrap_or_default()); + let first_fmt = src_cells.first().map(fmt).unwrap_or_default(); + let dates = match mode { + FillMode::Dates(unit) => Pattern::dates(&src_cells, unit, cx.wb.date_system, first_fmt), + _ => None, + }; + let detected = dates.unwrap_or_else(|| Pattern::detect(&src_cells, &custom, cx.wb.date_system, first_fmt)); match (mode, detected) { (FillMode::Alternate, p) | (FillMode::Extend, p @ Pattern::Copy) => p.alternate(&src_cells), (_, p) => p, @@ -130,7 +163,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F writes.push((*t, Some(existing))); continue; } - FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend => { + FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend | FillMode::Dates(_) => { let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style }; if let Some(f) = &cell.formula { if let Some(e) = f.expr() { @@ -204,9 +237,63 @@ enum Pattern { months: i64, sys: gridcraft_core::DateSystem, }, + /// Dates stepping by weekdays, skipping Saturday and Sunday. + Weekdays { + last: f64, + first: f64, + step: i64, + sys: gridcraft_core::DateSystem, + }, } impl Pattern { + /// The pattern of Fill Days, Fill Weekdays, Fill Months or Fill Years, or `None` when the + /// source isn't all dates (numbers, not formulas, under a date format in its first cell). + fn dates(cells: &[(CellRef, Option)], unit: DateUnit, sys: gridcraft_core::DateSystem, fmt: &str) -> Option { + if !is_date_format(fmt) { + return None; + } + let mut nums = Vec::with_capacity(cells.len()); + for (_, cell) in cells { + let cell = cell.as_ref()?; + if cell.formula.is_some() || !cell.value.is_number() { + return None; + } + nums.push(cell.value.as_f64()?); + } + let first = *nums.first()?; + let last = *nums.last()?; + let second = nums.get(1).copied(); + // The difference between the first two dates in the unit, and one unit when they fall in + // the same one. + let at_least_one = |n: i64| if n == 0 { 1 } else { n }; + Some(match unit { + DateUnit::Day => { + let days = second.map_or(1.0, |b| b.floor() - first.floor()); + Pattern::Linear { last, first, step: if days == 0.0 { 1.0 } else { days } } + } + DateUnit::Weekday => { + let step = match second { + Some(b) => at_least_one(weekdays_between(sys, first, b)?), + None => 1, + }; + Pattern::Weekdays { last, first, step, sys } + } + DateUnit::Month | DateUnit::Year => { + let months = match second { + Some(b) => { + let (a, b) = (datetime_from_serial(sys, first)?, datetime_from_serial(sys, b)?); + let years = b.year as i64 - a.year as i64; + if unit == DateUnit::Year { at_least_one(years) * 12 } else { at_least_one(years * 12 + b.month as i64 - a.month as i64) } + } + None if unit == DateUnit::Year => 12, + None => 1, + }; + Pattern::Months { last, first, months, sys } + } + }) + } + fn detect(cells: &[(CellRef, Option)], custom: &[Vec], sys: gridcraft_core::DateSystem, fmt: &str) -> Pattern { let vals: Vec = cells.iter().map(|(_, c)| c.as_ref().map(|c| c.value.clone()).unwrap_or_default()).collect(); if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some())) { @@ -343,20 +430,72 @@ impl Pattern { let s = add_months_clamped(*sys, base, months.checked_mul(k * sign)?)?; Some(Value::number(s)) } + Pattern::Weekdays { last, first, step, sys } => { + let base = if forward { *last } else { *first }; + Some(Value::number(add_weekdays(*sys, base, step.checked_mul(k)?.checked_mul(sign)?)?)) + } } } } +/// Whether `serial` falls on Monday to Friday; `None` outside the supported date range. +fn is_weekday(sys: gridcraft_core::DateSystem, serial: f64) -> Option { + datetime_from_serial(sys, serial).map(|d| d.weekday != 0 && d.weekday != 6) +} + +/// `serial` moved by `n` weekdays (back when `n` is negative), keeping its time of day. A start on +/// a Saturday or Sunday moves to the next (or previous) weekday for the first step. +fn add_weekdays(sys: gridcraft_core::DateSystem, serial: f64, n: i64) -> Option { + if n == 0 { + return Some(serial); + } + let dir = n.signum() as f64; + let mut d = serial; + let next_weekday = |d: &mut f64| -> Option<()> { + loop { + *d += dir; + if is_weekday(sys, *d)? { + return Some(()); + } + } + }; + next_weekday(&mut d)?; + // From a weekday, every seven days hold five weekdays. + let rest = n.unsigned_abs() - 1; + d += dir * (rest / 5 * 7) as f64; + for _ in 0..rest % 5 { + next_weekday(&mut d)?; + } + datetime_from_serial(sys, d)?; + Some(d) +} + +/// How many weekdays lie after `a` up to and including `b` (negative when `b` comes first), +/// counting whole days; `None` outside the supported date range. +fn weekdays_between(sys: gridcraft_core::DateSystem, a: f64, b: f64) -> Option { + let (lo, hi, sign) = if b >= a { (a.floor(), b.floor(), 1) } else { (b.floor(), a.floor(), -1) }; + let days = (hi - lo) as i64; + let mut count = days / 7 * 5; + let mut d = lo + (days / 7 * 7) as f64; + for _ in 0..days % 7 { + d += 1.0; + if is_weekday(sys, d)? { + count += 1; + } + } + Some(count * sign) +} + /// `serial` shifted by `months` calendar months, keeping the day of month but clamping it to the /// target month's last day (Jan 31 + 1 month = Feb 28/29). Always measured from the original date, -/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30. +/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30. The time of day is kept. fn add_months_clamped(sys: gridcraft_core::DateSystem, serial: f64, months: i64) -> Option { let d = datetime_from_serial(sys, serial)?; let total = (d.year as i64).checked_mul(12)?.checked_add(d.month as i64 - 1)?.checked_add(months)?; let year = total.div_euclid(12); let month = total.rem_euclid(12) + 1; let last = days_in_month(i32::try_from(year).ok()?, month as u32); - serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64)) + serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64)).map(|day| day + serial - serial.floor()) } fn round15(x: f64) -> f64 { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f4c47bb62..a4b0bd4d2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -941,6 +941,86 @@ fn fill_without_formatting_extends_the_series_and_keeps_the_target_formats() { assert_eq!([v(&s, "B3"), v(&s, "B4")], [Value::from("Product 2"), Value::from("On backorder")]); } +#[test] +fn fill_days_weekdays_months_and_years_step_a_date_by_that_unit() { + let mut s = s(); + let serials = |s: &Session, cells: &[&str]| cells.iter().map(|c| v(s, c)).collect::>(); + let n = |xs: &[f64]| xs.iter().map(|x| Value::Number(*x)).collect::>(); + // Friday 1/30/2026 is serial 46052. + s.execute("cell.set", json!({"cell": "A1", "input": "1/30/2026"})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(46052.0), "setup: a date"); + s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "days"})).unwrap(); + assert_eq!(serials(&s, &["A2", "A3", "A4"]), n(&[46053.0, 46054.0, 46055.0])); + // Fill Weekdays skips Saturday 1/31 and Sunday 2/1. + s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "weekdays"})).unwrap(); + assert_eq!(serials(&s, &["A2", "A3", "A4"]), n(&[46055.0, 46056.0, 46057.0])); + // From Saturday 1/31, the first weekday is Monday 2/2. + s.execute("cell.set", json!({"cell": "B1", "input": "1/31/2026"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3", "mode": "weekdays"})).unwrap(); + assert_eq!(serials(&s, &["B2", "B3"]), n(&[46055.0, 46056.0])); + // Fill Months keeps the day, clamped to the month's last day: Feb 28, Mar 31, Apr 30. + s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B4", "mode": "months"})).unwrap(); + assert_eq!(serials(&s, &["B2", "B3", "B4"]), n(&[46081.0, 46112.0, 46142.0])); + s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3", "mode": "years"})).unwrap(); + assert_eq!(serials(&s, &["B2", "B3"]), n(&[46418.0, 46783.0])); + // Two dates step by their difference in the unit: 1/15 and 3/15 by two months. + s.execute("cell.set", json!({"cell": "C1", "input": "1/15/2026"})).unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "3/15/2026"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "C1:C2", "target": "C1:C4", "mode": "months"})).unwrap(); + assert_eq!(serials(&s, &["C3", "C4"]), n(&[46157.0, 46218.0])); + // Monday 2/2 and Wednesday 2/4 step by two weekdays: Friday 2/6, Tuesday 2/10. + s.execute("cell.set", json!({"cell": "D1", "input": "2/2/2026"})).unwrap(); + s.execute("cell.set", json!({"cell": "D2", "input": "2/4/2026"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "D1:D2", "target": "D1:D4", "mode": "weekdays"})).unwrap(); + assert_eq!(serials(&s, &["D3", "D4"]), n(&[46059.0, 46063.0])); + // Upward from Monday 2/2: Friday 1/30, Thursday 1/29. + s.execute("cell.set", json!({"cell": "E5", "input": "2/2/2026"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "E5", "target": "E3:E5", "mode": "weekdays"})).unwrap(); + assert_eq!(serials(&s, &["E4", "E3"]), n(&[46052.0, 46051.0])); + // A number that isn't a date fills as a plain drag does: a lone number copies. + s.execute("cell.set", json!({"cell": "F1", "input": "7"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "F1", "target": "F1:F2", "mode": "days"})).unwrap(); + assert_eq!(v(&s, "F2"), Value::Number(7.0)); + // An upper-case date format is a date: 1/31/2026 as YYYY-MM-DD fills by months. + s.execute("cell.set", json!({"cell": "G1", "input": "1/31/2026"})).unwrap(); + s.execute("home.numberFormat", json!({"range": "G1", "code": "YYYY-MM-DD"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "G1", "target": "G1:G2", "mode": "months"})).unwrap(); + assert_eq!(v(&s, "G2"), Value::Number(46081.0)); + // A date with a time keeps the time: Fill Months and Fill Weekdays, a plain drag of two dates + // a month apart, and the Series dialog by months. + s.execute("cell.set", json!({"cell": "H1", "input": "1/31/2026 12:00"})).unwrap(); + assert_eq!(v(&s, "H1"), Value::Number(46053.5), "setup: a date and time"); + s.execute("edit.autoFill", json!({"source": "H1", "target": "H1:H2", "mode": "months"})).unwrap(); + assert_eq!(v(&s, "H2"), Value::Number(46081.5)); + s.execute("edit.autoFill", json!({"source": "H1", "target": "H1:H2", "mode": "weekdays"})).unwrap(); + assert_eq!(v(&s, "H2"), Value::Number(46055.5)); + s.execute("cell.set", json!({"cell": "I1", "input": "1/15/2026 6:00"})).unwrap(); + s.execute("cell.set", json!({"cell": "I2", "input": "2/15/2026 6:00"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "I1:I2", "target": "I1:I3"})).unwrap(); + assert_eq!(v(&s, "I3"), Value::Number(46096.25)); + s.execute("cell.set", json!({"cell": "J1", "input": "1/31/2026 12:00"})).unwrap(); + s.execute("edit.fillSeries", json!({"range": "J1:J2", "type": "date", "dateUnit": "month"})).unwrap(); + assert_eq!(v(&s, "J2"), Value::Number(46081.5)); +} + +#[test] +fn only_a_number_in_a_date_format_offers_the_date_fills() { + let mut s = s(); + let dates = |s: &Session, a: &str| crate::fill::starts_with_date(&s.doc().unwrap().wb, 0, gridcraft_core::RangeRef::parse(a).unwrap()); + s.execute("cell.set", json!({"cell": "A1", "input": "1/31/2026"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "7"})).unwrap(); + s.execute("home.numberFormat", json!({"range": "A2", "code": "0 \"days\""})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": "46053"})).unwrap(); + s.execute("home.numberFormat", json!({"range": "A3", "code": "YYYY-MM-DD"})).unwrap(); + s.execute("cell.set", json!({"cell": "A4", "input": "=A1"})).unwrap(); + s.execute("cell.set", json!({"cell": "A5", "input": "13:30"})).unwrap(); + assert!(dates(&s, "A1") && dates(&s, "A3"), "m/d/yyyy and YYYY-MM-DD are dates"); + assert!(!dates(&s, "A2"), "quoted text in a number format is not a date field"); + assert!(!dates(&s, "A4"), "a formula is not offered"); + assert!(!dates(&s, "A5"), "a time is not a date"); + assert!(!dates(&s, "A6"), "an empty cell is not a date"); +} + #[test] fn insert_delete_cells_adjust() { let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone(); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index b485104b1..d09e6d14a 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -3413,6 +3413,16 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { const FILL_MODES: [(&str, &str); 4] = [("Copy Cells", "copy"), ("Fill Series", "extend"), ("Fill Formatting Only", "formats"), ("Fill Without Formatting", "values")]; +/// The ways to fill from a date, which both menus add after [`FILL_MODES`] when the source starts +/// with one. +const DATE_FILL_MODES: [(&str, &str); 4] = [("Fill Days", "days"), ("Fill Weekdays", "weekdays"), ("Fill Months", "months"), ("Fill Years", "years")]; + +/// The fill modes the menus list for a fill from `src` on the active sheet. +fn fill_modes(app: &SheetApp, src: RangeRef) -> Vec<(&'static str, &'static str)> { + let dates = app.session.active().is_some_and(|d| gridcraft_engine::fill::starts_with_date(&d.wb, d.wb.active_sheet, src)); + FILL_MODES.into_iter().chain(DATE_FILL_MODES.into_iter().filter(|_| dates)).collect() +} + /// Remembers the fill just made so the Auto Fill Options button can offer the other ways. fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) { app.grid.fill_tag = @@ -3421,7 +3431,8 @@ fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) { /// The Auto Fill Options button below the bottom-right corner of cells a left-drag of the fill /// handle just filled. Its menu redoes that fill as Copy Cells, Fill Series, Fill Formatting Only -/// or Fill Without Formatting: it undoes the drag's fill, then runs `edit.autoFill` in that mode. +/// or Fill Without Formatting, and from a date also as Fill Days, Fill Weekdays, Fill Months or +/// Fill Years: it undoes the drag's fill, then runs `edit.autoFill` in that mode. /// The button goes away as soon as anything else changes the workbook or the selection moves. fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { let Some(tag) = app.grid.fill_tag.clone() else { return }; @@ -3464,9 +3475,10 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet Stroke::NONE, )); let mut chosen = None; + let modes = fill_modes(app, tag.src); egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(190.0); - for (label, mode) in FILL_MODES { + for (label, mode) in modes { if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { chosen = Some(mode); ui.close(); @@ -3493,10 +3505,11 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { let Some((src, target, at)) = app.grid.fill_menu else { return }; let mut close = false; + let mut items = fill_modes(app, src); + items.extend([("-", ""), ("Series…", "ui:series")]); let response = egui::Area::new(egui::Id::new("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(190.0); - let items = [FILL_MODES[0], FILL_MODES[1], FILL_MODES[2], FILL_MODES[3], ("-", ""), ("Series…", "ui:series")]; let lang = crate::i18n::current(ui.ctx()); for (label, mode) in items { if label == "-" { diff --git a/crates/ui-egui/tests/it/fill_handle_right_drag.rs b/crates/ui-egui/tests/it/fill_handle_right_drag.rs index 9562c1d63..17599ff53 100644 --- a/crates/ui-egui/tests/it/fill_handle_right_drag.rs +++ b/crates/ui-egui/tests/it/fill_handle_right_drag.rs @@ -1,5 +1,6 @@ //! Dragging the fill handle with the right button fills nothing on release: it opens Excel's fill -//! menu (Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…). +//! menu (Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…, and from +//! a date Fill Days, Fill Weekdays, Fill Months and Fill Years). use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2}; use egui_kittest::kittest::Queryable; @@ -203,6 +204,34 @@ fn series_opens_the_series_dialog_on_the_dragged_cells_in_their_direction() { assert_eq!(values(&h, &["C1", "D1", "E1"]), [n(2.0), n(3.0), n(4.0)]); } +#[test] +fn a_date_adds_fill_days_weekdays_months_and_years() { + let mut h = harness(|s| { + // Friday 1/30/2026 is serial 46052. + s.execute("cell.set", json!({"cell": "B1", "input": "1/30/2026"})).unwrap(); + s.execute("selection.set", json!({"cell": "B1"})).unwrap(); + }); + drag_handle(&mut h, "B1", "B3", PointerButton::Secondary); + for label in ["Fill Days", "Fill Months", "Fill Years"] { + assert!(h.query_by_label(label).is_some(), "{label}"); + } + h.get_by_label("Fill Weekdays").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B2", "B3"]), [n(46055.0), n(46056.0)], "Monday 2/2 and Tuesday 2/3"); + assert!(h.state().grid.fill_menu.is_none()); +} + +#[test] +fn a_number_gets_no_date_fills() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Secondary); + assert!(h.query_by_label("Fill Series").is_some()); + for label in ["Fill Days", "Fill Weekdays", "Fill Months", "Fill Years"] { + assert!(h.query_by_label(label).is_none(), "{label}"); + } +} + #[test] fn escape_mid_right_drag_cancels_it() { let mut h = harness(|s| { diff --git a/crates/ui-egui/tests/it/fill_options_button.rs b/crates/ui-egui/tests/it/fill_options_button.rs index d735f846a..1e06df8b5 100644 --- a/crates/ui-egui/tests/it/fill_options_button.rs +++ b/crates/ui-egui/tests/it/fill_options_button.rs @@ -1,5 +1,6 @@ //! After a left-drag of the fill handle, the Auto Fill Options button redoes the fill as Copy Cells, -//! Fill Series, Fill Formatting Only or Fill Without Formatting (#1403). +//! Fill Series, Fill Formatting Only or Fill Without Formatting (#1403), and from a date as Fill +//! Days, Fill Weekdays, Fill Months or Fill Years. use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; use egui_kittest::kittest::Queryable; @@ -139,6 +140,33 @@ fn a_lone_number_copies_on_drag_and_counts_up_with_fill_series() { assert_eq!(values(&h, &["B2", "B3", "B4"]), [n(6.0), n(7.0), n(8.0)]); } +#[test] +fn fill_months_redoes_a_date_drag_by_months() { + let mut h = harness(|s| { + // 1/31/2026 is serial 46053. + s.execute("cell.set", json!({"cell": "B1", "input": "1/31/2026"})).unwrap(); + s.execute("selection.set", json!({"cell": "B1"})).unwrap(); + }); + drag_handle(&mut h, "B1", "B3", PointerButton::Primary); + assert_eq!(values(&h, &["B2", "B3"]), [n(46054.0), n(46055.0)], "a plain drag steps by days"); + h.get_by_label("Auto Fill Options").click(); + h.run_steps(3); + h.get_by_label("Fill Months").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B2", "B3"]), [n(46081.0), n(46112.0)], "Feb 28 and Mar 31"); +} + +#[test] +fn a_number_drag_offers_no_date_fills() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + h.get_by_label("Auto Fill Options").click(); + h.run_steps(3); + assert!(h.query_by_label("Copy Cells").is_some()); + assert!(h.query_by_label("Fill Days").is_none()); +} + fn formatted(s: &mut Session) { s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [null], [9]]})).unwrap(); s.execute("home.bold", json!({"range": "B1:B2", "on": true})).unwrap(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5d5f1efee..174ba0ebe 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-23 · **Change:** minor (New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Fill Days, Fill Weekdays, Fill Months and Fill Years in the fill-handle menus for a date; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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; 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 @@ -50,7 +50,7 @@ this logic, with one undo step. | Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) | | Cursor while dragging cells, headers, freeze handles and objects | stable for the active drag (#936); Draw tools keep their crosshair; header arrows and a custom cell cursor remain unimplemented | | Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet | -| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series; month patterns use the workbook's 1900 or 1904 date system); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; a left-drag leaves an Auto Fill Options button with the first four (gone once the workbook changes or the selection moves); Fill Days/Weekdays/Months/Years, Linear/Growth Trend and Flash Fill missing | +| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series; month patterns use the workbook's 1900 or 1904 date system); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, and from a date Fill Days, Fill Weekdays, Fill Months and Fill Years, then Series…; a left-drag leaves an Auto Fill Options button with the same fill choices (gone once the workbook changes or the selection moves); Linear/Growth Trend and Flash Fill missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | | Point mode (click/drag/arrow references), reference colouring, F4 | done; Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. | @@ -181,6 +181,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) | | 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is | | 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) | +| 2026-10-24 | minor | When the fill starts from a date, the right-drag fill menu and the Auto Fill Options button also offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years (`edit.autoFill` `mode: days|weekdays|months|years`). A lone date steps by one unit; two or more dates step by the difference between the first two in that unit, or one unit when they fall in the same one. Fill Months and Fill Years keep the day of the month, clamped to the month's last day. Every fill by months or years (these two, a drag of dates a month apart, and Series by Month or Year) keeps the time of day of a date with a time, which used to be dropped | | 2026-10-22 | minor | Show Outline Symbols (`view.showOutlineSymbols`, Ctrl+8): a sheet can hide the outline's level buttons and brackets while keeping its outline, as Excel saves with `showOutlineSymbols="0"` (read and written); the grid then gives the bands no room | | 2026-10-22 | minor | A number in a repeat-fill format (`*`, as in the Accounting format) that is too wide for its column shows #### like any other number, instead of the fill rebuilding the value so its leading digits were cut off (2,509.00 CHF drawn as "509.00 CHF") | | 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 | From 2ae2329090828699221c9e88986b8301a8bdfbcb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 07:26:48 -0700 Subject: [PATCH 31/56] Docs: drop the stale missing-keys row that still listed Ctrl+6 Ctrl+6 hides and shows objects, and the row above already lists the other missing keys (Ctrl+Q, Ctrl+A after a function name, Ctrl+Alt+P). --- docs/ui-parity.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 17538e580..bfc45b975 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -137,7 +137,6 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | Ctrl+Q, Ctrl+A after a function name, Ctrl+Alt+P | same | Quick Analysis, Function Arguments, tooltips | missing | | Ctrl+Shift+A after a function name | Control+Shift+A | argument names: `=SUMIF(` becomes `=SUMIF(range,criteria,sum_range)`, the `(` added when missing; one Undo takes them back | **added** | | Ctrl+Shift+U | Control+Shift+U | expand or collapse the formula bar (`view.expandFormulaBar`), on the grid and while editing; on Windows and Linux it no longer underlines | **added** | -| Ctrl+6, Ctrl+Q, Ctrl+A after a function name, Ctrl+Alt+P | same | objects, Quick Analysis, Function Arguments, tooltips | missing | | Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F8 / F11 / F12 | same | group sheets, shape cycling, full screen, window keys, macros, VBA, Power Query | missing | | — | Cmd+= (calculate), Cmd+T (references while editing; F4 works), Shift+F7, Cmd+Shift+L | Mac-only extras | missing (Cmd+= is egui's zoom key; no thesaurus or Modify Cell Style) | From 30126a709abd0d79b28ac4a7783d61732f542791 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 07:37:50 -0700 Subject: [PATCH 32/56] PivotTables: Enable show details turns Show Details off for a PivotTable A PivotTable has an Enable show details option, set with the new pivot.options command ({pivot?, enableShowDetails?: bool}). While it is off, pivot.showDetails and double-clicking a value cell list no records: the double-click shows a message and does not open the cell for editing. Labels and headers still edit. Changing the option is one undo step and does not refresh the report. pivot.fields reports the setting. XLSX files keep it: an off option is written as enableDrill="0" on the pivotTableDefinition, and enableDrill is read on open (on when absent). --- ROADMAP.md | 1 + crates/engine/src/cmd/pivot.rs | 64 +++++++++++++++++++ crates/model/src/features.rs | 4 ++ crates/ui-egui/tests/it/pivot_show_details.rs | 17 ++++- crates/xlsx/src/pivot.rs | 4 ++ crates/xlsx/src/tests/pivot_tests.rs | 23 ++++++- docs/gaps.md | 7 +- 7 files changed, 116 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index da06359b5..7f3a2253e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-24 | PivotTables: Enable show details (`pivot.options` `enableShowDetails`); turned off, Show Details and double-clicking a value cell list no records, and XLSX files keep the setting (`enableDrill`) | | 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text | | 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row | | 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) | diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 229ff22b6..5277ff98a 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -188,6 +188,15 @@ pub fn specs() -> Vec { has_doc, show_details ), + cmd!( + "pivot.options", + "PivotTable Options", + ["PivotTable Analyze", "PivotTable"], + None, + "{pivot?, enableShowDetails?: bool (Data › Enable show details: off, Show Details and double-clicking a value cell list no records)} → {enableShowDetails}", + has_doc, + options + ), cmd!( "pivot.delete", "Delete PivotTable", @@ -757,6 +766,9 @@ fn show_details(s: &mut Session, p: &Json) -> Result { let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)).map_err(other)?; let (src, records) = found.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; + if !pt.enable_show_details { + return Err(other(SHOW_DETAILS_OFF)); + } if d.wb.protected_structure { return Err(other("The workbook is protected and cannot be changed.")); } @@ -824,6 +836,32 @@ fn show_details(s: &mut Session, p: &Json) -> Result { Ok(res) } +/// The message Show Details gives while Enable show details is off. +const SHOW_DETAILS_OFF: &str = "Show Details is turned off for this PivotTable. Turn on Enable show details in PivotTable Options."; + +/// PivotTable Options: Enable show details (Data). Changes only the option; the report is not +/// refreshed. Without params it returns the current setting. +fn options(s: &mut Session, p: &Json) -> Result { + let (si, pi) = find_pivot(s, p)?; + let enable = bool_param(p, "enableShowDetails"); + if p.get("enableShowDetails").is_some_and(|v| !v.is_null()) && enable.is_none() { + return Err(bad("pivot.options", "`enableShowDetails` must be true or false")); + } + let current = |s: &Session| -> Result { + let pt = s.doc()?.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?; + Ok(json!({"enableShowDetails": pt.enable_show_details})) + }; + let Some(on) = enable else { return current(s) }; + if current(s)?["enableShowDetails"] == json!(on) { + return current(s); + } + edit(s, |cx| { + let pt = cx.sheet_mut(si)?.pivots.get_mut(pi).ok_or_else(|| other("There's no such PivotTable."))?; + pt.enable_show_details = on; + Ok(json!({"enableShowDetails": on})) + }) +} + fn summarize_table(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let name = match str_param(p, "table") { @@ -861,6 +899,7 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json { "calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::>(), "valuesOnRows": pt.values_on_rows, "valuesPosition": pt.values_position, + "enableShowDetails": pt.enable_show_details, }) } @@ -2580,4 +2619,29 @@ mod tests { } assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); } + + #[test] + fn enable_show_details_off_stops_show_details() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + assert_eq!(s.execute("pivot.options", json!({})).unwrap()["enableShowDetails"], true); + s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); + assert!(!pt(&s).enable_show_details); + assert_eq!(s.execute("pivot.fields", json!({})).unwrap()["enableShowDetails"], false); + let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); + assert!(e.contains("Enable show details"), "{e}"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + // A label still reports that it is not a value cell. + let e = s.execute("pivot.showDetails", json!({"cell": "A5"})).unwrap_err().to_string(); + assert!(!e.contains("Enable show details"), "{e}"); + // Setting the same value again adds no undo step. + let after = s.doc().unwrap().revision; + s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); + assert_eq!(s.doc().unwrap().revision, after); + assert!(s.execute("pivot.options", json!({"enableShowDetails": "no"})).is_err()); + // One undo turns the option back on. + s.execute("edit.undo", json!({})).unwrap(); + assert!(pt(&s).enable_show_details); + assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap()["records"], 2); + } } diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index aa250b9b0..e7114b802 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -1044,6 +1044,9 @@ pub struct PivotTable { pub values: Vec, pub filters: Vec, pub layout: PivotLayout, + /// PivotTable Options › Data › Enable show details: Show Details lists a value cell's + /// source records only while this is on. + pub enable_show_details: bool, /// Grand total for each row (the "Grand Total" column), like Excel's "On for Rows". pub grand_totals_rows: bool, /// Grand total for each column (the "Grand Total" row), like Excel's "On for Columns". @@ -1087,6 +1090,7 @@ impl Default for PivotTable { values: vec![], filters: vec![], layout: PivotLayout::Compact, + enable_show_details: true, grand_totals_rows: true, grand_totals_cols: true, subtotals: true, diff --git a/crates/ui-egui/tests/it/pivot_show_details.rs b/crates/ui-egui/tests/it/pivot_show_details.rs index 9f11b29eb..6300a719f 100644 --- a/crates/ui-egui/tests/it/pivot_show_details.rs +++ b/crates/ui-egui/tests/it/pivot_show_details.rs @@ -1,5 +1,6 @@ //! Double-clicking a PivotTable's value cell shows its source records on a new sheet (Excel's -//! Show Details); a label of the report still opens the cell for editing. +//! Show Details) unless its Enable show details option is off; a label of the report still opens +//! the cell for editing. use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; use gridcraft_engine::Session; @@ -104,3 +105,17 @@ fn double_click_in_an_out_of_date_report_asks_for_a_refresh() { assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("out of date")), "{:?}", app.message); assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1); } + +#[test] +fn double_click_with_enable_show_details_off_says_why_and_does_not_edit() { + let mut h = harness(); + h.state_mut().session.execute("pivot.options", json!({"pivot": "PivotTable1", "enableShowDetails": false})).unwrap(); + h.run_steps(2); + let pos = center(&h, "E4"); + double_click(&mut h, pos); + + let app = h.state(); + assert!(app.editor.is_none(), "the value cell must not open for editing"); + assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("Enable show details")), "{:?}", app.message); + assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1); +} diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index f75523432..08f89335b 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -818,6 +818,9 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn if !pt.show_headers { s.push_str(" showHeaders=\"0\""); } + if !pt.enable_show_details { + s.push_str(" enableDrill=\"0\""); + } // `rowGrandTotals` is the grand total row below the report (Excel's "On for Columns"), // `colGrandTotals` the grand total column at its right ("On for Rows"). if !pt.grand_totals_cols { @@ -1282,6 +1285,7 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option Workbook { wb.names.push(DefinedName { name: "SalesData".into(), scope: None, formula: "Data!$A$1:$F$9".into(), comment: String::new(), hidden: false }); let mut report = Sheet::new("Report"); - // Pivot 1: tabular, page filter, custom and built-in value formats, collapsed item. + // Pivot 1: tabular, page filter, custom and built-in value formats, collapsed item, Enable + // show details off. let p1 = PivotTable { id: 1, name: "Sales by Region".into(), @@ -106,6 +107,7 @@ fn workbook() -> Workbook { ], filters: vec![PivotFilter { source_col: "Channel".into(), selected: Some(vec!["Online".into()]) }], layout: PivotLayout::Tabular, + enable_show_details: false, grand_totals_rows: false, grand_totals_cols: true, subtotals: false, @@ -165,6 +167,7 @@ fn workbook() -> Workbook { PivotFilter { source_col: "Product".into(), selected: Some(vec!["A".into(), "B".into()]) }, ], layout: PivotLayout::Outline, + enable_show_details: true, grand_totals_rows: true, grand_totals_cols: false, subtotals: true, @@ -663,6 +666,24 @@ fn grand_totals_read_in_excel_direction() { } } +// `enableDrill="0"` is PivotTable Options › Data › Enable show details turned off; without the +// attribute the option is on. +#[test] +fn enable_show_details_reads_and_writes_enable_drill() { + let table = |attr: &str| { + format!( + r#""# + ) + }; + for (attr, on) in [("enableDrill=\"0\"", false), ("enableDrill=\"1\"", true), ("", true)] { + let (wb, _) = read_xlsx(&with_pivot(&table(attr), Some(GOOD_CACHE))).unwrap(); + assert_eq!(wb.sheet(0).unwrap().pivots[0].enable_show_details, on, "{attr}"); + let parts = unzip(&write_xlsx(&wb).unwrap()); + let written = String::from_utf8(parts.get("xl/pivotTables/pivotTable1.xml").unwrap().clone()).unwrap(); + assert_eq!(written.contains(" enableDrill=\"0\""), !on, "{attr}: {written}"); + } +} + #[test] fn data_on_rows_with_one_value_field_reads_without_warning() { let table = r#""#; diff --git a/docs/gaps.md b/docs/gaps.md index b07727991..761a0e10d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (§8: PivotTable Show Details) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (§8: PivotTable Enable show details; earlier: PivotTable Show Details) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -85,7 +85,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`, `insert.pivotChart`, `table.insertSlicer`; calculated items and GETPIVOTDATA missing (calculated fields and values on rows done). Show Details (double-click a value, subtotal or grand total cell) lists - its source records on a new sheet; the "Enable show details" option isn't modelled yet. + its source records on a new sheet. The "Enable show details" option (`pivot.options` + `enableShowDetails`, saved as `enableDrill`) turns it off; it has no PivotTable Options dialog + yet (command only), and the right-click Show Details menu item is missing. - **Impact:** pivots are the analyst's main tool. - **Estimate:** 20–30 h for beta (25–40 h full). @@ -179,6 +181,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | §8: PivotTable Enable show details: `pivot.options` `enableShowDetails` turns Show Details off for a PivotTable, read and written in XLSX as `enableDrill` | | 2026-10-23 | minor | §8: PivotTable Show Details: double-clicking a value cell lists its source records on a new sheet (#2364) | | 2026-10-20 | minor | §11: the Japanese catalog covers the dialogs, messages and menus — 943 keys (~43%), matching the pt-BR coverage; a test keeps the two catalogs in step | | 2026-10-17 | minor | §6: the five reports it tracked are closed and their PRs merged (#57, #133, #139, #140, #134; PRs #148, #155, #156, #157); the entry stays open for §15's oracle suite | From 3fa6cb470b408f3613f7412d036f0445d551b2b6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 08:47:51 -0700 Subject: [PATCH 33/56] PivotTables: right-clicking a value cell offers Show Details in the cell menu The cell menu of a PivotTable's value, subtotal or grand total cell now has a Show Details item after Sort Z to A. It runs pivot.showDetails on the active cell, which lists the cell's source records on a new sheet, as double-clicking the cell does. Labels and headers of a current report, and cells outside a report, do not offer it. In a report that is out of date every cell of the report offers the item, and running it shows the message that asks for a refresh. Whether the menu offers the item is cached per document, revision, sheet and active cell. --- ROADMAP.md | 1 + crates/ui-egui/src/grid.rs | 32 ++++++ crates/ui-egui/tests/it/main.rs | 1 + .../tests/it/pivot_show_details_menu.rs | 106 ++++++++++++++++++ docs/ui-parity.md | 5 +- 5 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/it/pivot_show_details_menu.rs diff --git a/ROADMAP.md b/ROADMAP.md index da06359b5..e0a3c218c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-24 | PivotTables: right-clicking a value, subtotal or grand total cell offers Show Details in the cell menu, which lists the cell's source records on a new sheet as double-clicking does | | 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text | | 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row | | 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) | diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index b485104b1..abe8bcf42 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -163,6 +163,9 @@ pub struct GridState { /// The PivotTable Fields pane's `pivot.fields` answer, cached per (doc uid, revision, pivot): /// it scans the whole source list, far too slow to repeat every frame (#2113). pub pivot_fields: Option<((u64, u64, String), serde_json::Value)>, + /// Whether the cell menu offers Show Details, cached per (doc uid, revision, sheet, active + /// cell): finding out computes the whole PivotTable report. + pub show_details_item: Option<((u64, u64, usize, CellRef), bool)>, pub header_menu: Option<(Pos2, bool)>, /// Page Layout view pagination, cached per (doc uid, revision, sheet, rows, columns). pub paper: Option, @@ -3353,6 +3356,12 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { if let Some(item) = insert_copied { items.insert(6, item); } + if show_details_offered(app) + && let Some(i) = items.iter().position(|(_, id)| *id == "data.sortDescending") + { + items.insert(i + 1, ("-", "")); + items.insert(i + 2, ("Show Details", "pivot.showDetails")); + } let lang = crate::i18n::current(ui.ctx()); for (label, id) in items { if label == "-" { @@ -3409,6 +3418,29 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { } } +/// Whether the cell menu offers Show Details (Excel's right-click › Show Details): the active cell +/// is a value, subtotal or grand total cell of a PivotTable, or a cell of a report that is out of +/// date, where running the command says so. Labels and headers of a current report don't offer it. +fn show_details_offered(app: &mut SheetApp) -> bool { + let Some(d) = app.session.active() else { return false }; + let (si, at) = (d.wb.active_sheet, d.selection.active); + let key = (d.uid, d.revision, si, at); + if let Some((k, on)) = app.grid.show_details_item + && k == key + { + return on; + } + let on = d.wb.active().and_then(|sh| sh.pivots.iter().find(|pt| pt.last_range.is_some_and(|r| r.contains(at)))).is_some_and(|pt| { + let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); + !matches!( + gridcraft_engine::pivot::detail_records(&d.wb, pt, si, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)), + Ok(None) + ) + }); + app.grid.show_details_item = Some((key, on)); + on +} + /// The ways to fill a dragged range, as the fill menu and the Auto Fill Options button list them. const FILL_MODES: [(&str, &str); 4] = [("Copy Cells", "copy"), ("Fill Series", "extend"), ("Fill Formatting Only", "formats"), ("Fill Without Formatting", "values")]; diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 3a9769c1c..4a70a8f7a 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -62,6 +62,7 @@ mod page_setup_margins; mod paste_special_dialog; mod pivot_pane; mod pivot_show_details; +mod pivot_show_details_menu; mod popup_dismissal; mod protect_password; mod right_to_left; diff --git a/crates/ui-egui/tests/it/pivot_show_details_menu.rs b/crates/ui-egui/tests/it/pivot_show_details_menu.rs new file mode 100644 index 000000000..02527d8a3 --- /dev/null +++ b/crates/ui-egui/tests/it/pivot_show_details_menu.rs @@ -0,0 +1,106 @@ +//! Right-clicking a PivotTable's value cell offers Show Details in the cell menu (Excel's +//! right-click › Show Details); labels of the report and ordinary cells don't offer it. + +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 100], ["West", 80], ["East", 30]]})).unwrap(); + // Report at D3: D4 East, E4 its Sum of Sales, E6 the Grand Total. + session + .execute("insert.pivotTable", json!({"source": "Sheet1!A1:B4", "destination": "Sheet1!D3", "rows": ["Region"], "values": ["Sales"]})) + .unwrap(); + session.execute("selection.set", json!({"cell": "A1"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn center(h: &egui_kittest::Harness<'_, SheetApp>, cell: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).center() +} + +fn right_click(h: &mut egui_kittest::Harness<'_, SheetApp>, cell: &str) { + let pos = center(h, cell); + h.input_mut().events.push(Event::PointerMoved(pos)); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(2); + } + assert!(h.state().grid.context_menu.is_some(), "the cell menu opens on {cell}"); +} + +#[test] +fn show_details_in_the_cell_menu_lists_a_value_cells_records_on_a_new_sheet() { + let mut h = harness(); + right_click(&mut h, "E4"); + h.get_by_label("Show Details").click(); + h.run_steps(2); + + let app = h.state(); + assert!(app.grid.context_menu.is_none(), "the menu closes"); + let wb = &app.session.active().unwrap().wb; + assert_eq!(wb.sheets.len(), 2); + let sheet = wb.active().unwrap(); + let text = |a: &str| sheet.value(CellRef::parse(a).unwrap()).display(); + assert_eq!( + [text("A1"), text("B1"), text("A2"), text("B2"), text("A3"), text("B3"), text("A4")], + ["Region", "Sales", "East", "100", "East", "30", ""] + ); +} + +#[test] +fn show_details_in_the_cell_menu_on_the_grand_total_takes_every_record() { + let mut h = harness(); + right_click(&mut h, "E6"); + h.get_by_label("Show Details").click(); + h.run_steps(2); + + let wb = &h.state().session.active().unwrap().wb; + assert_eq!(wb.sheets.len(), 2); + let sheet = wb.active().unwrap(); + assert_eq!(sheet.value(CellRef::parse("B4").unwrap()).display(), "30"); +} + +#[test] +fn labels_of_the_report_and_ordinary_cells_have_no_show_details_item() { + let mut h = harness(); + for cell in ["D4", "D3", "A2"] { + right_click(&mut h, cell); + assert!(h.query_by_label("Sort A to Z").is_some(), "the cell menu is up on {cell}"); + assert!(h.query_by_label("Show Details").is_none(), "{cell} offers no Show Details"); + let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + h.input_mut().events.push(escape); + h.run_steps(2); + } +} + +#[test] +fn show_details_in_an_out_of_date_report_asks_for_a_refresh() { + let mut h = harness(); + // West goes away without a refresh: the report on the sheet is no longer what the source gives. + h.state_mut().session.execute("range.setValues", json!({"range": "Sheet1!A3", "values": [["East"]]})).unwrap(); + h.run_steps(2); + right_click(&mut h, "E4"); + h.get_by_label("Show Details").click(); + h.run_steps(2); + + let app = h.state(); + assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("out of date")), "{:?}", app.message); + assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5d5f1efee..d7715c3aa 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-23 · **Change:** minor (New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Show Details in the cell menu of a PivotTable value cell; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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; 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 @@ -64,7 +64,7 @@ this logic, with one undo step. | Freeze panes, split panes | freeze done; split to verify | | A Table's header names and filter buttons in the column headings once its header row scrolls away | done (#1423): with the active cell in the Table, as in Excel; the button opens the column's filter menu, the rest of the heading still selects the column. Plain AutoFilter ranges keep their letters (Excel does the same) | | Copy as HTML table for other apps | done (#36, 10-10) | -| Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | +| Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Show Details on a PivotTable's value, subtotal or grand total cell (`pivot.showDetails`). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | | Move and resize charts, pictures and shapes | charts can't be resized (#190) | | Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial | | Quick Analysis (Ctrl+Q) | missing | @@ -178,6 +178,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Right-clicking a PivotTable's value, subtotal or grand total cell offers Show Details in the cell menu, as in Excel ("Right-click a field in the values area of the PivotTable, and then click Show Details"): it lists the cell's source records on a new sheet, as double-clicking the cell does (`pivot.showDetails`). Labels and headers of a current report don't offer it; in a report that is out of date every cell offers it, and choosing it asks for a refresh | | 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) | | 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is | | 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) | From 28efc9dabf4841a8f584e3ecb567603c9810cf7b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 09:25:36 -0700 Subject: [PATCH 34/56] Docs: list the date fill modes in the ui-parity history row without table pipes --- docs/ui-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 174ba0ebe..07dd01764 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -181,7 +181,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) | | 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is | | 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) | -| 2026-10-24 | minor | When the fill starts from a date, the right-drag fill menu and the Auto Fill Options button also offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years (`edit.autoFill` `mode: days|weekdays|months|years`). A lone date steps by one unit; two or more dates step by the difference between the first two in that unit, or one unit when they fall in the same one. Fill Months and Fill Years keep the day of the month, clamped to the month's last day. Every fill by months or years (these two, a drag of dates a month apart, and Series by Month or Year) keeps the time of day of a date with a time, which used to be dropped | +| 2026-10-24 | minor | When the fill starts from a date, the right-drag fill menu and the Auto Fill Options button also offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years (`edit.autoFill` with `mode` set to `days`, `weekdays`, `months` or `years`). A lone date steps by one unit; two or more dates step by the difference between the first two in that unit, or one unit when they fall in the same one. Fill Months and Fill Years keep the day of the month, clamped to the month's last day. Every fill by months or years (these two, a drag of dates a month apart, and Series by Month or Year) keeps the time of day of a date with a time, which used to be dropped | | 2026-10-22 | minor | Show Outline Symbols (`view.showOutlineSymbols`, Ctrl+8): a sheet can hide the outline's level buttons and brackets while keeping its outline, as Excel saves with `showOutlineSymbols="0"` (read and written); the grid then gives the bands no room | | 2026-10-22 | minor | A number in a repeat-fill format (`*`, as in the Accounting format) that is too wide for its column shows #### like any other number, instead of the fill rebuilding the value so its leading digits were cut off (2,509.00 CHF drawn as "509.00 CHF") | | 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 | From 3d41bdb95618809464bfc2a34d38136c8fdbb354 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 09:46:33 -0700 Subject: [PATCH 35/56] Sheet tabs: drag the handle at the scrollbar's left edge to give the tabs more or less room The sheet tabs took up to 60% of the tab bar and the horizontal scrollbar the rest, and nothing between them could be dragged. The gap at the scrollbar's left edge is now a handle: it shows the resize cursor, and dragging it right gives the tabs more of the bar, until the scrollbar is 100 px wide. After the window is narrowed, the scrollbar is still 100 px wide. Dragging it left gives the scrollbar more, down to the New sheet button. Room the tabs do not fill stays empty, and New sheet stays after the last tab. Each open workbook keeps its own share while it is open. The share is not saved in the file, and a workbook starts at the default. --- ROADMAP.md | 1 + crates/ui-egui/src/grid.rs | 5 + crates/ui-egui/src/tabs.rs | 77 ++++++++++++- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/tab_split.rs | 159 +++++++++++++++++++++++++++ docs/ui-parity.md | 5 +- 6 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/tests/it/tab_split.rs diff --git a/ROADMAP.md b/ROADMAP.md index da06359b5..b329e15c7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-24 | Sheet tabs: dragging the handle between the tabs and the horizontal scrollbar gives the tabs more or less of the bar, as in Excel; each open workbook keeps its own share | | 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text | | 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row | | 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) | diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index b485104b1..3702f086b 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -176,6 +176,11 @@ pub struct GridState { pub geo: Option, /// The horizontal scrollbar's track in the sheet tab bar (hit-testing for tests). pub hscroll: Option, + /// The handle between the sheet tabs and the horizontal scrollbar (hit-testing for tests). + pub tab_split: Option, + /// The share of the tab bar each workbook's tabs (and New sheet) take after the handle was + /// dragged, by document uid. A workbook without one gives its tabs up to 60%. + pub tab_shares: std::collections::HashMap, /// Ctrl+wheel zoom in progress (see [`WheelZoom`]). pub wheel_zoom: Option, /// A one-finger touch drag in progress: it pans the sheet instead of selecting. diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index bc6fcc1d8..49f7993a4 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -55,6 +55,46 @@ fn tab_drop(i: usize, steps: i64, copy: bool) -> Option { Some(json!({"sheet": i, "to": to})) } +/// The New sheet (+) button and the gap before it. +const NEW_SHEET_WIDTH: f32 = 32.0; +/// The handle between the tabs and the horizontal scrollbar. +const SPLIT_WIDTH: f32 = 8.0; +/// The narrowest the scrollbar track gets when the handle is dragged to the right. +const MIN_TRACK: f32 = 100.0; + +/// The share of the tab bar (`bar`: its left edge and width) the tabs and New sheet take with +/// the handle dragged to `x`: New sheet stays shown, and the track ending at `right` keeps +/// [`MIN_TRACK`]. +fn dragged_share(x: f32, bar: (f32, f32), right: f32) -> f32 { + let (left, width) = bar; + if !width.is_finite() || width <= 0.0 || !x.is_finite() { + return 0.6; + } + let min = NEW_SHEET_WIDTH / width; + let max = ((right - SPLIT_WIDTH - MIN_TRACK - left) / width).max(min); + ((x - left) / width).clamp(min, max) +} + +/// The handle at the left edge of the horizontal scrollbar: dragging it gives the sheet tabs more +/// or less of the bar, as in Excel, for the workbook `uid`. +fn tab_split_handle(app: &mut SheetApp, ui: &mut Ui, split: Rect, bar: (f32, f32), right: f32, uid: u64, t: &Tokens) { + app.grid.tab_split = Some(split); + let resp = ui.interact(split, ui.id().with("tab_split"), Sense::drag()); + if resp.hovered() || resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + if resp.dragged() + && let Some(p) = resp.interact_pointer_pos() + { + app.grid.tab_shares.insert(uid, dragged_share(p.x - SPLIT_WIDTH / 2.0, bar, right)); + ui.ctx().request_repaint(); + } + let color = if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled }; + for dy in [-4.0, 0.0, 4.0] { + ui.painter().circle_filled(pos2(split.center().x, split.center().y + dy), 1.0, color); + } +} + pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::bottom("sheet_tabs").exact_size(32.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { @@ -81,6 +121,12 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { } ui.add_space(8.0); let Some(d) = app.session.active() else { return }; + let (bar_left, bar_width) = (ui.cursor().left(), ui.available_width()); + let uid = d.uid; + // The track ends where the grid does, so it stops under the vertical scrollbar. A share + // dragged in a wider window is held to the same limits as a drag at this width. + let bar_right = app.grid.rect.map_or(bar_left + bar_width, |r| r.right()).min(bar_left + bar_width); + let share = app.grid.tab_shares.get(&uid).map(|&share| dragged_share(bar_left + bar_width * share, (bar_left, bar_width), bar_right)); let sheets: Vec<(usize, String, Option<[u8; 3]>, bool)> = d.wb.sheets .iter() @@ -91,7 +137,12 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { // Keep worksheet tabs in their own horizontally scrollable viewport. As in Excel, the // tabs get up to 60% of the bar and the grid's horizontal scrollbar the rest; a strip // that needs less shrinks to its tabs, so New sheet (+) sits right after the last one. - let tabs_width = (ui.available_width() * 0.6).max(100.0); + // After the handle at the scrollbar's left edge is dragged, the tabs and New sheet + // take the share it was dragged to. + let tabs_width = match share { + Some(share) => (bar_width * share - NEW_SHEET_WIDTH).max(0.0), + None => (bar_width * 0.6).max(100.0), + }; // Reveal the selected sheet after navigation or layout changes, but // allow browsing other tabs without snapping back on every frame. let reveal_key = egui::Id::new((d.uid, d.wb.active_sheet, &sheets, tabs_width.to_bits())); @@ -116,6 +167,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { egui::ScrollArea::horizontal() .id_salt("worksheet_tabs_horizontal") .auto_shrink([true, false]) + .min_scrolled_width(0.0) .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) .show(ui, |ui| { if wheel_scroll.abs() > 0.0 { @@ -251,7 +303,13 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { // The track ends where the grid does, so it stops under the vertical scrollbar. let avail = ui.available_rect_before_wrap(); let right = app.grid.rect.map_or(avail.right(), |r| r.right()).min(avail.right()); - let track = Rect::from_min_max(pos2(avail.left() + 8.0, avail.center().y - 7.0), pos2(right, avail.center().y + 7.0)); + // The handle sits at the track's left edge. With a dragged share, the room the tabs + // don't fill stays empty before it. + let split_left = + share.map_or(avail.left(), |share| (bar_left + bar_width * share).min(right - SPLIT_WIDTH - MIN_TRACK).max(avail.left())); + let split = Rect::from_min_max(pos2(split_left, avail.top()), pos2(split_left + SPLIT_WIDTH, avail.bottom())); + tab_split_handle(app, ui, split, (bar_left, bar_width), right, uid, &t); + let track = Rect::from_min_max(pos2(split.right(), avail.center().y - 7.0), pos2(right, avail.center().y + 7.0)); crate::grid::horizontal_scrollbar(app, ui, track, &t); }); }); @@ -373,7 +431,9 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { #[cfg(test)] mod tests { - use super::{activate_sheet, drop_copies_sheet, navigator_items, rename_needs_focus, tab_drop}; + use super::{ + MIN_TRACK, NEW_SHEET_WIDTH, SPLIT_WIDTH, activate_sheet, dragged_share, drop_copies_sheet, navigator_items, rename_needs_focus, tab_drop, + }; use crate::SheetApp; use egui::Modifiers; use gridcraft_engine::model::{Sheet, Visibility}; @@ -459,4 +519,15 @@ mod tests { let a1 = gridcraft_engine::core::CellRef::parse("A1").unwrap(); assert!(app.session.active().unwrap().wb.sheet(0).unwrap().cell(a1).is_some(), "the value lands on the first sheet"); } + + #[test] + fn a_dragged_share_keeps_new_sheet_and_the_scrollbar_shown() { + // A 1,000 px bar from x = 100 whose track ends at x = 1,100. + let bar = (100.0, 1000.0); + assert_eq!(dragged_share(600.0, bar, 1100.0), 0.5); + assert_eq!(dragged_share(0.0, bar, 1100.0), NEW_SHEET_WIDTH / 1000.0, "New sheet stays shown"); + assert_eq!(dragged_share(1100.0, bar, 1100.0), (1000.0 - SPLIT_WIDTH - MIN_TRACK) / 1000.0, "the track keeps its minimum"); + assert_eq!(dragged_share(f32::NAN, bar, 1100.0), 0.6); + assert_eq!(dragged_share(600.0, (100.0, 0.0), 1100.0), 0.6, "an empty bar has the default share"); + } } diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 3a9769c1c..8734cd77b 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -79,6 +79,7 @@ mod shrink_to_fit; mod sort_dialog_columns; mod symbol_glyphs; mod system_theme; +mod tab_split; mod table_headings; mod text_box_editing; mod text_overflow_scroll; diff --git a/crates/ui-egui/tests/it/tab_split.rs b/crates/ui-egui/tests/it/tab_split.rs new file mode 100644 index 000000000..21a21c4cb --- /dev/null +++ b/crates/ui-egui/tests/it/tab_split.rs @@ -0,0 +1,159 @@ +//! The handle between the sheet tabs and the horizontal scrollbar: dragging it gives the tabs more +//! or less of the tab bar, as in Excel. +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, Shape, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(width: f32, sheets: usize) -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("sheet.rename", json!({"sheet":0,"name":"Tab00"})).unwrap(); + for i in 1..sheets { + s.execute("home.insertSheet", json!({"name":format!("Tab{i:02}")})).unwrap(); + } + s.execute("sheet.activate", json!({"sheet":0})).unwrap(); + let mut fonts_ready = false; + let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(width, 120.0)).build_ui_state( + move |ui, app: &mut SheetApp| { + if !fonts_ready { + ui.ctx().set_fonts(gridcraft_ui_egui::theme::font_definitions()); + fonts_ready = true; + return; + } + app.grid.cells_rect = Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(ui.max_rect().width(), 80.0))); + gridcraft_ui_egui::tabs::sheet_tabs(app, ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(20); + h +} + +fn split(h: &Harness) -> Rect { + h.state().grid.tab_split.expect("the tab bar has a split handle") +} + +fn track(h: &Harness) -> Rect { + h.state().grid.hscroll.expect("the tab bar has a horizontal scrollbar") +} + +/// The clip rectangle of the tab strip: the viewport the tab names are painted in. +fn strip(h: &Harness) -> Rect { + h.output() + .shapes + .iter() + .find_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == "Tab00" => Some(s.clip_rect), + _ => None, + }) + .expect("Tab00 was painted") +} + +fn drag(h: &mut Harness, from: Pos2, to: Pos2) { + h.input_mut().events.extend([ + Event::PointerMoved(from), + Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.step(); + for i in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 6.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(20); +} + +#[test] +fn dragging_the_handle_right_gives_overflowing_tabs_more_room() { + let mut h = harness(1200.0, 30); + let (before, handle) = (strip(&h), split(&h)); + assert!(before.width() <= 1200.0 * 0.6, "the tabs start at their default share: {before:?}"); + drag(&mut h, handle.center(), pos2(1000.0, handle.center().y)); + let after = strip(&h); + assert!(after.width() > before.width() + 200.0, "the strip grew: {before:?} -> {after:?}"); + assert!((split(&h).center().x - 1000.0).abs() <= 2.0, "the handle follows the pointer: {:?}", split(&h)); + assert_eq!(track(&h).left(), split(&h).right(), "the scrollbar starts at the handle"); + assert!(track(&h).width() >= 100.0, "the scrollbar keeps a usable width: {:?}", track(&h)); + assert_eq!(h.state().session.doc().unwrap().wb.sheets.len(), 30, "dragging the handle does not edit the workbook"); +} + +#[test] +fn dragging_the_handle_left_gives_the_scrollbar_more_room() { + let mut h = harness(1200.0, 30); + let (before, handle) = (track(&h), split(&h)); + drag(&mut h, handle.center(), pos2(handle.center().x - 300.0, handle.center().y)); + let after = track(&h); + assert!(after.width() > before.width() + 280.0, "the scrollbar grew: {before:?} -> {after:?}"); + assert!(strip(&h).width() < 1200.0 * 0.6 - 280.0, "the strip shrank: {:?}", strip(&h)); +} + +#[test] +fn the_handle_stops_before_the_scrollbar_gets_too_narrow_and_keeps_new_sheet_shown() { + let mut h = harness(1200.0, 30); + let handle = split(&h); + drag(&mut h, handle.center(), pos2(1195.0, handle.center().y)); + assert!(track(&h).width() >= 100.0 - 0.5, "the scrollbar keeps 100 px: {:?}", track(&h)); + let (handle, bar_left) = (split(&h), strip(&h).left()); + drag(&mut h, handle.center(), pos2(0.0, handle.center().y)); + // The tab strip has no width left, and the New sheet button (26 px after a 6 px gap) still + // fits before the handle. + assert!((split(&h).left() - bar_left - 32.0).abs() <= 1.0, "the handle is 32 px after the strip's start {bar_left}: {:?}", split(&h)); +} + +#[test] +fn a_narrower_window_keeps_the_scrollbar_after_the_handle_was_dragged_right() { + let mut h = harness(1200.0, 30); + let handle = split(&h); + drag(&mut h, handle.center(), pos2(1195.0, handle.center().y)); + h.set_size(vec2(700.0, 120.0)); + h.run_steps(20); + let track = track(&h); + assert!(track.width() >= 100.0 - 0.5 && track.right() <= 700.5, "the scrollbar keeps 100 px in the bar: {track:?}"); + assert_eq!(track.left(), split(&h).right(), "the scrollbar starts at the handle"); + assert!(strip(&h).right() <= split(&h).left() - 32.0 + 0.5, "the tabs end before New sheet: {:?}", strip(&h)); +} + +#[test] +fn room_the_tabs_do_not_fill_stays_empty_and_new_sheet_follows_the_last_tab() { + let mut h = harness(1200.0, 2); + let plus_before = track(&h).left() - 8.0 - 26.0; + let handle = split(&h); + drag(&mut h, handle.center(), pos2(900.0, handle.center().y)); + assert!((split(&h).center().x - 900.0).abs() <= 2.0, "the handle stays where it was dragged: {:?}", split(&h)); + assert_eq!(track(&h).left(), split(&h).right()); + // New sheet (+) is still right after the last tab: clicking there adds a sheet. + let at = pos2(plus_before + 13.0, handle.center().y); + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(20); + assert_eq!(h.state().session.doc().unwrap().wb.sheets.len(), 3, "New sheet stayed after the last tab"); +} + +#[test] +fn the_handle_shows_the_resize_cursor() { + let mut h = harness(1200.0, 30); + let at = split(&h).center(); + h.input_mut().events.push(Event::PointerMoved(at)); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal); +} + +#[test] +fn each_workbook_keeps_its_own_tab_share() { + let mut h = harness(1200.0, 30); + let handle = split(&h); + drag(&mut h, handle.center(), pos2(1000.0, handle.center().y)); + assert!(track(&h).left() > 990.0, "the first workbook's scrollbar starts at the handle: {:?}", track(&h)); + h.state_mut().session.new_workbook(); + h.run_steps(20); + // The new workbook's one tab takes its default room, so its scrollbar starts right after New sheet. + assert!(track(&h).left() < 400.0, "a new workbook starts at the default share: {:?}", track(&h)); + assert_eq!(h.state().grid.tab_shares.len(), 1, "only the dragged workbook has a share"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5d5f1efee..c12ec826b 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-23 · **Change:** minor (New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (dragging the handle between the sheet tabs and the horizontal scrollbar resizes the tabs; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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; 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 @@ -68,7 +68,7 @@ this logic, with one undo step. | Move and resize charts, pictures and shapes | charts can't be resized (#190) | | Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial | | Quick Analysis (Ctrl+Q) | missing | -| Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); the tabs/scrollbar split can't be dragged yet | +| Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); dragging the handle at the scrollbar's left edge gives the tabs more or less of the bar; each workbook keeps its share while it is open (not saved in the file) | | Text overflow into empty neighbours, wrap, rotation, shrink to fit | done | ## Keyboard @@ -178,6 +178,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Sheet tabs: the handle at the horizontal scrollbar's left edge shows the resize cursor, and dragging it moves the boundary, as in Excel ("Hover the mouse pointer at the edge of the scrollbar until you see the double-headed arrow"). Dragging it right gives the tabs more of the bar, until the scrollbar is 100 px wide (the scrollbar stays 100 px wide after the window is narrowed); dragging it left gives the scrollbar more, down to the New sheet button. Room the tabs don't fill stays empty, with New sheet after the last tab. Each open workbook keeps its own share; a workbook starts at the default (tabs up to 60%) | | 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) | | 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is | | 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) | From 7c10db07d9207e6a9aee60ebd2e684d744edc393 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 10:31:09 -0700 Subject: [PATCH 36/56] PivotTables: Show Details reports the turned-off option before an out-of-date report With Enable show details off, Show Details on a value cell of a report whose source has changed gave the out-of-date error. It now gives the turned-off message, and a label still reports that it is not a value cell. The message names pivot.options with enableShowDetails set to true, since there is no PivotTable Options dialog. --- crates/engine/src/cmd/pivot.rs | 26 ++++++++++++++----- crates/ui-egui/tests/it/pivot_show_details.rs | 2 +- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 5277ff98a..05acb4170 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -764,11 +764,13 @@ fn show_details(s: &mut Session, p: &Json) -> Result { .find(|pt| pt.last_range.is_some_and(|r| r.contains(at))) .ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); - let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)).map_err(other)?; - let (src, records) = found.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; - if !pt.enable_show_details { + let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)); + // With the option off, a report that is out of date or a source that cannot be read is + // reported as the option; only a cell that is not a value cell keeps its own message. + if !pt.enable_show_details && !matches!(found, Ok(None)) { return Err(other(SHOW_DETAILS_OFF)); } + let (src, records) = found.map_err(other)?.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; if d.wb.protected_structure { return Err(other("The workbook is protected and cannot be changed.")); } @@ -837,7 +839,7 @@ fn show_details(s: &mut Session, p: &Json) -> Result { } /// The message Show Details gives while Enable show details is off. -const SHOW_DETAILS_OFF: &str = "Show Details is turned off for this PivotTable. Turn on Enable show details in PivotTable Options."; +const SHOW_DETAILS_OFF: &str = "Show Details is turned off for this PivotTable. Turn it on with pivot.options and enableShowDetails set to true."; /// PivotTable Options: Enable show details (Data). Changes only the option; the report is not /// refreshed. Without params it returns the current setting. @@ -2629,11 +2631,11 @@ mod tests { assert!(!pt(&s).enable_show_details); assert_eq!(s.execute("pivot.fields", json!({})).unwrap()["enableShowDetails"], false); let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); - assert!(e.contains("Enable show details"), "{e}"); + assert!(e.contains("turned off") && e.contains("pivot.options") && e.contains("enableShowDetails"), "{e}"); assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); // A label still reports that it is not a value cell. let e = s.execute("pivot.showDetails", json!({"cell": "A5"})).unwrap_err().to_string(); - assert!(!e.contains("Enable show details"), "{e}"); + assert!(!e.contains("turned off"), "{e}"); // Setting the same value again adds no undo step. let after = s.doc().unwrap().revision; s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); @@ -2644,4 +2646,16 @@ mod tests { assert!(pt(&s).enable_show_details); assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap()["records"], 2); } + + #[test] + fn enable_show_details_off_is_reported_before_an_out_of_date_report() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); + // The source changes after the report was written, so the report is out of date. + s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap(); + let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); + assert!(e.contains("turned off") && !e.contains("out of date"), "{e}"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + } } diff --git a/crates/ui-egui/tests/it/pivot_show_details.rs b/crates/ui-egui/tests/it/pivot_show_details.rs index 6300a719f..959eea0f6 100644 --- a/crates/ui-egui/tests/it/pivot_show_details.rs +++ b/crates/ui-egui/tests/it/pivot_show_details.rs @@ -116,6 +116,6 @@ fn double_click_with_enable_show_details_off_says_why_and_does_not_edit() { let app = h.state(); assert!(app.editor.is_none(), "the value cell must not open for editing"); - assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("Enable show details")), "{:?}", app.message); + assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("enableShowDetails")), "{:?}", app.message); assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1); } From 010eddd25e329e47e8a893541a52bd5414127372 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 11:27:58 -0700 Subject: [PATCH 37/56] PivotTables: the cell menu computes a report once for all its cells The cell menu checked whether to offer Show Details by computing the whole report again for each new active cell. pivot::detail_cells now returns every value, subtotal and grand total cell of a report from one computation, and the menu caches that set per document, revision, sheet and PivotTable. --- crates/engine/src/cmd/pivot.rs | 31 ++++++++++ crates/engine/src/pivot.rs | 56 +++++++++++++------ crates/ui-egui/src/grid.rs | 33 ++++++----- .../tests/it/pivot_show_details_menu.rs | 19 ++++++- 4 files changed, 106 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 229ff22b6..ae5446715 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -2580,4 +2580,35 @@ mod tests { } assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); } + + #[test] + fn detail_cells_are_the_cells_show_details_takes() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + for layout in 0..2 { + if layout == 1 { + // A two-level row axis, with subtotals at the top of each group. + s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap(); + } + let d = s.doc().unwrap(); + let si = d.wb.active_sheet; + let pt = &d.wb.active().unwrap().pivots[0]; + let r = pt.last_range.unwrap(); + let cells = crate::pivot::detail_cells(&d.wb, pt, si).unwrap(); + let mut values = 0; + for row in 0..r.height() { + for col in 0..r.width() { + let one = crate::pivot::detail_records(&d.wb, pt, si, row, col).unwrap(); + assert_eq!(cells.contains(&(row, col)), one.is_some(), "layout {layout}, cell ({row}, {col})"); + values += usize::from(one.is_some()); + } + } + assert!(values > 4, "layout {layout} has value cells"); + } + // An out-of-date report answers as Show Details does. + s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap(); + let d = s.doc().unwrap(); + let pt = &d.wb.active().unwrap().pivots[0]; + assert!(crate::pivot::detail_cells(&d.wb, pt, d.wb.active_sheet).unwrap_err().contains("out of date")); + } } diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index 397dd5ad2..a490824da 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -816,11 +816,30 @@ pub const NOT_A_VALUE_CELL: &str = "select a value cell in a PivotTable"; /// on the sheet is no longer what the source computes: the cell's records would be another cell's. pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u32, col: u32) -> Result)>, String> { let src = read_source(wb, &pt.source, pivot_sheet)?; - let mut probe = Probe { at: (row as usize, col as usize), hit: None, paths: Vec::new() }; + let mut probe = Probe { at: Some((row as usize, col as usize)), hit: None, paths: Some(Vec::new()), cells: None }; let out = compute_from(wb, pt, &src, Some(&mut probe))?; - // Excel reads its PivotTable cache; without one, the report written by the last refresh - // must still be what the source gives. Checked first: in a stale report even a cell that - // computes as a label may show a value. + // Checked first: in a stale report even a cell that computes as a label may show a value. + check_current(wb, pt, pivot_sheet, &out)?; + let Some((rp, cp)) = probe.hit else { return Ok(None) }; + let records = + probe.paths.unwrap_or_default().into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect(); + Ok(Some((src, records))) +} + +/// The cells of the report grid (relative to its top-left) where [`detail_records`] finds +/// records: its value, subtotal and grand total cells, from one computation of the report. The +/// same errors as [`detail_records`]. +pub fn detail_cells(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result, String> { + let src = read_source(wb, &pt.source, pivot_sheet)?; + let mut probe = Probe { at: None, hit: None, paths: None, cells: Some(HashSet::new()) }; + let out = compute_from(wb, pt, &src, Some(&mut probe))?; + check_current(wb, pt, pivot_sheet, &out)?; + Ok(probe.cells.unwrap_or_default().into_iter().map(|(r, c)| (r as u32, c as u32)).collect()) +} + +/// An error when the report on the sheet is not what `out` computes. Excel reads its PivotTable +/// cache; without one, the report written by the last refresh must still be what the source gives. +fn check_current(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, out: &Output) -> Result<(), String> { let sh = wb.sheet(pivot_sheet).ok_or("There's no such sheet.")?; let current = pt.last_range.is_some_and(|r| { r.height() == out.height().max(1) @@ -834,17 +853,17 @@ pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u if !current { return Err("The PivotTable report is out of date. Refresh it first.".into()); } - let Some((rp, cp)) = probe.hit else { return Ok(None) }; - let records = probe.paths.into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect(); - Ok(Some((src, records))) + Ok(()) } -/// What [`detail_records`] asks of a report computation: the row and column item paths of the -/// value cell at `at`, and the paths of every record the filters let through. +/// What [`detail_records`] and [`detail_cells`] ask of a report computation: the row and column +/// item paths of the value cell at `at`, the paths of every record the filters let through (when +/// `paths` is set), and every value cell (when `cells` is set). struct Probe { - at: (usize, usize), + at: Option<(usize, usize)>, hit: Option<(Vec, Vec)>, - paths: Vec<(usize, Vec, Vec)>, + paths: Option, Vec)>>, + cells: Option>, } fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result { @@ -959,8 +978,8 @@ fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option< if !col_leaves.contains(&cp[..shown]) { col_leaves.insert(cp[..shown].to_vec()); } - if let Some(pr) = probe.as_deref_mut() { - pr.paths.push((ri, rp.clone(), cp.clone())); + if let Some(paths) = probe.as_deref_mut().and_then(|pr| pr.paths.as_mut()) { + paths.push((ri, rp.clone(), cp.clone())); } row_set.insert(rp); col_set.insert(cp); @@ -1326,10 +1345,13 @@ fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option< _ => raw, }, }; - if let Some(pr) = probe.as_deref_mut() - && pr.at == (r, l + j) - { - pr.hit = Some((rp.to_vec(), cp.to_vec())); + if let Some(pr) = probe.as_deref_mut() { + if pr.at == Some((r, l + j)) { + pr.hit = Some((rp.to_vec(), cp.to_vec())); + } + if let Some(cells) = pr.cells.as_mut() { + cells.insert((r, l + j)); + } } g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten()); } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index abe8bcf42..8b36f520b 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -163,9 +163,11 @@ pub struct GridState { /// The PivotTable Fields pane's `pivot.fields` answer, cached per (doc uid, revision, pivot): /// it scans the whole source list, far too slow to repeat every frame (#2113). pub pivot_fields: Option<((u64, u64, String), serde_json::Value)>, - /// Whether the cell menu offers Show Details, cached per (doc uid, revision, sheet, active - /// cell): finding out computes the whole PivotTable report. - pub show_details_item: Option<((u64, u64, usize, CellRef), bool)>, + /// The cells of a PivotTable report where the cell menu offers Show Details, relative to the + /// report's top-left and cached per (doc uid, revision, sheet, pivot): finding them computes + /// the whole report. `None` when the report can't be computed or is out of date: every cell + /// of it offers Show Details, and running it says why. + pub show_details_cells: Option<((u64, u64, usize, usize), Option>)>, pub header_menu: Option<(Pos2, bool)>, /// Page Layout view pagination, cached per (doc uid, revision, sheet, rows, columns). pub paper: Option, @@ -3424,20 +3426,21 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { fn show_details_offered(app: &mut SheetApp) -> bool { let Some(d) = app.session.active() else { return false }; let (si, at) = (d.wb.active_sheet, d.selection.active); - let key = (d.uid, d.revision, si, at); - if let Some((k, on)) = app.grid.show_details_item - && k == key + let Some((pi, pt)) = d.wb.active().and_then(|sh| sh.pivots.iter().enumerate().find(|(_, pt)| pt.last_range.is_some_and(|r| r.contains(at)))) + else { + return false; + }; + let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); + let cell = (at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)); + let key = (d.uid, d.revision, si, pi); + if let Some((k, cells)) = &app.grid.show_details_cells + && *k == key { - return on; + return cells.as_ref().is_none_or(|c| c.contains(&cell)); } - let on = d.wb.active().and_then(|sh| sh.pivots.iter().find(|pt| pt.last_range.is_some_and(|r| r.contains(at)))).is_some_and(|pt| { - let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); - !matches!( - gridcraft_engine::pivot::detail_records(&d.wb, pt, si, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)), - Ok(None) - ) - }); - app.grid.show_details_item = Some((key, on)); + let cells = gridcraft_engine::pivot::detail_cells(&d.wb, pt, si).ok(); + let on = cells.as_ref().is_none_or(|c| c.contains(&cell)); + app.grid.show_details_cells = Some((key, cells)); on } diff --git a/crates/ui-egui/tests/it/pivot_show_details_menu.rs b/crates/ui-egui/tests/it/pivot_show_details_menu.rs index 02527d8a3..73172155e 100644 --- a/crates/ui-egui/tests/it/pivot_show_details_menu.rs +++ b/crates/ui-egui/tests/it/pivot_show_details_menu.rs @@ -4,7 +4,7 @@ use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; use egui_kittest::kittest::Queryable as _; use gridcraft_engine::Session; -use gridcraft_engine::core::CellRef; +use gridcraft_engine::core::{CellRef, Value}; use gridcraft_ui_egui::{SheetApp, grid::Geo}; use serde_json::json; @@ -90,6 +90,23 @@ fn labels_of_the_report_and_ordinary_cells_have_no_show_details_item() { } } +#[test] +fn the_cell_menu_computes_the_report_once_for_all_its_cells() { + let mut h = harness(); + right_click(&mut h, "E4"); + assert!(h.query_by_label("Show Details").is_some()); + let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + h.input_mut().events.push(escape); + h.run_steps(2); + // West goes away behind the session's back, so the revision stays the same. A menu that + // computed the report again for D4 would find it out of date and offer Show Details there. + let doc = h.state_mut().session.active_mut().unwrap(); + let si = doc.wb.active_sheet; + std::sync::Arc::make_mut(&mut doc.wb).sheet_mut(si).unwrap().set_value(CellRef::parse("A3").unwrap(), Value::text("East")); + right_click(&mut h, "D4"); + assert!(h.query_by_label("Show Details").is_none(), "D4 is a label of the report computed for E4"); +} + #[test] fn show_details_in_an_out_of_date_report_asks_for_a_refresh() { let mut h = harness(); From 118cd15d935bf79f75c1465932107cbd2bd44778 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 11:22:54 -0700 Subject: [PATCH 38/56] Outline: Settings puts summary rows above and summary columns left of their detail Data > Outline > Settings now has Summary rows below detail and Summary columns to right of detail. With one cleared, each group's - / + button sits on the line before the group, its bracket runs down to the last detail line, and Hide Detail, Show Detail, the level buttons and Ungroup mark or clear the collapsed flag on that line. Changing a direction moves the flags of collapsed groups to their new summary lines. - model: Sheet::summary_below and summary_right (on by default, also for older JSON). - xlsx: read and written as sheetPr/outlinePr summaryBelow and summaryRight; files that set them no longer lose them on save. - engine: data.outlineSettings {sheet?, summaryBelow?, summaryRight?}, undoable; outline::groups_with places the summary line. - ui: a Settings button in the Data tab's Outline group opens the dialog; the outline band follows the setting. --- crates/engine/src/cmd/data.rs | 146 +++++++++++++++++++++++++++-- crates/engine/src/outline.rs | 54 +++++++++-- crates/model/src/sheet.rs | 9 ++ crates/ui-egui/src/dialogs.rs | 14 +++ crates/ui-egui/src/grid.rs | 4 +- crates/ui-egui/src/outline.rs | 60 +++++++++++- crates/ui-egui/src/ribbon.rs | 3 + crates/ui-egui/tests/it/outline.rs | 30 +++++- crates/xlsx/src/sheet_read.rs | 4 + crates/xlsx/src/sheet_write.rs | 12 ++- crates/xlsx/src/tests/malformed.rs | 28 ++++++ docs/ui-parity.md | 5 +- 12 files changed, 343 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 8f7c47fb8..9a98e4e6a 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -106,6 +106,15 @@ pub fn specs() -> Vec { has_doc, show_levels ), + cmd!( + "data.outlineSettings", + "Outline Settings", + ["Data", "Outline"], + None, + "{sheet?, summaryBelow?: bool, summaryRight?: bool} (summary rows below their detail, summary columns right of it; off puts them above / left) → {summaryBelow, summaryRight}", + has_doc, + outline_settings + ), cmd!( "data.subtotal", "Subtotal", @@ -1329,6 +1338,7 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result { let (rows, a, b) = outline_lines(s, p)?; edit(s, |cx| { let sh = cx.sheet_mut(si)?; + let before = outline::summary_before(sh, rows); let map = if rows { &mut sh.rows } else { &mut sh.cols }; let b = b.min(a.saturating_add(1_048_575)); for i in a..=b { @@ -1339,9 +1349,10 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result { } } // A summary line whose group is gone has nothing left to collapse. + let summary = if before { a.checked_sub(1) } else { b.checked_add(1) }; if !on - && !outline::groups(map).iter().any(|g| g.end == b) - && let Some(e) = map.get_mut(&b.saturating_add(1)) + && !outline::groups_with(map, before).iter().any(|g| if before { g.start == a } else { g.end == b }) + && let Some(e) = summary.and_then(|i| map.get_mut(&i)) { e.collapsed = false; } @@ -1359,8 +1370,9 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result { edit(s, |cx| { let si = cx.sheet_index(); let sh = cx.sheet_mut(si)?; + let before = outline::summary_before(sh, rows); let map = if rows { &mut sh.rows } else { &mut sh.cols }; - let all = outline::groups(map); + let all = outline::groups_with(map, before); let level_ok = |g: &outline::OutlineGroup| want.is_none_or(|l| g.level == l); let pick = |hit: &dyn Fn(&outline::OutlineGroup) -> bool| -> Vec { let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect(); @@ -1369,13 +1381,13 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result { }; let mut chosen = pick(&|g| g.start <= b && g.end >= a); if chosen.is_empty() { - chosen = pick(&|g| g.summary() >= a && g.summary() <= b); + chosen = pick(&|g| g.has_summary() && g.summary() >= a && g.summary() <= b); } for g in chosen { // Inner groups collapsed on their own (their summary marked), before this one changes. let inner: Vec = all .iter() - .filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.summary() != g.summary()) + .filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.has_summary() && o.summary() != g.summary()) .filter(|o| map.get(&o.summary()).is_some_and(|e| e.collapsed)) .copied() .collect(); @@ -1384,6 +1396,9 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result { set_hidden(e, hide || inner.iter().any(|o| o.contains(i))); } } + if !g.has_summary() { + continue; + } if hide { map.entry(g.summary()).or_default().collapsed = true; } else if let Some(e) = map.get_mut(&g.summary()) { @@ -1419,15 +1434,16 @@ fn show_levels(s: &mut Session, p: &Json) -> Result { edit(s, |cx| { let si = cx.sheet_index(); let sh = cx.sheet_mut(si)?; - for (map, n) in [(&mut sh.rows, rows), (&mut sh.cols, cols)] { + let before = (outline::summary_before(sh, true), outline::summary_before(sh, false)); + for (map, n, before) in [(&mut sh.rows, rows, before.0), (&mut sh.cols, cols, before.1)] { let Some(n) = n else { continue }; - let groups = outline::groups(map); + let groups = outline::groups_with(map, before); for e in map.values_mut().filter(|e| e.outline > 0) { set_hidden(e, e.outline >= n); } // Inner groups come first, so a summary line shared with the group around it ends up // as that outer group says. - for g in groups { + for g in groups.into_iter().filter(|g| g.has_summary()) { map.entry(g.summary()).or_default().collapsed = g.level >= n; } } @@ -1436,6 +1452,53 @@ fn show_levels(s: &mut Session, p: &Json) -> Result { }) } +/// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns +/// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line. +fn outline_settings(s: &mut Session, p: &Json) -> Result { + let si = target_sheet(s, p)?; + edit(s, |cx| { + let sh = cx.sheet_mut(si)?; + for (rows, key) in [(true, "summaryBelow"), (false, "summaryRight")] { + let Some(on) = bool_param(p, key) else { continue }; + let was_before = outline::summary_before(sh, rows); + if rows { + sh.summary_below = on; + } else { + sh.summary_right = on; + } + if was_before != on { + continue; + } + let map = if rows { &mut sh.rows } else { &mut sh.cols }; + let mut marked: Vec = outline::groups_with(map, was_before) + .into_iter() + .filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed)) + .collect(); + // A mark on a line that nested groups share moves with the outermost of them whose + // detail is all hidden, or with the outermost when none is. + let mut owner: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for g in &marked { + let key = (!g.collapsed, g.level); + let best = owner.entry(g.summary()).or_insert(key); + *best = (*best).min(key); + } + marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level))); + for g in &marked { + if let Some(e) = map.get_mut(&g.summary()) { + e.collapsed = false; + } + } + for g in marked { + let g = outline::OutlineGroup { summary_before: !on, ..g }; + if g.has_summary() { + map.entry(g.summary()).or_default().collapsed = true; + } + } + } + Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right})) + }) +} + fn subtotal(s: &mut Session, p: &Json) -> Result { let (sheet, r) = data_target(s, p, "data.subtotal")?; let header = bool_param(p, "header").unwrap_or(true); @@ -1771,6 +1834,73 @@ mod tests { assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left")); } + #[test] + fn summary_rows_above_the_detail_take_the_collapsed_mark() { + let mut s = nested_rows(); + let r = s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); + assert_eq!(r, json!({"summaryBelow": false, "summaryRight": true})); + // Row 4 (index 3) is the outer group's summary, row 5 the inner one's. + s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, true), (true, false), (true, false), (true, false), (true, false), (false, false)]); + s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, false); 6]); + // Hide Detail on the summary row collapses the group below it. + s.execute("data.hideDetail", json!({"rows": "4:4"})).unwrap(); + assert_eq!(row_state(&s, 3..=4), [(false, true), (true, false)]); + s.execute("data.showLevels", json!({"rows": 1})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, true), (true, true), (true, false), (true, false), (true, false), (false, false)]); + // Ungrouping the outer group clears its mark on the row above. + s.execute("data.showLevels", json!({"rows": 3})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + s.execute("data.ungroup", json!({"rows": "5:8"})).unwrap(); + assert_eq!(row_state(&s, 3..=3), [(false, false)]); + } + + #[test] + fn changing_the_summary_direction_moves_collapsed_marks() { + let mut s = nested_rows(); + s.execute("data.group", json!({"cols": "C:E"})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + s.execute("data.hideDetail", json!({"cols": "C:E"})).unwrap(); + s.execute("data.outlineSettings", json!({"summaryBelow": false, "summaryRight": false})).unwrap(); + let col_marked = |s: &Session, c: u32| s.doc().unwrap().wb.active().unwrap().cols.get(&c).is_some_and(|l| l.collapsed); + assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::>(), [true, false, false, false, false, false]); + assert!(col_marked(&s, 1) && !col_marked(&s, 5)); + s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap(); + assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::>(), [false, false, false, false, false, true]); + assert!(col_marked(&s, 1), "columns keep their direction"); + // One undo step per change; no params leaves the settings as they are. + s.execute("edit.undo", json!({})).unwrap(); + assert!(row_state(&s, 3..=3)[0].1); + assert_eq!(s.execute("data.outlineSettings", json!({})).unwrap(), json!({"summaryBelow": false, "summaryRight": false})); + // Nested groups that end on one row share its mark: it moves to the outer group only. + let mut s = Session::new(); + s.new_workbook(); + s.execute("data.group", json!({"rows": "5:8"})).unwrap(); + s.execute("data.group", json!({"rows": "6:8"})).unwrap(); + s.execute("data.showLevels", json!({"rows": 1})).unwrap(); + s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); + assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::>(), [true, false, false, false, false, false]); + s.execute("data.showDetail", json!({"rows": "4:4"})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, false); 6]); + // With only the inner group collapsed, its mark goes to row 5, and the outer group's + // Hide and Show Detail keep it collapsed. + s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "6:8", "level": 2})).unwrap(); + s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]); + s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]); + // A group on the first row has no row above it to mark. + let mut s = Session::new(); + s.new_workbook(); + s.execute("data.group", json!({"rows": "1:3"})).unwrap(); + s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "1:3"})).unwrap(); + assert_eq!(row_state(&s, 0..=3), [(true, false), (true, false), (true, false), (false, false)]); + } + #[test] fn column_sort_rejects_row_key_zero_without_panicking() { let mut s = Session::new(); diff --git a/crates/engine/src/outline.rs b/crates/engine/src/outline.rs index 80d14afe2..a3ca782c9 100644 --- a/crates/engine/src/outline.rs +++ b/crates/engine/src/outline.rs @@ -1,17 +1,18 @@ //! Outline groups (Data ▸ Group) of a sheet's rows or columns: the runs of lines at or above //! each outline level, as the grid draws them (brackets, − / + buttons, level buttons) and the //! Show / Hide Detail commands act on them. Summary rows sit below their detail and summary -//! columns right of it (Excel's defaults; the model has no setting for the other way round). +//! columns right of it, or above / left of it when the sheet's outline settings say so +//! ([`Sheet::summary_below`], [`Sheet::summary_right`]). use std::collections::BTreeMap; -use gridcraft_model::LineInfo; +use gridcraft_model::{LineInfo, Sheet}; /// The deepest outline level XLSX allows. pub const MAX_LEVEL: u8 = 7; /// One group: lines `start..=end` at outline level `level` or deeper, with its summary line -/// just after `end`. +/// just after `end`, or just before `start` when `summary_before` is set. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct OutlineGroup { pub level: u8, @@ -19,25 +20,43 @@ pub struct OutlineGroup { pub end: u32, /// Every detail line is hidden: the button shows +. pub collapsed: bool, + /// The summary line is above (rows) or left of (columns) the detail. + pub summary_before: bool, } impl OutlineGroup { /// The summary line, where the − / + button sits. pub fn summary(&self) -> u32 { - self.end.saturating_add(1) + if self.summary_before { self.start.saturating_sub(1) } else { self.end.saturating_add(1) } + } + /// Whether the summary line exists: a group that starts at the first line (summary before) + /// or ends at the last one (summary after) has none. + pub fn has_summary(&self) -> bool { + !self.contains(self.summary()) } pub fn contains(&self, line: u32) -> bool { line >= self.start && line <= self.end } } +/// Whether the sheet's summary rows (`rows`) or summary columns sit before their detail. +pub fn summary_before(sh: &Sheet, rows: bool) -> bool { + if rows { !sh.summary_below } else { !sh.summary_right } +} + /// The deepest outline level used by these lines (0 = no outline). pub fn max_level(lines: &BTreeMap) -> u8 { lines.values().map(|l| l.outline.min(MAX_LEVEL)).max().unwrap_or(0) } -/// Every group, ordered by where it ends (inner groups before the group around them). +/// Every group, with its summary line after the detail; see [`groups_with`]. pub fn groups(lines: &BTreeMap) -> Vec { + groups_with(lines, false) +} + +/// Every group, ordered by where it ends (inner groups before the group around them), with its +/// summary line before the detail when `summary_before` is set. +pub fn groups_with(lines: &BTreeMap, summary_before: bool) -> Vec { let mut out = Vec::new(); // Open group per level (index level - 1): start line and whether all its lines are hidden. let mut open: [Option<(u32, bool)>; MAX_LEVEL as usize] = [None; MAX_LEVEL as usize]; @@ -47,7 +66,7 @@ pub fn groups(lines: &BTreeMap) -> Vec { if let Some(slot) = open.get_mut(level as usize - 1) && let Some((start, hidden)) = slot.take() { - out.push(OutlineGroup { level, start, end, collapsed: hidden }); + out.push(OutlineGroup { level, start, end, collapsed: hidden, summary_before }); } } }; @@ -102,9 +121,9 @@ mod tests { assert_eq!( g, vec![ - OutlineGroup { level: 2, start: 5, end: 6, collapsed: false }, - OutlineGroup { level: 1, start: 4, end: 7, collapsed: false }, - OutlineGroup { level: 1, start: 11, end: 12, collapsed: true }, + OutlineGroup { level: 2, start: 5, end: 6, collapsed: false, summary_before: false }, + OutlineGroup { level: 1, start: 4, end: 7, collapsed: false, summary_before: false }, + OutlineGroup { level: 1, start: 11, end: 12, collapsed: true, summary_before: false }, ] ); assert_eq!(g[1].summary(), 8); @@ -129,5 +148,22 @@ mod tests { assert_eq!(g.len(), 7); assert!(g.iter().all(|g| g.start == u32::MAX - 1 && g.end == u32::MAX)); assert_eq!(g[0].summary(), u32::MAX); + assert!(!g[0].has_summary()); + } + + #[test] + fn summary_before_the_detail() { + // Rows 5..8 at level 1 with 6..7 at level 2: the summaries are rows 4 and 5. + let l = lines(&[(4, 1, false), (5, 2, false), (6, 2, false), (7, 1, false), (0, 1, false)]); + let g = groups_with(&l, true); + let at = |level: u8, start: u32| g.iter().find(|g| g.level == level && g.start == start).copied(); + assert_eq!(at(1, 4).map(|g| (g.summary(), g.has_summary())), Some((3, true))); + assert_eq!(at(2, 5).map(|g| (g.summary(), g.has_summary())), Some((4, true))); + // A group on the first line has no line before it. + assert_eq!(at(1, 0).map(|g| (g.summary(), g.has_summary())), Some((0, false))); + let mut sh = Sheet::new("S"); + assert!(!summary_before(&sh, true) && !summary_before(&sh, false)); + sh.summary_below = false; + assert!(summary_before(&sh, true) && !summary_before(&sh, false)); } } diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index f7ef8f3be..b43bfa525 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -80,6 +80,13 @@ pub struct Sheet { /// outline itself stays. #[serde(default = "yes")] pub show_outline_symbols: bool, + /// Outline summary rows sit below their detail, and summary columns right of it (Data ▸ + /// Outline ▸ Settings; off puts them above / left). Saved as `outlinePr summaryBelow` and + /// `summaryRight`. + #[serde(default = "yes")] + pub summary_below: bool, + #[serde(default = "yes")] + pub summary_right: bool, pub right_to_left: bool, pub zoom: u16, pub cond_formats: Vec, @@ -149,6 +156,8 @@ impl Sheet { visibility: Visibility::Visible, show_gridlines: true, show_outline_symbols: true, + summary_below: true, + summary_right: true, show_headings: true, show_formulas: false, show_zeros: true, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e83db2fa1..80b647875 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -642,6 +642,20 @@ impl Dialog { ], json!({"groupBy": "A", "function": "sum"}), ), + "outlineSettings" => { + let (below, right) = + app.session.active().and_then(|d| d.wb.active().map(|s| (s.summary_below, s.summary_right))).unwrap_or((true, true)); + Dialog::form( + "outlineSettings", + "Settings", + "data.outlineSettings", + vec![ + Check { key: "summaryBelow", label: "Summary rows below detail" }, + Check { key: "summaryRight", label: "Summary columns to right of detail" }, + ], + json!({"summaryBelow": below, "summaryRight": right}), + ) + } "zoom" => { // The current zoom is pre-selected (Custom when it matches no preset). Fit // selection needs the grid's size, so it is captured here for `view.zoomToSelection`. diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index b485104b1..b635d40b6 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -252,8 +252,8 @@ impl Geo { let (fr, fc) = sh.freeze.unwrap_or((0, 0)); let frozen_w = (ci.start(fc) as f32) * z; let frozen_h = (ri.start(fr) as f32) * z; - let row_outline = Arc::new(crate::outline::Axis::new(&sh.rows)); - let col_outline = Arc::new(crate::outline::Axis::new(&sh.cols)); + let row_outline = Arc::new(crate::outline::Axis::of(sh, true)); + let col_outline = Arc::new(crate::outline::Axis::of(sh, false)); // Show Outline Symbols off: no bands (the outline's hidden lines stay hidden). let symbols = sh.show_outline_symbols; let mut band_w = if symbols { crate::outline::band(row_outline.levels, zoom) } else { 0.0 }; diff --git a/crates/ui-egui/src/outline.rs b/crates/ui-egui/src/outline.rs index 19fd88497..9096d1611 100644 --- a/crates/ui-egui/src/outline.rs +++ b/crates/ui-egui/src/outline.rs @@ -1,7 +1,8 @@ //! The outline band of grouped rows and columns (Data ▸ Group), as in Excel: a strip left of the //! row headers and one above the column headers, one lane per outline level, with a bracket per -//! expanded group, a − / + button on its summary line (below / right of the detail) and the level -//! buttons 1…n+1 in the corner. The bands exist only while the sheet has an outline. +//! expanded group, a − / + button on its summary line (below / right of the detail, or above / +//! left of it when the sheet's outline settings say so) and the level buttons 1…n+1 in the +//! corner. The bands exist only while the sheet has an outline. //! //! Layout and hit-testing are pure functions of [`Geo`] in logical (left-to-right) coordinates, //! so right-to-left sheets get them mirrored with the rest of the grid. Clicks run commands @@ -31,6 +32,12 @@ impl Axis { pub fn new(lines: &BTreeMap) -> Axis { Axis { levels: outline::max_level(lines), groups: outline::groups(lines) } } + /// The outline of a sheet's rows (`rows`) or columns, with its summary lines where the + /// sheet's outline settings put them. + pub fn of(sh: &Sheet, rows: bool) -> Axis { + let lines = if rows { &sh.rows } else { &sh.cols }; + Axis { levels: outline::max_level(lines), groups: outline::groups_with(lines, outline::summary_before(sh, rows)) } + } } /// One lane's width at this zoom (`zoom` is the sheet zoom, 1.0 = 100%). @@ -197,7 +204,7 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout { for grp in &axis.groups { let across = dir.lane(step, grp.level); // The button on the summary line, if that line is on screen. - let button = dir.visible(grp.summary()).map(|(a0, a1)| (a0 + a1) / 2.0).filter(|_| grp.summary() > grp.end); + let button = dir.visible(grp.summary()).map(|(a0, a1)| (a0 + a1) / 2.0).filter(|_| grp.has_summary()); if let Some(mid) = button { out.buttons.push(Button { rect: dir.rect(mid, across, side), action: Action::Toggle { rows: dir.rows, group: *grp } }); } @@ -207,6 +214,18 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout { // The bracket, over the part of the detail that is on screen. let first = dir.first_visible(grp.start, grp.end); let Some((first_line, (a0, _))) = first else { continue }; + if grp.summary_before { + // From the button down (or across) to the last detail line, with the tick there. + let a0 = button.map_or(a0, |mid| mid + side / 2.0); + let end = dir.start(grp.end) + dir.size(grp.end); + let a1 = end.min(dir.hi); + if a1 <= a0 { + continue; + } + let tick = (end <= dir.hi).then(|| [dir.point(a1 - 0.5, across), dir.point(a1 - 0.5, across + side / 2.0)]); + out.brackets.push(Bracket { line: [dir.point(a0, across), dir.point(a1 - 0.5, across)], tick }); + continue; + } let a1 = match button { Some(mid) => mid - side / 2.0, None => dir.start(grp.summary()).min(dir.hi), @@ -381,6 +400,41 @@ mod tests { assert_eq!(outer.action.label(), "\u{2212}"); } + #[test] + fn summaries_above_and_left_take_the_toggles_and_the_bracket_ticks_move_to_the_end() { + let mut sh = sheet(); + sh.summary_below = false; + sh.summary_right = false; + let g = geo(&sh); + let l = layout(&g, &sh); + let toggle = |rows: bool, level: u8| { + l.buttons.iter().find(|b| matches!(b.action, Action::Toggle { rows: r, group } if r == rows && group.level == level)).unwrap() + }; + // Rows 5:8: the outer − is on row 4 (index 3), the inner one (rows 6:7) on row 5. + let outer = toggle(true, 1); + assert!(g.cell_rect(&sh, CellRef::new(3, 0)).y_range().contains(outer.rect.center().y)); + assert!(g.cell_rect(&sh, CellRef::new(4, 0)).y_range().contains(toggle(true, 2).rect.center().y)); + // Column group C:E: its − is above column B. + let col = toggle(false, 1); + assert!(g.cell_rect(&sh, CellRef::new(0, 1)).x_range().contains(col.rect.center().x)); + // The bracket runs from the button to the end of row 8, with the tick there. + assert_eq!(l.brackets.len(), 3); + let b = l.brackets.iter().find(|b| (b.line[0].x - outer.rect.center().x).abs() < 0.01).unwrap(); + let row8 = g.cell_rect(&sh, CellRef::new(7, 0)); + assert!((b.line[0].y - outer.rect.bottom()).abs() < 0.01 && (b.line[1].y - (row8.bottom() - 0.5)).abs() < 1.0); + assert!(b.tick.is_some_and(|t| (t[0].y - b.line[1].y).abs() < 0.01 && t[1].x > t[0].x)); + // A group on the first row has no row above it: no toggle, still a bracket. + let mut sh = Sheet::new("S"); + sh.summary_below = false; + for r in 0..=2 { + sh.rows.entry(r).or_default().outline = 1; + } + let g = geo(&sh); + let l = layout(&g, &sh); + assert!(!l.buttons.iter().any(|b| matches!(b.action, Action::Toggle { .. }))); + assert_eq!(l.brackets.len(), 1); + } + #[test] fn collapsed_groups_show_plus_and_no_bracket() { let mut sh = sheet(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index ab9cc311d..3519be8f1 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2176,6 +2176,9 @@ fn data(app: &mut SheetApp, ui: &mut Ui) { if small_button(ui, Icon::Minus, "Hide Detail", "Hide Detail", false).clicked() { act(app, "data.hideDetail", json!({})); } + if small_button(ui, Icon::Settings, "Settings", "Outline Settings", false).clicked() { + app.open_dialog("outlineSettings", json!({})); + } }); sep(ui); if big_button(ui, Icon::ChartBar, "Data\nAnalysis", "Data Analysis", false).clicked() { diff --git a/crates/ui-egui/tests/it/outline.rs b/crates/ui-egui/tests/it/outline.rs index 7b507af3e..57aa7b740 100644 --- a/crates/ui-egui/tests/it/outline.rs +++ b/crates/ui-egui/tests/it/outline.rs @@ -1,4 +1,5 @@ -//! The outline band of grouped rows and columns: − / + and level buttons (#373). +//! The outline band of grouped rows and columns: − / + and level buttons (#373), and the +//! Outline Settings dialog that moves the summary rows above their detail. use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; use gridcraft_engine::Session; @@ -90,3 +91,30 @@ fn outline_buttons_collapse_expand_and_undo() { fn outline_buttons_work_on_right_to_left_sheets() { outline_buttons_collapse_and_expand(true); } + +#[test] +fn settings_dialog_puts_summary_rows_above_their_detail() { + use egui_kittest::kittest::Queryable as _; + let mut h = harness(); + h.state_mut().session.run("data.group", json!({"rows": "5:8"})).unwrap(); + h.state_mut().open_dialog("outlineSettings", json!({})); + h.run_steps(4); + h.get_by_label("Summary rows below detail").click(); + h.run_steps(3); + h.get_by_label_contains("OK").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + let sh = h.state().session.active().unwrap().wb.active().unwrap().clone(); + assert!(!sh.summary_below && sh.summary_right); + + // The − now sits on row 4, above the detail; it hides rows 5:8 and marks row 4 collapsed. + let minus = button(&h, toggle(true, 1)); + let geo = h.state().grid.geo.clone().unwrap(); + let row4 = geo.cell_rect(&sh, gridcraft_engine::core::CellRef::new(3, 0)); + assert!(row4.y_range().contains(minus.y), "{minus:?} is not on row 4 ({row4:?})"); + click(&mut h, minus); + assert_eq!(hidden_rows(&h), [4, 5, 6, 7]); + let sh = h.state().session.active().unwrap().wb.active().unwrap().clone(); + assert!(sh.rows.get(&3).is_some_and(|l| l.collapsed)); + assert!(!sh.rows.get(&8).is_some_and(|l| l.collapsed)); +} diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 7cb2e8c22..aadd1141e 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -120,6 +120,10 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result read_views(e, &mut sheet), diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index e0e863ec3..52dc979e6 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -138,11 +138,21 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( let mut s = format!("{XML_DECL}"); // sheetPr let fit = sheet.print.fit_to.is_some(); - if sheet.tab_color.is_some() || fit { + let summary = !sheet.summary_below || !sheet.summary_right; + if sheet.tab_color.is_some() || fit || summary { s.push_str(""); if let Some(c) = &sheet.tab_color { let _ = write!(s, "", color_attrs(c)); } + if summary { + s.push_str(""); + } if fit { s.push_str(""); } diff --git a/crates/xlsx/src/tests/malformed.rs b/crates/xlsx/src/tests/malformed.rs index 91b5d4b9c..4ee815e86 100644 --- a/crates/xlsx/src/tests/malformed.rs +++ b/crates/xlsx/src/tests/malformed.rs @@ -343,3 +343,31 @@ fn outline_symbols_flag_round_trips() { let (wb, _) = read_xlsx(&minimal("", &[], "", "")).unwrap(); assert!(wb.sheet(0).unwrap().show_outline_symbols); } + +/// Outline Settings (`outlinePr summaryBelow="0" summaryRight="0"`) are read and written back, +/// between the tab color and the page setup properties. +#[test] +fn outline_summary_directions_round_trip() { + let body = r#""#; + let (wb, _) = read_xlsx(&minimal(body, &[], "", "")).unwrap(); + let sh = wb.sheet(0).unwrap(); + assert!(!sh.summary_below && sh.summary_right); + let bytes = write_xlsx(&wb).unwrap(); + let xml = String::from_utf8(crate::package::Package::open(&bytes).unwrap().read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap(); + let (tab, pr, fit) = (xml.find(""), xml.find(""#; + let (wb, _) = read_xlsx(&minimal(body, &[], "", "")).unwrap(); + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + let sh = back.sheet(0).unwrap(); + assert!(sh.summary_below && !sh.summary_right); + // Absent means below and right, and the defaults write no `outlinePr`. + let (wb, _) = read_xlsx(&minimal("", &[], "", "")).unwrap(); + let sh = wb.sheet(0).unwrap(); + assert!(sh.summary_below && sh.summary_right); + let bytes = write_xlsx(&wb).unwrap(); + let xml = String::from_utf8(crate::package::Package::open(&bytes).unwrap().read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap(); + assert!(!xml.contains("outlinePr"), "{xml}"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5d5f1efee..d4174ab13 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-23 · **Change:** minor (New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Outline Settings: summary rows above and summary columns left of their detail; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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; 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 @@ -59,7 +59,7 @@ this logic, with one undo step. | AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) | | Header resize and double-click AutoFit | done (#42, #88 for header dividers) | | Data › Advanced Filter | dialog offers in-place or copy-to filtering, list and optional criteria ranges, and unique records; Alt+A,Q opens it (#1622). The engine's copy-to path clears only the result footprint and rejects worksheet overflow (#1031). | -| Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), View › Show Outline Symbols (Ctrl+8) hides the bands and is saved with the sheet, and a block of cells still groups rows without the Rows / Columns dialog | +| Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; Data › Outline › Settings (`data.outlineSettings`) puts summary rows above and summary columns left of their detail, saved as `outlinePr` (the dialog's Automatic styles check box and Apply Styles button are missing), View › Show Outline Symbols (Ctrl+8) hides the bands and is saved with the sheet, and a block of cells still groups rows without the Rows / Columns dialog | | Reorder selected whole columns by dragging their headers | done (10-11) | | Freeze panes, split panes | freeze done; split to verify | | A Table's header names and filter buttons in the column headings once its header row scrolls away | done (#1423): with the active cell in the Table, as in Excel; the button opens the column's filter menu, the rest of the heading still selects the column. Plain AutoFilter ranges keep their letters (Excel does the same) | @@ -178,6 +178,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Outline Settings (Data ▸ Outline ▸ Settings, `data.outlineSettings`): clearing Summary rows below detail or Summary columns to right of detail puts the − / + buttons on the line above or left of each group, and Hide Detail, Show Detail and the level buttons mark that line collapsed. Changing a direction moves the marks of collapsed groups. Read and written as `outlinePr summaryBelow` / `summaryRight` | | 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) | | 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is | | 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) | From 9425358faa9b138158a2a95261f0cd6cebaae390 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 12:16:24 -0700 Subject: [PATCH 39/56] docs: Different First Page and Different Odd & Even Pages are no longer listed as missing The Page Layout view row in docs/ui-parity.md listed both options as missing. The Header & Footer tab has had both check boxes since #344, and the first page and the even pages print and show their own header and footer. The row now says so and lists Scale with Document and Align with Page Margins as missing instead: XLSX keeps both settings, but no control shows them and printing does not read them. --- docs/ui-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5d5f1efee..e6b7b8ea5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -32,7 +32,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | Dark mode, system appearance | yes | yes | done (10-09) | -| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792, with header and footer margins); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | +| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab, whose Different First Page and Different Odd & Even Pages give the first page and the even pages their own header and footer (#344) | margins drag on the rulers (#792, with header and footer margins); header/footer pictures and the Scale with Document / Align with Page Margins check boxes missing (XLSX keeps both settings) (1–3 h) | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | ## Grid interaction From b745c136b22d7fd4c7dbea0f2c1c9b9f855d9e23 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 12:54:07 -0700 Subject: [PATCH 40/56] Headers and footers: printing follows the Scale with Document and Align with Page Margins check boxes The Header & Footer tab now has the Scale with Document and Align with Page Margins check boxes, and pageLayout.headerFooter takes scaleWithDoc and alignWithMargins. XLSX already kept both settings, but nothing else read them. With Scale with Document on, header and footer text prints at the sheet's print scale. With it off, the text keeps its point size. With Align with Page Margins on, the left and right sections line up with the page's side margins. With it off, they start 0.7 inch from the paper's edges, or a quarter of the page width on paper narrower than 2.8 inches. The Page Layout view draws headers and footers and takes clicks on them the same way. Both settings are on by default, so a sheet printed at a scale other than 100% now prints its header and footer text at that scale. --- crates/engine/src/cmd/print.rs | 58 ++++++++++++++++++++- crates/engine/src/cmd/view.rs | 10 +++- crates/engine/src/paper.rs | 40 +++++++++++++- crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/el.rs | 2 + crates/ui-egui/src/i18n/es.rs | 2 + crates/ui-egui/src/i18n/fr.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 2 + crates/ui-egui/src/i18n/ko.rs | 2 + crates/ui-egui/src/i18n/nl.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt.rs | 2 + crates/ui-egui/src/i18n/ro.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/sr.rs | 2 + crates/ui-egui/src/i18n/tr.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/i18n/vi.rs | 2 + crates/ui-egui/src/i18n/zh.rs | 2 + crates/ui-egui/src/paper_view.rs | 36 ++++++++----- crates/ui-egui/tests/it/page_layout_view.rs | 22 ++++++++ docs/ui-parity.md | 2 +- 24 files changed, 185 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 1c91518e2..52c975688 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -139,8 +139,26 @@ impl SheetLayout { let [l, r, t, b, ..] = self.margins; ((self.page_w - l - r).max(36.0), (self.page_h - t - b).max(36.0)) } + /// Left and right margins, in points, that header and footer sections line up with: the + /// page's side margins with Align with Page Margins on, otherwise 0.7 inch on each side, at + /// most a quarter of the page width. + pub(crate) fn hf_sides(&self) -> (f32, f32) { + if self.settings.hf_align_with_margins { + (self.margins[0], self.margins[1]) + } else { + let side = HF_FIXED_SIDE_MARGIN.min(self.page_w / 4.0); + (side, side) + } + } + /// Scale of header and footer text: the print scale with Scale with Document on, otherwise 1. + pub(crate) fn hf_text_scale(&self) -> f32 { + if self.settings.hf_scale_with_doc { self.scale } else { 1.0 } + } } +/// Side margin of headers and footers, in points, when Align with Page Margins is off. +const HF_FIXED_SIDE_MARGIN: f32 = 0.7 * 72.0; + /// Options shared by the print commands. #[derive(Clone, Debug, Default)] pub struct PrintOptions { @@ -1476,7 +1494,9 @@ fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at: } fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::HfPage, cx: &HfContext) { - let [l, r, _, _, hm, fm] = lay.margins; + let [_, _, _, _, hm, fm] = lay.margins; + let (l, r) = lay.hf_sides(); + let text_scale = lay.hf_text_scale(); let pw = lay.page_w; let ph = lay.page_h; for (spec, header) in [(lay.settings.hf(which, false), true), (lay.settings.hf(which, true), false)] { @@ -1488,7 +1508,7 @@ fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model:: if sec.text.trim().is_empty() { continue; } - let size = sec.size.unwrap_or(10.0).clamp(1.0, 72.0); + let size = sec.size.unwrap_or(10.0).clamp(1.0, 72.0) * text_scale; let font = Font::styled(gridcraft_pdf::Family::Sans, sec.bold, sec.italic); let lines: Vec<&str> = sec.text.split('\n').take(20).collect(); let n = lines.len() as f32; @@ -2021,6 +2041,40 @@ mod tests { assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj")); } + /// The x position and font size of the first `(text) Tj` in a PDF. + fn text_at(pdf: &[u8], text: &str) -> Option<(f32, f32)> { + let s = String::from_utf8_lossy(pdf); + let before = &s[..s.find(&format!("({text}) Tj"))?]; + let tf = before.rfind(" Tf")?; + let size = before[..tf].rsplit(' ').next()?.parse().ok()?; + let tm = before.rfind(" Tm")?; + let mut nums = before[..tm].rsplit([' ', '\n']); + let _y = nums.next()?; + let x = nums.next()?.parse().ok()?; + Some((x, size)) + } + + #[test] + fn headers_scale_with_the_sheet_and_align_with_its_margins_unless_turned_off() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap(); + s.execute("pageLayout.pageSetup", json!({"scale": 50, "left": 1.5, "right": 1.5})).unwrap(); + s.execute("pageLayout.headerFooter", json!({"header": "&LLeft&C&14Middle"})).unwrap(); + // Both on (the default): the text prints at half size, from the 1.5-inch left margin. + let (pdf, _) = pdf_of(&mut s, json!({})); + assert_eq!(text_at(&pdf, "Left"), Some((108.0, 5.0))); + assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(7.0)); + let r = s.execute("pageLayout.headerFooter", json!({"scaleWithDoc": false, "alignWithMargins": false})).unwrap(); + assert_eq!((&r["scaleWithDoc"], &r["alignWithMargins"]), (&json!(false), &json!(false))); + // Both off: full size, 0.7 inch from the paper's left edge. + let (pdf, _) = pdf_of(&mut s, json!({})); + assert_eq!(text_at(&pdf, "Left"), Some((50.4, 10.0))); + assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(14.0)); + s.execute("edit.undo", json!({})).unwrap(); + let pr = &s.doc().unwrap().wb.sheet(0).unwrap().print; + assert!(pr.hf_scale_with_doc && pr.hf_align_with_margins); + } + #[test] fn pdf_prints_cjk_text_instead_of_question_marks() { let mut s = s(); diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index b42aec9a6..60d907f40 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -115,7 +115,7 @@ pub fn specs() -> Vec { "Header & Footer", ["Insert", "Text"], None, - "{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\", differentFirst?: bool, differentOddEven?: bool, firstHeader?, firstFooter?, evenHeader?, evenFooter?} or one section: {sheet?, page?: odd|even|first, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer, differentFirst, differentOddEven, firstHeader, firstFooter, evenHeader, evenFooter}", + "{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\", differentFirst?: bool, differentOddEven?: bool, scaleWithDoc?: bool, alignWithMargins?: bool, firstHeader?, firstFooter?, evenHeader?, evenFooter?} or one section: {sheet?, page?: odd|even|first, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer, differentFirst, differentOddEven, scaleWithDoc, alignWithMargins, firstHeader, firstFooter, evenHeader, evenFooter}", has_doc, header_footer ), @@ -392,6 +392,12 @@ fn header_footer(s: &mut Session, p: &Json) -> Result { if let Some(on) = bool_param(p, "differentOddEven") { pr.different_odd_even = on; } + if let Some(on) = bool_param(p, "scaleWithDoc") { + pr.hf_scale_with_doc = on; + } + if let Some(on) = bool_param(p, "alignWithMargins") { + pr.hf_align_with_margins = on; + } if let Some((page, footer, idx, text)) = §ion { let spec = pr.hf_mut(*page, *footer); let mut parts = super::print::split_header_footer(spec); @@ -405,6 +411,8 @@ fn header_footer(s: &mut Session, p: &Json) -> Result { "footer": pr.footer, "differentFirst": pr.different_first, "differentOddEven": pr.different_odd_even, + "scaleWithDoc": pr.hf_scale_with_doc, + "alignWithMargins": pr.hf_align_with_margins, "firstHeader": pr.first_header, "firstFooter": pr.first_footer, "evenHeader": pr.even_header, diff --git a/crates/engine/src/paper.rs b/crates/engine/src/paper.rs index 01065c09e..0800f4fc7 100644 --- a/crates/engine/src/paper.rs +++ b/crates/engine/src/paper.rs @@ -29,6 +29,12 @@ pub struct PageGrid { pub gap: f64, /// Print scale (1.0 = 100%): screen sizes of cells are `zoom × scale`. pub scale: f32, + /// Left and right margins of headers and footers in layout units (the side margins, or a + /// fixed 0.7 inch, at most a quarter of the page width, with Align with Page Margins off). + pub hf_sides: [f64; 2], + /// Layout units per sheet pixel of header and footer text: 1 with Scale with Document on, + /// otherwise `1 / scale`, so the text keeps its point size on paper. + pub hf_text_k: f32, /// Paper size in points (after orientation), for the rulers. pub paper_pt: (f32, f32), pub row_bands: Vec<(u32, u32)>, @@ -65,6 +71,8 @@ impl PageGrid { let s = f64::from(lay.scale.clamp(0.1, 4.0)); let to_layout = |pt: f32| f64::from(pt) / f64::from(PX_TO_PT) / s; let margins = lay.margins.map(to_layout); + let (hf_left, hf_right) = lay.hf_sides(); + let hf_text_k = lay.hf_text_scale() / s as f32; let band_range = |bands: &[(u32, u32)], lo: u32, hi: u32| { let first = bands.iter().position(|b| b.1 >= lo).unwrap_or(0); let last = bands.iter().rposition(|b| b.0 <= hi).unwrap_or(first).max(first); @@ -79,6 +87,8 @@ impl PageGrid { margins, gap: f64::from(PAGE_GAP) / s, scale: lay.scale, + hf_sides: [to_layout(hf_left), to_layout(hf_right)], + hf_text_k, paper_pt: (lay.page_w, lay.page_h), rows_complete: row_bands.last().is_some_and(|b| b.1 >= MAX_ROWS - 1), cols_complete: col_bands.last().is_some_and(|b| b.1 >= MAX_COLS - 1), @@ -174,11 +184,12 @@ impl PageGrid { } /// The header or footer section under the point: the top or bottom margin of a page, split - /// into thirds between the side margins. + /// into thirds between the header and footer side margins. pub fn hf_zone_at(&self, x: f64, y: f64) -> Option<((usize, usize), HfZone)> { let (i, j) = self.page_at(x, y)?; let (px, py, _, _) = self.page_rect(i, j); - let [l, r, t, b, ..] = self.margins; + let [_, _, t, b, ..] = self.margins; + let [l, r] = self.hf_sides; let footer = if y < py + t { false } else if y >= py + self.page_h - b { @@ -301,6 +312,31 @@ mod tests { assert!(g.is_data_page(0, 0) && !g.is_data_page(1, 0)); } + #[test] + fn header_zones_and_text_follow_scale_with_document_and_align_with_page_margins() { + let mut wb = wb_with(10, 3); + if let Some(sh) = wb.sheet_mut(0) { + sh.print.scale = 50; + sh.print.margins = [2.0, 2.0, 0.75, 0.75, 0.3, 0.3]; + } + let g = PageGrid::build(&wb, 0, 50, 20).unwrap(); + let (px, py, _, _) = g.page_rect(0, 0); + // Both on: the sections sit between the 2-inch side margins and the text scales with the cells. + assert_eq!(g.hf_sides, [g.margins[0], g.margins[1]]); + assert!((g.hf_text_k - 1.0).abs() < 1e-6); + assert_eq!(g.hf_zone_at(px + 1000.0, py + 3.0).map(|z| z.1.section), Some(2)); + if let Some(sh) = wb.sheet_mut(0) { + sh.print.hf_scale_with_doc = false; + sh.print.hf_align_with_margins = false; + } + let g = PageGrid::build(&wb, 0, 50, 20).unwrap(); + // Both off: 0.7 inch (67.2 sheet pixels, 134.4 layout units at 50%) on each side, and the + // text keeps its point size on paper. + assert!(g.hf_sides.iter().all(|s| (s - 134.4).abs() < 1e-3), "{:?}", g.hf_sides); + assert!((g.hf_text_k - 2.0).abs() < 1e-6); + assert_eq!(g.hf_zone_at(px + 1000.0, py + 3.0).map(|z| z.1.section), Some(1)); + } + #[test] fn print_scale_and_hostile_settings_stay_bounded() { let mut wb = wb_with(3, 3); diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index fc9181feb..c622a1831 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -206,6 +206,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Footer" => "Zápatí", "Different First Page" => "Jiná první stránka", "Different Odd & Even Pages" => "Jiné liché a sudé stránky", + "Scale with Document" => "Měnit velikost s dokumentem", + "Align with Page Margins" => "Zarovnat k okrajům stránky", "Go to Footer" => "Přejít na zápatí", "Go to Header" => "Přejít na záhlaví", "Go to\nFooter" => "Přejít\nna zápatí", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 61c2292f4..8296909c8 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -210,6 +210,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Footer" => "Fußzeile", "Different First Page" => "Erste Seite anders", "Different Odd & Even Pages" => "Gerade und ungerade Seiten anders", + "Scale with Document" => "Mit Dokument skalieren", + "Align with Page Margins" => "An Seitenrändern ausrichten", "Go to Footer" => "Zur Fußzeile wechseln", "Go to Header" => "Zur Kopfzeile wechseln", "Go to\nFooter" => "Zur\nFußzeile", diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index e54416a6f..bd972471d 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -202,6 +202,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Footer" => "Υποσέλιδο", "Different First Page" => "Διαφορετική πρώτη σελίδα", "Different Odd & Even Pages" => "Διαφορετικές μονές και ζυγές σελίδες", + "Scale with Document" => "Κλιμάκωση με το έγγραφο", + "Align with Page Margins" => "Στοίχιση με τα περιθώρια σελίδας", "Go to Footer" => "Μετάβαση στο υποσέλιδο", "Go to Header" => "Μετάβαση στην κεφαλίδα", "Go to\nFooter" => "Μετάβαση στο\nυποσέλιδο", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 36898c098..5a1770438 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -209,6 +209,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Footer" => "Pie de página", "Different First Page" => "Primera página diferente", "Different Odd & Even Pages" => "Páginas pares e impares diferentes", + "Scale with Document" => "Escalar con el documento", + "Align with Page Margins" => "Alinear con los márgenes de página", "Go to Footer" => "Ir al pie de página", "Go to Header" => "Ir al encabezado", "Go to\nFooter" => "Ir al pie\nde página", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 317647c2f..9927e060f 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -4,6 +4,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Different First Page" => "Première page différente", "Different Odd & Even Pages" => "Pages paires et impaires différentes", + "Scale with Document" => "Mettre à l'échelle avec le document", + "Align with Page Margins" => "Aligner sur les marges de la page", "Paste" => "Coller", "Paste Values" => "Coller les valeurs", "Paste Formulas" => "Coller les formules", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 65f356669..d0a06adfa 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -327,6 +327,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "Vai a intestazione", "Different First Page" => "Prima pagina diversa", "Different Odd & Even Pages" => "Pagine pari e dispari diverse", + "Scale with Document" => "Ridimensiona con il documento", + "Align with Page Margins" => "Allinea ai margini della pagina", "Go to Footer" => "Vai a piè di pagina", "Ruler" => "Righello", // File › Backstage view. diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 2df4cfc11..c78a111f5 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -331,6 +331,8 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Go to Header", "ヘッダーに移動"), ("Different First Page", "先頭ページのみ別指定"), ("Different Odd & Even Pages", "奇数/偶数ページ別指定"), + ("Scale with Document", "文書に合わせて拡大/縮小"), + ("Align with Page Margins", "ページ余白に合わせる"), ("Go to Footer", "フッターに移動"), ("Ruler", "ルーラー"), ("File", "ファイル"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index d3c440cf3..35195e2ae 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -332,6 +332,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "머리글로 이동", "Different First Page" => "첫 페이지를 다르게 지정", "Different Odd & Even Pages" => "짝수와 홀수 페이지를 다르게 지정", + "Scale with Document" => "문서에 맞게 배율 조정", + "Align with Page Margins" => "페이지 여백에 맞추기", "Go to Footer" => "바닥글로 이동", "Ruler" => "눈금자", // File › Backstage view. diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index c1f952357..b8c995057 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -326,6 +326,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "Naar koptekst", "Different First Page" => "Eerste pagina afwijkend", "Different Odd & Even Pages" => "Even en oneven pagina's afwijkend", + "Scale with Document" => "Meeschalen met document", + "Align with Page Margins" => "Uitlijnen op paginamarges", "Go to Footer" => "Naar voettekst", "Ruler" => "Liniaal", "File" => "Bestand", diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index e3185d55a..9b4c8b90a 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -322,6 +322,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "Przejdź do nagłówka", "Different First Page" => "Inna pierwsza strona", "Different Odd & Even Pages" => "Inne strony nieparzyste i parzyste", + "Scale with Document" => "Skaluj z dokumentem", + "Align with Page Margins" => "Wyrównaj do marginesów strony", "Go to Footer" => "Przejdź do stopki", "Ruler" => "Linijka", "File" => "Plik", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1b56ff504..6e6349657 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -328,6 +328,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "Ir para o Cabeçalho", "Different First Page" => "Primeira Página Diferente", "Different Odd & Even Pages" => "Páginas Pares e Ímpares Diferentes", + "Scale with Document" => "Dimensionar com o Documento", + "Align with Page Margins" => "Alinhar com as Margens da Página", "Go to Footer" => "Ir para o Rodapé", "Ruler" => "Régua", // File › Backstage view. diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 9e0d92516..abd3faa4c 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -329,6 +329,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "Salt la antet", "Different First Page" => "Prima pagină diferită", "Different Odd & Even Pages" => "Pagini pare și impare diferite", + "Scale with Document" => "Scalare cu documentul", + "Align with Page Margins" => "Aliniere la marginile paginii", "Go to Footer" => "Salt la subsol", "Ruler" => "Riglă", "File" => "Fișier", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index ce675701e..dfbc945c4 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -332,6 +332,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "Перейти к верхнему колонтитулу", "Different First Page" => "Особый колонтитул для первой страницы", "Different Odd & Even Pages" => "Разные колонтитулы для четных и нечетных страниц", + "Scale with Document" => "Масштабировать вместе с документом", + "Align with Page Margins" => "Выровнять по полям страницы", "Go to Footer" => "Перейти к нижнему колонтитулу", "Ruler" => "Линейка", // File › Backstage view. diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index c71ce53a8..71c600feb 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -325,6 +325,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "Idi na zaglavlje", "Different First Page" => "Drugačija prva stranica", "Different Odd & Even Pages" => "Drugačije neparne i parne stranice", + "Scale with Document" => "Skaliraj sa dokumentom", + "Align with Page Margins" => "Poravnaj sa marginama stranice", "Go to Footer" => "Idi na podnožje", "Ruler" => "Lenjir", "File" => "Datoteka", diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index ce2bc8e1e..86f950375 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -205,6 +205,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Footer" => "Altbilgi", "Different First Page" => "İlk Sayfada Farklı", "Different Odd & Even Pages" => "Tek ve Çift Sayfalarda Farklı", + "Scale with Document" => "Belgeyle Ölçeklendir", + "Align with Page Margins" => "Sayfa Kenar Boşluklarıyla Hizala", "Go to Footer" => "Altbilgiye Git", "Go to Header" => "Üstbilgiye Git", "Go to\nFooter" => "Altbilgiye\nGit", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 97659cc12..24a3c0756 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -325,6 +325,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "Перейти до верхнього колонтитула", "Different First Page" => "Окремий колонтитул для першої сторінки", "Different Odd & Even Pages" => "Різні колонтитули для парних і непарних сторінок", + "Scale with Document" => "Масштабувати разом із документом", + "Align with Page Margins" => "Вирівняти за полями сторінки", "Go to Footer" => "Перейти до нижнього колонтитула", "Ruler" => "Лінійка", // File › Backstage view. diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index 94c32b3a5..11fe14fcf 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -330,6 +330,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "Đi tới đầu trang", "Different First Page" => "Trang đầu khác", "Different Odd & Even Pages" => "Trang chẵn lẻ khác nhau", + "Scale with Document" => "Co giãn theo tài liệu", + "Align with Page Margins" => "Căn theo lề trang", "Go to Footer" => "Đi tới chân trang", "Ruler" => "Thước", // File › Backstage view. diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index c9d3ecb7d..c03dfb384 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -330,6 +330,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Go to Header" => "转至页眉", "Different First Page" => "首页不同", "Different Odd & Even Pages" => "奇偶页不同", + "Scale with Document" => "随文档缩放", + "Align with Page Margins" => "与页边距对齐", "Go to Footer" => "转至页脚", "Ruler" => "标尺", // File › Backstage view. diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 621a97903..d0005d76d 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -182,7 +182,8 @@ fn tr<'a>(app: &SheetApp, s: &'a str) -> &'a str { /// Section rectangles (left, centre, right) of a page's header or footer on screen. fn section_rects(geo: &Geo, pg: &PageGrid, (i, j): (usize, usize), footer: bool) -> [Rect; 3] { let (px, py, w, h) = pg.page_rect(i, j); - let [l, r, t, b, ..] = pg.margins; + let [_, _, t, b, ..] = pg.margins; + let [l, r] = pg.hf_sides; let (y0, y1) = if footer { (py + h - b, py + h) } else { (py, py + t) }; let inner = (w - l - r).max(3.0); let third = inner / 3.0; @@ -195,8 +196,8 @@ fn section_rects(geo: &Geo, pg: &PageGrid, (i, j): (usize, usize), footer: bool) out } -fn hf_font(geo: &Geo, size_pt: Option, bold: bool) -> FontId { - let px = size_pt.unwrap_or(10.0).clamp(1.0, 72.0) * 96.0 / 72.0 * geo.z; +fn hf_font(geo: &Geo, pg: &PageGrid, size_pt: Option, bold: bool) -> FontId { + let px = size_pt.unwrap_or(10.0).clamp(1.0, 72.0) * 96.0 / 72.0 * pg.hf_text_k * geo.z; let px = px.clamp(4.0, 120.0); if bold { theme::ui_bold(px) } else { theme::ui_font(px) } } @@ -235,7 +236,7 @@ pub(crate) fn paint_headers_footers(app: &SheetApp, ui: &egui::Ui, p: &Painter, if spec.is_empty() { if hovered { let label = if footer { "Add footer" } else { "Add header" }; - p.text(rects[1].center(), Align2::CENTER_CENTER, tr(app, label), hf_font(geo, Some(10.0), false), Color32::from_gray(120)); + p.text(rects[1].center(), Align2::CENTER_CENTER, tr(app, label), hf_font(geo, &pg, Some(10.0), false), Color32::from_gray(120)); } continue; } @@ -255,7 +256,7 @@ pub(crate) fn paint_headers_footers(app: &SheetApp, ui: &egui::Ui, p: &Painter, if sec.text.trim().is_empty() { continue; } - let font = hf_font(geo, sec.size, sec.bold); + let font = hf_font(geo, &pg, sec.size, sec.bold); let r = rects[k]; let (anchor, x) = match k { 0 => (Align2::LEFT_TOP, r.left()), @@ -326,8 +327,9 @@ pub fn start_editing(app: &mut SheetApp) { app.grid.hf_start = true; } -/// Turns Different First Page / Different Odd & Even Pages on or off, keeping the edit on the -/// same page (which may now show another header). +/// Turns a Header & Footer option (Different First Page, Different Odd & Even Pages, Scale with +/// Document, Align with Page Margins) on or off, keeping the edit on the same page (which may +/// now show another header). fn set_option(app: &mut SheetApp, key: &str, on: bool) { let Some(e) = app.grid.hf_edit.as_ref() else { return }; let (sheet, page, zone) = (e.sheet, e.page, HfZone { footer: e.footer, section: e.section }); @@ -446,7 +448,7 @@ fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) { let rects = section_rects(geo, pg, e.page, e.footer); let p = ui.painter().with_clip_rect(geo.cells); let t = Tokens::get(ui.ctx()); - let font = hf_font(geo, Some(10.0), false); + let font = hf_font(geo, pg, Some(10.0), false); let mut switch_to = None; for (k, r) in rects.iter().enumerate() { if !r.intersects(geo.cells) { @@ -893,11 +895,10 @@ pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) { go_to(app, true); } sep(ui); - let (first, odd_even) = app - .session - .active() - .and_then(|d| d.wb.sheet(app.grid.hf_edit.as_ref().map_or(0, |e| e.sheet)).map(|s| (s.print.different_first, s.print.different_odd_even))) - .unwrap_or_default(); + let (first, odd_even, scale_doc, align) = + app.session.active().and_then(|d| d.wb.sheet(app.grid.hf_edit.as_ref().map_or(0, |e| e.sheet))).map_or((false, false, true, true), |s| { + (s.print.different_first, s.print.different_odd_even, s.print.hf_scale_with_doc, s.print.hf_align_with_margins) + }); ui.vertical(|ui| { let (mut f, mut o) = (first, odd_even); if ui.checkbox(&mut f, tr(app, "Different First Page")).changed() { @@ -907,6 +908,15 @@ pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) { set_option(app, "differentOddEven", o); } }); + ui.vertical(|ui| { + let (mut sd, mut am) = (scale_doc, align); + if ui.checkbox(&mut sd, tr(app, "Scale with Document")).changed() { + set_option(app, "scaleWithDoc", sd); + } + if ui.checkbox(&mut am, tr(app, "Align with Page Margins")).changed() { + set_option(app, "alignWithMargins", am); + } + }); } fn sep(ui: &mut egui::Ui) { diff --git a/crates/ui-egui/tests/it/page_layout_view.rs b/crates/ui-egui/tests/it/page_layout_view.rs index b2e80d0d9..863ad2b08 100644 --- a/crates/ui-egui/tests/it/page_layout_view.rs +++ b/crates/ui-egui/tests/it/page_layout_view.rs @@ -2,6 +2,7 @@ //! transform, and headers are edited in place, committed on a click elsewhere, and undoable. use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::kittest::Queryable; use gridcraft_engine::Session; use gridcraft_engine::core::CellRef; use gridcraft_engine::model::SheetViewMode; @@ -163,6 +164,27 @@ fn the_first_page_edits_its_own_header_when_different_first_page_is_on() { assert_eq!(pr.header, "&LLeft", "the other pages keep their header"); } +#[test] +fn scale_with_document_and_align_with_page_margins_are_check_boxes_on_the_header_and_footer_tab() { + let mut h = fixture(); + gridcraft_ui_egui::paper_view::start_editing(h.state_mut()); + h.run_steps(4); + let options = |h: &Harness| { + let pr = &h.state().session.doc().unwrap().wb.active().unwrap().print; + (pr.hf_scale_with_doc, pr.hf_align_with_margins) + }; + assert_eq!(options(&h), (true, true)); + h.get_by_label("Scale with Document").click(); + h.run_steps(3); + assert_eq!(options(&h), (false, true)); + h.get_by_label("Align with Page Margins").click(); + h.run_steps(3); + assert_eq!(options(&h), (false, false)); + assert!(h.state().grid.hf_edit.is_some(), "the header stays in edit"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(options(&h), (false, true)); +} + #[test] fn insert_header_and_footer_switches_to_page_layout_view() { let mut h = fixture(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5d5f1efee..8cb3ce5ac 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -32,7 +32,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | Dark mode, system appearance | yes | yes | done (10-09) | -| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792, with header and footer margins); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | +| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab, whose Scale with Document and Align with Page Margins set whether header and footer text follows the print scale and the side margins, on paper and in the view | margins drag on the rulers (#792, with header and footer margins); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | ## Grid interaction From 71669dae6d49eae58f3ad7217b25882195007a67 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 14:07:58 -0700 Subject: [PATCH 41/56] Error Checking: its arrow opens a Circular References submenu listing a cell on each loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formulas > Error Checking is now a split button. The button opens the Error Checking dialog as before, and its arrow opens a menu with Error Checking… and Circular References. Circular References lists a cell on each circular reference as Sheet!A1, up to 100, and picking one selects it on its own sheet and scrolls the grid to it. With no circular reference the item is unavailable. The calculation kept only the loops its latest run found, so typing in any cell outside a loop emptied the list, and the status bar's Circular References with it, while the loop was still there. A partial recalculation now keeps the loops among the formulas it does not evaluate. In automatic calculation a loop leaves the list once an edit breaks it. In manual calculation only the edited formulas run, so a listed cell stays until it is edited or the workbook is recalculated. A full recalculation or iterative calculation builds the list again. --- crates/calc/src/recalc.rs | 14 +- crates/calc/src/tests.rs | 26 ++++ crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/el.rs | 1 + crates/ui-egui/src/i18n/es.rs | 1 + crates/ui-egui/src/i18n/fr.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ko.rs | 1 + crates/ui-egui/src/i18n/nl.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt.rs | 1 + crates/ui-egui/src/i18n/ro.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/sr.rs | 1 + crates/ui-egui/src/i18n/tr.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/i18n/vi.rs | 1 + crates/ui-egui/src/i18n/zh.rs | 1 + crates/ui-egui/src/ribbon.rs | 42 +++++- crates/ui-egui/src/widgets.rs | 28 ++++ .../ui-egui/tests/it/error_checking_menu.rs | 129 ++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/ui-parity.md | 1 + 25 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/tests/it/error_checking_menu.rs diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 66374bed2..4650c2073 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -1103,7 +1103,12 @@ impl Calc { } let tw = prof_now(); self.last_recalc_cells += results.len(); - self.circular = cycles; + // The loops this round finds replace the ones listed for the formulas it evaluated. + if !self.circular.is_empty() { + let evaluated: HashSet = dirty.iter().copied().collect(); + self.circular.retain(|k| !evaluated.contains(k)); + } + self.circular.extend(cycles); // Write back. let mut spill_changes: Vec = Vec::new(); let mut freed: Vec<(usize, RangeRef)> = Vec::new(); @@ -1216,6 +1221,13 @@ impl Calc { } dirty = self.dirty_closure(wb, &spill_changes); } + // Loops among formulas the run did not evaluate stay listed while they are still formulas. + // Iterative calculation reports none. + if wb.calc.iterative { + self.circular.clear(); + } else { + self.circular.retain(|k| self.graph.nodes.contains_key(k)); + } } } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index ef21c49e3..e554f0196 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -384,6 +384,32 @@ fn circular_references_without_iterative_calculation() { assert_eq!(t.get("A1"), Value::Error(CellError::Circ)); } +/// The list of circular references keeps the loops that a recalculation of other cells does not +/// reach, and drops a loop once a constant is typed over one of its cells. +#[test] +fn circular_references_stay_listed_until_the_loop_is_removed() { + let mut t = T::new(); + t.set("A1", "=A1+1"); + t.set("B1", "=B2"); + t.set("B2", "=B1"); + assert!(t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular); + t.set("D4", "7"); + t.set("D5", "=D4*2"); + assert!(t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular); + assert!(t.calc.circular.iter().any(|k| *k == (0, c("B1")) || *k == (0, c("B2"))), "{:?}", t.calc.circular); + t.set("A1", "5"); + assert!(!t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular); + assert!(t.calc.circular.iter().any(|k| *k == (0, c("B1")) || *k == (0, c("B2"))), "{:?}", t.calc.circular); + t.set("B2", "3"); + assert!(t.calc.circular.is_empty(), "{:?}", t.calc.circular); + // In manual calculation only the edited formulas are evaluated; a constant typed over the listed cell removes it. + t.wb.calc.mode = gridcraft_model::CalcMode::Manual; + t.set("C1", "=C1+1"); + assert_eq!(t.calc.circular, vec![(0, c("C1"))]); + t.set("C1", "1"); + assert!(t.calc.circular.is_empty(), "{:?}", t.calc.circular); +} + #[test] fn date_text_coerces_in_the_workbook_date_system() { for (sys, serial) in [(gridcraft_core::DateSystem::D1900, 1462.0), (gridcraft_core::DateSystem::D1904, 0.0)] { diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index fc9181feb..2145164f9 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -142,6 +142,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Odebrat šipky", "Show Formulas" => "Zobrazit vzorce", "Error Checking" => "Kontrola chyb", + "Circular References" => "Cyklické odkazy", "Evaluate Formula" => "Vyhodnotit vzorec", "Watch Window" => "Okno sledování", "Calculation\nOptions" => "Možnosti\nvýpočtu", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 61c2292f4..c2da15e37 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -146,6 +146,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Pfeile entfernen", "Show Formulas" => "Formeln anzeigen", "Error Checking" => "Fehlerüberprüfung", + "Circular References" => "Zirkelbezüge", "Evaluate Formula" => "Formel auswerten", "Watch Window" => "Überwachungsfenster", "Calculation\nOptions" => "Berechnungs-\noptionen", diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index e54416a6f..92b0efa6f 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -141,6 +141,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Κατάργηση βελών", "Show Formulas" => "Εμφάνιση τύπων", "Error Checking" => "Έλεγχος σφαλμάτων", + "Circular References" => "Κυκλικές αναφορές", "Evaluate Formula" => "Αξιολόγηση τύπου", "Watch Window" => "Παράθυρο παρακολούθησης", "Calculation\nOptions" => "Επιλογές\nυπολογισμού", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 36898c098..4c8ad097f 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -145,6 +145,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Quitar flechas", "Show Formulas" => "Mostrar fórmulas", "Error Checking" => "Comprobación de errores", + "Circular References" => "Referencias circulares", "Evaluate Formula" => "Evaluar fórmula", "Watch Window" => "Ventana Inspección", "Calculation\nOptions" => "Opciones\nde cálculo", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 317647c2f..a11c8b42d 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -154,6 +154,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Supprimer les flèches", "Show Formulas" => "Afficher les formules", "Error Checking" => "Vérification des erreurs", + "Circular References" => "Références circulaires", "Evaluate Formula" => "Évaluation de formule", "Watch Window" => "Fenêtre Espion", "Calculation\nOptions" => "Options de\ncalcul", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 65f356669..f969e9d55 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -149,6 +149,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Rimuovi frecce", "Show Formulas" => "Mostra formule", "Error Checking" => "Controllo errori", + "Circular References" => "Riferimenti circolari", "Evaluate Formula" => "Valuta formula", "Watch Window" => "Finestra controlli", "Calculation\nOptions" => "Opzioni di\ncalcolo", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 2df4cfc11..fcc0dffbc 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -154,6 +154,7 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Remove Arrows", "矢印の解除"), ("Show Formulas", "数式の表示"), ("Error Checking", "エラーチェック"), + ("Circular References", "循環参照"), ("Evaluate Formula", "数式の検証"), ("Watch Window", "ウォッチ ウィンドウ"), ("Calculation\nOptions", "計算方法の設定"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index d3c440cf3..143f3eb78 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -151,6 +151,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "화살표 제거", "Show Formulas" => "수식 표시", "Error Checking" => "오류 검사", + "Circular References" => "순환 참조", "Evaluate Formula" => "수식 계산", "Watch Window" => "감시 창", "Calculation\nOptions" => "계산\n옵션", diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index c1f952357..66a2b5fe6 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -150,6 +150,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Pijlen verwijderen", "Show Formulas" => "Formules weergeven", "Error Checking" => "Foutopsporing", + "Circular References" => "Kringverwijzingen", "Evaluate Formula" => "Formule evalueren", "Watch Window" => "Bewakingsvenster", "Calculation\nOptions" => "Berekenings-\nopties", diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index e3185d55a..f552de7fd 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -145,6 +145,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Usuń strzałki", "Show Formulas" => "Pokaż formuły", "Error Checking" => "Sprawdzanie błędów", + "Circular References" => "Odwołania cykliczne", "Evaluate Formula" => "Szacuj formułę", "Watch Window" => "Okno czujki", "Calculation\nOptions" => "Opcje\nobliczania", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1b56ff504..3307f30bf 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -148,6 +148,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Remover Setas", "Show Formulas" => "Exibir Fórmulas", "Error Checking" => "Verificação de Erros", + "Circular References" => "Referências Circulares", "Evaluate Formula" => "Avaliar Fórmula", "Watch Window" => "Janela de Inspeção", "Calculation\nOptions" => "Opções de\nCálculo", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 9e0d92516..6fa0390ec 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -152,6 +152,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Eliminare săgeți", "Show Formulas" => "Afișare formule", "Error Checking" => "Verificare erori", + "Circular References" => "Referințe circulare", "Evaluate Formula" => "Evaluare formulă", "Watch Window" => "Fereastră de urmărire", "Calculation\nOptions" => "Opțiuni de\ncalcul", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index ce675701e..b74f34c19 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -151,6 +151,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Убрать стрелки", "Show Formulas" => "Показать формулы", "Error Checking" => "Проверка ошибок", + "Circular References" => "Циклические ссылки", "Evaluate Formula" => "Вычислить формулу", "Watch Window" => "Окно контрольных значений", "Calculation\nOptions" => "Параметры\nвычислений", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index c71ce53a8..221cb0305 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -146,6 +146,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Ukloni strelice", "Show Formulas" => "Prikaži formule", "Error Checking" => "Provera grešaka", + "Circular References" => "Kružne reference", "Evaluate Formula" => "Proceni formulu", "Watch Window" => "Prozor za praćenje", "Calculation\nOptions" => "Opcije\nizračunavanja", diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index ce2bc8e1e..46abcf1a3 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -144,6 +144,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Okları Kaldır", "Show Formulas" => "Formülleri Göster", "Error Checking" => "Hata Denetimi", + "Circular References" => "Döngüsel Başvurular", "Evaluate Formula" => "Formülü Değerlendir", "Watch Window" => "Gözcü Penceresi", "Calculation\nOptions" => "Hesaplama\nSeçenekleri", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 97659cc12..12933c630 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -147,6 +147,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Прибрати стрілки", "Show Formulas" => "Показати формули", "Error Checking" => "Перевірка помилок", + "Circular References" => "Циклічні посилання", "Evaluate Formula" => "Обчислити формулу", "Watch Window" => "Окно контрольних значень", "Calculation\nOptions" => "Параметри\nобчислень", diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index 94c32b3a5..ad4fa9013 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -153,6 +153,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Bỏ mũi tên", "Show Formulas" => "Hiện công thức", "Error Checking" => "Kiểm tra lỗi", + "Circular References" => "Tham chiếu vòng", "Evaluate Formula" => "Đánh giá công thức", "Watch Window" => "Cửa sổ theo dõi", "Calculation\nOptions" => "Tùy chọn\ntính toán", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index c9d3ecb7d..1dfda444e 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -151,6 +151,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "删除箭头", "Show Formulas" => "显示公式", "Error Checking" => "错误检查", + "Circular References" => "循环引用", "Evaluate Formula" => "公式求值", "Watch Window" => "监视窗口", "Calculation\nOptions" => "计算\n选项", diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index ab9cc311d..85f5b81cf 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -8,7 +8,7 @@ use crate::SheetApp; use crate::icons::{self, Icon}; use crate::keytips::keytip_table; use crate::theme::{self, Tokens}; -use crate::widgets::{big_button, color_palette, icon_button, small_button, split_button, toggle_button}; +use crate::widgets::{big_button, color_palette, icon_button, small_button, small_split_button, split_button, toggle_button}; pub const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"]; @@ -382,6 +382,23 @@ fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) { app.run_or_alert(id, params); } +/// How many cells Formulas › Error Checking › Circular References lists. +const CIRCULAR_MENU_LIMIT: usize = 100; + +/// The cell listed for each circular reference, for the Circular References submenu: each once, in the order +/// the calculation found them, as (sheet index, cell, "Sheet!A1"). +fn circular_references(app: &SheetApp) -> Vec<(usize, String, String)> { + let Some(d) = app.session.active() else { return Vec::new() }; + let mut seen = std::collections::HashSet::new(); + d.calc + .circular + .iter() + .filter(|k| seen.insert(**k)) + .filter_map(|(si, c)| d.wb.sheet(*si).map(|sh| (*si, c.a1(), format!("{}!{}", sh.name, c.a1())))) + .take(CIRCULAR_MENU_LIMIT) + .collect() +} + /// Keytips are painted, not focusable widgets: the grid retains keyboard focus. fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) { let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return }; @@ -2009,11 +2026,28 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { act(app, "formulas.showFormulas", json!({})); } - let __k = small_button(ui, Icon::Validation, "Error Checking", "Error Checking", false); - control_keytip(app, ui, &__k, "dialog:errorChecking"); - if __k.clicked() { + let (ec, ec_arrow) = small_split_button(ui, Icon::Validation, "Error Checking", "Error Checking"); + control_keytip(app, ui, &ec, "dialog:errorChecking"); + if ec.clicked() { app.open_dialog("errorChecking", json!({})); } + egui::Popup::menu(&ec_arrow).show(|ui| { + let lang = app.ui.language; + if ui.button(format!("{}…", lang.tr("Error Checking"))).clicked() { + app.open_dialog("errorChecking", json!({})); + } + let circular = circular_references(app); + ui.add_enabled_ui(!circular.is_empty(), |ui| { + ui.menu_button(lang.tr("Circular References"), |ui| { + for (sheet, cell, label) in circular { + if ui.button(label).clicked() { + act(app, "selection.set", json!({"range": cell, "sheet": sheet})); + app.grid.ensure_visible = true; + } + } + }); + }); + }); let __k = small_button(ui, Icon::Calc, "Evaluate Formula", "Evaluate Formula", false); control_keytip(app, ui, &__k, "dialog:evaluateFormula"); if __k.clicked() { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e5322c3ec..6a7b3bfb5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -160,6 +160,34 @@ pub fn split_button(ui: &mut Ui, icon: Icon, accent: Option, tip: &str) (m.on_hover_text(tip_text(ui, tip)).clicked(), a) } +/// A small ribbon split button with a label (Error Checking): the icon and label run the default +/// action, the arrow at the right opens a menu. Returns (main response, arrow response). +pub fn small_split_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str) -> (Response, Response) { + let t = Tokens::get(ui.ctx()); + let font = theme::ui_font(12.5); + let label = tr(ui, label); + let tw = ui.painter().layout_no_wrap(label.to_string(), font.clone(), t.text).size().x + 6.0; + let (rect, _) = ui.allocate_exact_size(vec2(24.0 + tw + 14.0, 23.0), Sense::hover()); + let main = Rect::from_min_size(rect.min, vec2(24.0 + tw, rect.height())); + let arrow = Rect::from_min_max(pos2(main.right(), rect.top()), rect.max); + let m = ui.interact(main, ui.id().with((tip, "main")), Sense::click()); + let a = ui.interact(arrow, ui.id().with((tip, "arrow")), Sense::click()); + if m.hovered() || a.hovered() { + ui.painter().rect_filled(rect, 4.0, t.hover); + for (part, r) in [(&m, main), (&a, arrow)] { + if part.is_pointer_button_down_on() { + ui.painter().rect_filled(r, 4.0, t.pressed); + } + } + ui.painter().line_segment([pos2(main.right(), rect.top() + 3.0), pos2(main.right(), rect.bottom() - 3.0)], Stroke::new(1.0, t.separator)); + } + icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(16.0, 16.0)), icon, t.text); + ui.painter().text(pos2(rect.left() + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, t.text); + icons::paint(ui.painter(), Rect::from_center_size(pos2(arrow.center().x, rect.center().y + 1.0), vec2(10.0, 10.0)), Icon::Chevron, t.text_dim); + let tip = tip_text(ui, tip); + (m.on_hover_text(tip.clone()), a.on_hover_text(tip)) +} + /// A one-line text field for a value stored in the workbook, which callers reload every frame. The text being typed is /// kept in egui memory until the field loses focus, then returned to commit (`None` when Escape cancels the edit). pub fn committed_text(ui: &mut Ui, id: egui::Id, current: &str, width: Option) -> Option { diff --git a/crates/ui-egui/tests/it/error_checking_menu.rs b/crates/ui-egui/tests/it/error_checking_menu.rs new file mode 100644 index 000000000..0639020da --- /dev/null +++ b/crates/ui-egui/tests/it/error_checking_menu.rs @@ -0,0 +1,129 @@ +//! Formulas › Error Checking is a split button, as in Excel: the button opens the Error Checking +//! dialog, and its arrow opens a menu whose Circular References submenu lists a cell on each +//! circular reference and selects the one picked, on its own sheet. + +use egui::{Event, Shape, pos2, vec2}; +use egui_kittest::kittest::{NodeT as _, Queryable as _}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// The submenu item, with the arrow egui puts after a submenu's label. +const CIRCULAR: &str = "Circular References ⏵"; + +fn harness(loops: bool) -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.insertSheet", json!({"name": "Data"})).unwrap(); + if loops { + s.execute("cell.set", json!({"cell": "B3", "input": "=B4"})).unwrap(); + s.execute("cell.set", json!({"cell": "B4", "input": "=B3"})).unwrap(); + } + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + if loops { + s.execute("cell.set", json!({"cell": "C2", "input": "=C2+1"})).unwrap(); + } + s.execute("selection.set", json!({"range": "H20"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h.state_mut().ui.ribbon_tab = "Formulas".into(); + h.run_steps(4); + h +} + +/// The painted "Error Checking" ribbon label: its left edge, right edge and vertical center. +fn label(h: &Harness) -> (f32, f32, f32) { + h.output() + .shapes + .iter() + .find_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == "Error Checking" => { + Some((t.pos.x, t.pos.x + t.galley.size().x, t.pos.y + t.galley.size().y / 2.0)) + } + _ => None, + }) + .expect("the Formulas tab shows Error Checking") +} + +fn click(h: &mut Harness, at: egui::Pos2) { + h.input_mut().events.push(Event::PointerMoved(at)); + h.run_steps(2); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + } + h.run_steps(2); +} + +fn open_menu(h: &mut Harness) { + let (_, right, y) = label(h); + // The arrow sits right of the label, past the 6 px gap after the text. + click(h, pos2(right + 13.0, y)); +} + +fn hover(h: &mut Harness, label: &str) { + let at = h.get_by_label(label).rect().center(); + h.input_mut().events.push(Event::PointerMoved(at)); + h.run_steps(6); +} + +#[test] +fn error_checking_button_opens_the_dialog_and_its_arrow_opens_the_menu() { + let mut h = harness(false); + let (left, _, y) = label(&h); + click(&mut h, pos2(left + 4.0, y)); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("errorChecking"), "the button opens Error Checking"); + h.state_mut().dialog = None; + h.run_steps(2); + open_menu(&mut h); + assert!(h.state().dialog.is_none(), "the arrow opens a menu, not the dialog"); + h.get_by_label("Error Checking…").click(); + h.run_steps(4); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("errorChecking"), "the menu's first item opens Error Checking"); +} + +#[test] +fn circular_references_is_unavailable_without_a_circular_reference() { + let mut h = harness(false); + open_menu(&mut h); + assert!(h.get_by_label(CIRCULAR).accesskit_node().is_disabled()); +} + +#[test] +fn circular_references_lists_the_loops_and_selects_the_cell_picked() { + let mut h = harness(true); + open_menu(&mut h); + assert!(!h.get_by_label(CIRCULAR).accesskit_node().is_disabled()); + hover(&mut h, CIRCULAR); + assert!(h.query_by_label("Sheet1!C2").is_some(), "the loop on the active sheet is listed"); + let other = ["Data!B3", "Data!B4"].into_iter().find(|l| h.query_by_label(l).is_some()).expect("the loop on Data is listed"); + h.get_by_label(other).click(); + h.run_steps(4); + let app = h.state(); + let d = app.session.active().unwrap(); + assert_eq!(d.wb.active_sheet, 1, "picking a cell on Data goes to Data"); + assert_eq!(format!("Data!{}", d.selection.active.a1()), other); +} + +#[test] +fn circular_references_scrolls_to_the_cell_picked() { + let mut h = harness(false); + h.state_mut().run("cell.set", json!({"cell": "C500", "input": "=C500+1"})).unwrap(); + h.state_mut().run("selection.set", json!({"range": "H20"})).unwrap(); + h.run_steps(4); + assert_eq!(h.state().view().scroll.y, 0.0, "setup: the view is at the top of the sheet"); + open_menu(&mut h); + hover(&mut h, CIRCULAR); + h.get_by_label("Sheet1!C500").click(); + h.run_steps(4); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "C500"); + assert!(h.state().view().scroll.y > 0.0, "the grid scrolls to the cell picked"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 3a9769c1c..1422e683e 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -24,6 +24,7 @@ mod dialog_confirm; mod dialog_focus; mod dialog_move; mod drag_cursor; +mod error_checking_menu; mod file_menu; mod fill_handle_double_click; mod fill_handle_right_drag; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5d5f1efee..b3bb9e0e5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -24,6 +24,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Formula bar with Name Box, expand, function hints | yes | yes | done | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | Ctrl-drag (Cmd or Option on macOS) drops a copy (#971); active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Formula bar with Name Box, expand, function hints | yes | yes | done; dynamic-array spill children show the source formula muted and read-only, using current spill ownership | +| Formulas › Error Checking arrow: Circular References | yes | yes: the button opens Error Checking and its arrow opens a menu with Error Checking… and Circular References, which lists a cell on each circular reference as `Sheet!A1`, up to 100, and selects the one picked, on its own sheet (unavailable when there are none) | done; the Error Checking… item and the limit of 100 are GridCraft's own | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); New sheet (+) right after the last tab, the strip capped at 60% of the bar (#943); manual wheel/trackpad browsing preserved; next/previous skips Hidden and VeryHidden sheets and retains the current sheet and selection when no visible destination exists; rename focus bug on macOS (#145) | | Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial | From 1dd07b7f755040d0a6df22c3ac7680003848b636 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 14:51:06 -0700 Subject: [PATCH 42/56] Circular references: Error Checking and the status bar quote sheet names that need quotes formulas.errorChecking listed each circular reference as Sheet!A1 with the sheet name as it is, so a sheet named Bob's Q1 gave Bob's Q1!B2, which a formula does not accept as a reference. The list now quotes the name with quote_sheet, as references are written elsewhere in the engine ('Bob''s Q1'!B2), and the status bar's Circular References label uses the same form. --- crates/engine/src/cmd/formulas.rs | 8 ++++++-- crates/engine/src/tests.rs | 17 +++++++++++++++++ crates/ui-egui/src/tabs.rs | 2 +- crates/ui-egui/tests/it/interaction.rs | 9 +++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 7718279b2..15216492b 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -664,8 +664,12 @@ fn error_checking(s: &mut Session, _: &Json) -> Result { } } } - let circ: Vec = - d.calc.circular.iter().map(|(si, c)| format!("{}!{}", d.wb.sheet(*si).map(|s| s.name.clone()).unwrap_or_default(), c.a1())).collect(); + let circ: Vec = d + .calc + .circular + .iter() + .map(|(si, c)| format!("{}!{}", d.wb.sheet(*si).map(|s| gridcraft_formula::quote_sheet(&s.name)).unwrap_or_default(), c.a1())) + .collect(); Ok(json!({"errors": out, "circular": circ})) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f4c47bb62..70552b21a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2784,6 +2784,23 @@ fn calculation_options_enable_iterative_calculation() { near(&s, "A1", 10.0); } +#[test] +fn error_checking_quotes_sheet_names_in_circular_references() { + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "Bob's Q1"})).unwrap(); + s.execute("cell.set", json!({"cell": "B2", "input": "=B2+1"})).unwrap(); + let r = s.execute("formulas.errorChecking", json!({})).unwrap(); + assert_eq!(r["circular"], json!(["'Bob''s Q1'!B2"])); + // The reference reads back in a formula and in Go To. + let reference = r["circular"][0].as_str().unwrap().to_string(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": format!("=ROW({reference})")})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(2.0)); + s.execute("edit.goTo", json!({"reference": reference})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!((d.wb.active_sheet, d.selection.active.a1()), (1, "B2".to_string())); +} + fn chart_data(s: &mut Session) { s.execute( "range.setValues", diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index bc6fcc1d8..10b0c3bc0 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -282,7 +282,7 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { if !d.calc.circular.is_empty() { ui.add_space(12.0); let (si, c) = d.calc.circular[0]; - let name = d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(); + let name = d.wb.sheet(si).map(|s| gridcraft_engine::formula::quote_sheet(&s.name)).unwrap_or_default(); ui.label(egui::RichText::new(format!("Circular References: {name}!{}", c.a1())).font(theme::ui_font(12.5)).color(t.danger)); } if app.session.clipboard.is_some() && app.editor.is_none() { diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index 10841c7bd..fbe7089e6 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -922,6 +922,15 @@ fn status_bar_shows_macro_badge() { h.run_steps(2); } +#[test] +fn status_bar_quotes_the_sheet_name_of_a_circular_reference() { + let mut s = blank(); + s.execute("home.insertSheet", json!({"name": "Bob's Q1"})).unwrap(); + s.execute("cell.set", json!({"cell": "B2", "input": "=B2+1"})).unwrap(); + let h = harness(s); + assert!(h.query_by_label("Circular References: 'Bob''s Q1'!B2").is_some(), "the status bar names the cell as a reference"); +} + #[test] fn alt_f8_opens_the_macros_dialog() { let mut h = harness(blank()); From a75224617b8a8caa6f449b8a477396e4112c6e00 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 15:43:52 -0700 Subject: [PATCH 43/56] Status bar: Circular References names a cell only when a loop is on the active sheet The status bar named the first circular reference in the list, even when it was on another sheet. It now names the first one on the active sheet, and shows only Circular References when every loop is on other sheets, as Excel's status bar does. --- crates/ui-egui/src/tabs.rs | 11 +++++++--- crates/ui-egui/tests/it/interaction.rs | 28 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 10b0c3bc0..d6d462b80 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -281,9 +281,14 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { } if !d.calc.circular.is_empty() { ui.add_space(12.0); - let (si, c) = d.calc.circular[0]; - let name = d.wb.sheet(si).map(|s| gridcraft_engine::formula::quote_sheet(&s.name)).unwrap_or_default(); - ui.label(egui::RichText::new(format!("Circular References: {name}!{}", c.a1())).font(theme::ui_font(12.5)).color(t.danger)); + let label = match d.calc.circular.iter().find(|(si, _)| *si == d.wb.active_sheet) { + Some(&(si, c)) => { + let name = d.wb.sheet(si).map(|s| gridcraft_engine::formula::quote_sheet(&s.name)).unwrap_or_default(); + format!("Circular References: {name}!{}", c.a1()) + } + None => "Circular References".to_owned(), + }; + ui.label(egui::RichText::new(label).font(theme::ui_font(12.5)).color(t.danger)); } if app.session.clipboard.is_some() && app.editor.is_none() { ui.add_space(12.0); diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index fbe7089e6..71779aa26 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -931,6 +931,34 @@ fn status_bar_quotes_the_sheet_name_of_a_circular_reference() { assert!(h.query_by_label("Circular References: 'Bob''s Q1'!B2").is_some(), "the status bar names the cell as a reference"); } +#[test] +fn status_bar_names_no_cell_when_every_circular_reference_is_on_another_sheet() { + let mut s = blank(); + s.execute("home.insertSheet", json!({"name": "Data"})).unwrap(); + s.execute("cell.set", json!({"cell": "B2", "input": "=B2+1"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + let mut h = harness(s); + assert!(h.query_by_label("Circular References").is_some(), "the status bar still reports circular references"); + assert!(h.query_by_label("Circular References: Data!B2").is_none(), "no cell address from another sheet"); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Data"})).unwrap(); + h.run_steps(2); + assert!(h.query_by_label("Circular References: Data!B2").is_some(), "the active sheet's loop is named"); +} + +#[test] +fn status_bar_names_a_circular_reference_on_the_active_sheet() { + let mut s = blank(); + s.execute("cell.set", json!({"cell": "A1", "input": "=A1+1"})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Data"})).unwrap(); + s.execute("cell.set", json!({"cell": "B2", "input": "=B2+1"})).unwrap(); + s.execute("formulas.calculateNow", json!({})).unwrap(); + let circular = s.active().unwrap().calc.circular.clone(); + assert_eq!(circular, vec![(0, CellRef::new(0, 0)), (1, CellRef::new(1, 1))], "Sheet1's loop is listed first"); + let h = harness(s); + assert!(h.query_by_label("Circular References: Data!B2").is_some(), "the status bar names the loop on the active sheet"); + assert!(h.query_by_label("Circular References: Sheet1!A1").is_none(), "not the first loop listed"); +} + #[test] fn alt_f8_opens_the_macros_dialog() { let mut h = harness(blank()); From a2e4b2b1da6d70844f0e2a0f53397b79beedc2a9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 15:48:55 -0700 Subject: [PATCH 44/56] Error Checking: the Circular References submenu scrolls The submenu lists up to 100 cells. When the list was taller than the window, the cells below its bottom edge could not be reached. The list now sits in a scroll area 400 points high. --- crates/ui-egui/src/ribbon.rs | 12 ++++---- .../ui-egui/tests/it/error_checking_menu.rs | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 85f5b81cf..04c0e814e 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2039,12 +2039,14 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { let circular = circular_references(app); ui.add_enabled_ui(!circular.is_empty(), |ui| { ui.menu_button(lang.tr("Circular References"), |ui| { - for (sheet, cell, label) in circular { - if ui.button(label).clicked() { - act(app, "selection.set", json!({"range": cell, "sheet": sheet})); - app.grid.ensure_visible = true; + egui::ScrollArea::vertical().id_salt("circular_references").max_height(400.0).show(ui, |ui| { + for (sheet, cell, label) in circular { + if ui.button(label).clicked() { + act(app, "selection.set", json!({"range": cell, "sheet": sheet})); + app.grid.ensure_visible = true; + } } - } + }); }); }); }); diff --git a/crates/ui-egui/tests/it/error_checking_menu.rs b/crates/ui-egui/tests/it/error_checking_menu.rs index 0639020da..10467e163 100644 --- a/crates/ui-egui/tests/it/error_checking_menu.rs +++ b/crates/ui-egui/tests/it/error_checking_menu.rs @@ -127,3 +127,31 @@ fn circular_references_scrolls_to_the_cell_picked() { assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "C500"); assert!(h.state().view().scroll.y > 0.0, "the grid scrolls to the cell picked"); } + +#[test] +fn circular_references_scrolls_a_list_taller_than_the_window() { + let mut h = harness(false); + for row in 1..=100 { + h.state_mut().run("cell.set", json!({"cell": format!("A{row}"), "input": format!("=A{row}+1")})).unwrap(); + } + h.state_mut().run("selection.set", json!({"range": "H20"})).unwrap(); + h.run_steps(4); + open_menu(&mut h); + hover(&mut h, CIRCULAR); + let window = h.ctx.content_rect(); + assert!(h.get_by_label("Sheet1!A100").rect().bottom() > window.bottom(), "setup: the last cell starts below the window"); + hover(&mut h, "Sheet1!A1"); + for _ in 0..20 { + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, -10.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(4); + } + let last = h.get_by_label("Sheet1!A100").rect(); + assert!(last.bottom() <= window.bottom(), "the wheel brings the last cell into the window: {last:?} in {window:?}"); + click(&mut h, last.center()); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A100"); +} From 0eb2f1f6f057985c26cacb569f425384b623f1c9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 16:32:53 -0700 Subject: [PATCH 45/56] Charts: 100% Stacked Area draws each value as a share of its category's total Insert > Line or Area and the All Charts list offer 100% Stacked Area, and insert.chart and chart.set take it as type area with subtype stacked100. Each series' area stacks on the ones before it as its share of the category's total. When every series on the value axis is 100% stacked and no value is negative, the axis runs from 0% to 100%. A blank cell counts as zero, and a category whose total is zero stays on the baseline. An XLSX area chart with percentStacked grouping now opens as 100% stacked and is saved back as percentStacked. It used to open as a stacked area and was saved as stacked. --- ROADMAP.md | 1 + crates/chart/src/render.rs | 44 +++++++++++++------ crates/chart/src/tests.rs | 44 ++++++++++++++++++- crates/engine/src/cmd/insert.rs | 3 +- .../engine/tests/it/chart_area_stacked100.rs | 35 +++++++++++++++ crates/engine/tests/it/main.rs | 1 + crates/model/src/features.rs | 1 + crates/ui-egui/src/dialogs.rs | 3 +- crates/ui-egui/src/i18n/el.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt.rs | 1 + crates/ui-egui/src/i18n/ro.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/ribbon.rs | 1 + crates/ui-egui/tests/it/interaction.rs | 27 ++++++++++++ crates/xlsx/src/chart.rs | 16 +++---- crates/xlsx/src/tests/roundtrip.rs | 1 + docs/chart-parity.md | 7 +-- 20 files changed, 162 insertions(+), 29 deletions(-) create mode 100644 crates/engine/tests/it/chart_area_stacked100.rs diff --git a/ROADMAP.md b/ROADMAP.md index da06359b5..6aa2a087e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-24 | Charts: 100% Stacked Area (`insert.chart` `{type: "area", subtype: "stacked100"}`), drawn as each value's share of its category's total; XLSX `percentStacked` area charts open as 100% stacked instead of stacked | | 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text | | 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row | | 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) | diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 0a6fdbe32..e97f576a1 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -868,6 +868,7 @@ enum Fam { LineStack, Area, AreaStack, + Area100, } fn family(k: ChartKind) -> Fam { @@ -879,10 +880,16 @@ fn family(k: ChartKind) -> Fam { LineStacked => Fam::LineStack, Area => Fam::Area, AreaStacked => Fam::AreaStack, + AreaStacked100 => Fam::Area100, _ => Fam::Col, } } +/// Families stacked to 100%: each category's values are shares of its total. +fn is_100(f: Fam) -> bool { + matches!(f, Fam::Col100 | Fam::Area100) +} + fn is_col(f: Fam) -> bool { matches!(f, Fam::Col | Fam::ColStack | Fam::Col100) } @@ -900,25 +907,38 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta let any_primary = data.series.iter().any(|s| !s.secondary); let sec: Vec = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect(); - // Totals for 100% stacking, per axis. - let mut totals = [vec![0.0f64; ncat], vec![0.0f64; ncat]]; + // Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart). + let mut totals: Vec<((bool, Fam), Vec)> = Vec::new(); for (i, s) in data.series.iter().enumerate() { - if fams.get(i) == Some(&Fam::Col100) { - let a = sec.get(i).copied().unwrap_or(false) as usize; + let fam = fams.get(i).copied().unwrap_or(Fam::Col); + if is_100(fam) { + let key = (sec.get(i).copied().unwrap_or(false), fam); + let p = match totals.iter().position(|(k, _)| *k == key) { + Some(p) => p, + None => { + totals.push((key, vec![0.0; ncat])); + totals.len() - 1 + } + }; for (j, v) in s.values.iter().take(ncat).enumerate() { - if let (Some(v), Some(t)) = (v, totals.get_mut(a).and_then(|t| t.get_mut(j))) { + if let (Some(v), Some(t)) = (v, totals.get_mut(p).and_then(|(_, t)| t.get_mut(j))) { *t += v.abs(); } } } } + // A value's share of its category's total, for the 100% families. + let share = |key: (bool, Fam), j: usize, v: f64| { + let t = totals.iter().find(|(k, _)| *k == key).and_then(|(_, t)| t.get(j)).copied().unwrap_or(0.0); + if t > 0.0 { v / t } else { 0.0 } + }; // Stack accumulators keyed by (axis, family): positive and negative running sums. let mut stacks: Vec<((bool, Fam), Vec, Vec)> = Vec::new(); let mut spans: Vec>> = Vec::with_capacity(data.series.len()); for (i, s) in data.series.iter().enumerate() { let fam = fams.get(i).copied().unwrap_or(Fam::Col); let a = sec.get(i).copied().unwrap_or(false); - let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack); + let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack | Fam::Area100); let key = (a, fam); let si = if stacked { match stacks.iter().position(|(k, _, _)| *k == key) { @@ -936,12 +956,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta let v = s.values.get(j).copied().flatten(); let span = match (si.and_then(|p| stacks.get_mut(p)), fam) { (Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| { - let v = if fam == Fam::Col100 { - let t = totals.get(a as usize).and_then(|t| t.get(j)).copied().unwrap_or(0.0); - if t > 0.0 { v / t } else { 0.0 } - } else { - v - }; + let v = if fam == Fam::Col100 { share(key, j, v) } else { v }; let acc = if v >= 0.0 { pos.get_mut(j) } else { neg.get_mut(j) }?; let base = *acc; *acc += v; @@ -949,6 +964,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta }), (Some((_, pos, _)), _) => { let v = v.unwrap_or(0.0); + let v = if fam == Fam::Area100 { share(key, j, v) } else { v }; pos.get_mut(j).map(|acc| { let base = *acc; *acc = clean(*acc + v); @@ -970,7 +986,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta continue; } let force_zero = members.iter().any(|i| fams.get(*i).is_some_and(|f| !is_line(*f))); - let all100 = members.iter().all(|i| fams.get(*i) == Some(&Fam::Col100)); + let all100 = members.iter().all(|i| fams.get(*i).is_some_and(|f| is_100(*f))); let range = value_range(members.iter().flat_map(|i| { let line = fams.get(*i).is_some_and(|f| is_line(*f)); spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] }) @@ -1044,7 +1060,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta for (i, s) in data.series.iter().enumerate() { let fam = fams.get(i).copied().unwrap_or(Fam::Col); let want = match pass { - 0 => matches!(fam, Fam::Area | Fam::AreaStack), + 0 => matches!(fam, Fam::Area | Fam::AreaStack | Fam::Area100), 1 => is_col(fam), _ => is_line(fam), }; diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index cfe5d5a42..9bddbb270 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -5,7 +5,7 @@ use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, Sparkl use crate::*; -const ALL_KINDS: [ChartKind; 25] = [ +const ALL_KINDS: [ChartKind; 26] = [ ChartKind::ColumnClustered, ChartKind::ColumnStacked, ChartKind::ColumnStacked100, @@ -19,6 +19,7 @@ const ALL_KINDS: [ChartKind; 25] = [ ChartKind::Doughnut, ChartKind::Area, ChartKind::AreaStacked, + ChartKind::AreaStacked100, ChartKind::Scatter, ChartKind::ScatterLines, ChartKind::Bubble, @@ -1073,7 +1074,7 @@ fn pie_points_follow_workbook_theme() { #[test] fn area_charts_draw_source_value_labels_only_when_enabled() { - for kind in [ChartKind::Area, ChartKind::AreaStacked] { + for kind in [ChartKind::Area, ChartKind::AreaStacked, ChartKind::AreaStacked100] { let d = sample(kind); let mut c = chart(kind); c.data_labels = false; @@ -1097,6 +1098,45 @@ fn area_charts_draw_source_value_labels_only_when_enabled() { } } +#[test] +fn hundred_percent_stacked_areas_fill_each_category_to_its_total() { + let d = ChartData { + category_levels: vec![], + palette: vec![], + categories: ["Q1", "Q2", "Q3", "Q4"].iter().map(|s| s.to_string()).collect(), + series: vec![ + series("North", &[10.0, 30.0, 0.0, 5.0], [20, 96, 130, 255], ChartKind::AreaStacked100), + series("South", &[30.0, 10.0, 0.0, 15.0], [25, 107, 36, 255], ChartKind::AreaStacked100), + ], + }; + let mut c = chart(ChartKind::AreaStacked100); + c.data_labels = false; + let p = render(&c, &d, 640.0, 400.0, &ApproxMeasure); + let t = texts(&p); + assert!(t.contains(&"100%") && t.contains(&"0%"), "{t:?}"); + assert!(!t.contains(&"40"), "{t:?}"); + let polys: Vec<&Vec<[f32; 2]>> = p + .iter() + .filter_map(|p| match p { + Prim::Polygon { pts, .. } => Some(pts), + _ => None, + }) + .collect(); + let (Some(north), Some(south)) = (polys.first(), polys.get(1)) else { panic!("two areas expected: {polys:?}") }; + // Each polygon is its top edge left to right, then its base right to left. + let top = |poly: &[[f32; 2]], j: usize| poly.get(j).map(|p| p[1]).unwrap_or(f32::NAN); + let base = |poly: &[[f32; 2]], j: usize| poly.get(poly.len() - 1 - j).map(|p| p[1]).unwrap_or(f32::NAN); + let (y0, y100) = (base(north, 0), top(south, 0)); + assert!(y0 > y100, "{y0} {y100}"); + let frac = |y: f32| (y0 - y) / (y0 - y100); + for (j, share) in [(0, 0.25), (1, 0.75), (3, 0.25)] { + assert!((frac(top(north, j)) - share).abs() < 1e-3, "Q{}: North reaches {}", j + 1, frac(top(north, j))); + assert!((frac(top(south, j)) - 1.0).abs() < 1e-3, "Q{}: South reaches {}", j + 1, frac(top(south, j))); + } + // A category whose total is zero stays on the baseline. + assert!(frac(top(south, 2)).abs() < 1e-3, "Q3: South reaches {}", frac(top(south, 2))); +} + #[test] fn two_level_categories_resolve_and_draw() { let years = ["2023", "", "", "2024", "", ""]; diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 2388d1f25..3c45031af 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -92,7 +92,7 @@ pub fn specs() -> Vec { "Chart Properties", ["Chart Design"], None, - "{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}", + "{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}", has_doc, chart_set ), @@ -582,6 +582,7 @@ pub fn chart_kind(t: &str, sub: &str) -> ChartKind { ("pie", _) => ChartKind::Pie, ("doughnut", _) => ChartKind::Doughnut, ("area", "stacked") => ChartKind::AreaStacked, + ("area", "stacked100") => ChartKind::AreaStacked100, ("area", _) => ChartKind::Area, ("scatter", "lines") => ChartKind::ScatterLines, ("scatter", _) => ChartKind::Scatter, diff --git a/crates/engine/tests/it/chart_area_stacked100.rs b/crates/engine/tests/it/chart_area_stacked100.rs new file mode 100644 index 000000000..2d0d0c21d --- /dev/null +++ b/crates/engine/tests/it/chart_area_stacked100.rs @@ -0,0 +1,35 @@ +//! The 100% stacked area chart: `insert.chart` and `chart.set` take type `area` with subtype +//! `stacked100`. + +use gridcraft_engine::Session; +use gridcraft_model::ChartKind; +use serde_json::json; + +fn session() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", "10", "30"], ["Q2", "30", "10"]]})).unwrap(); + s +} + +fn kinds(s: &Session) -> Vec { + s.doc().unwrap().wb.sheet(0).unwrap().charts.iter().map(|c| c.kind).collect() +} + +#[test] +fn insert_chart_makes_a_hundred_percent_stacked_area() { + let mut s = session(); + s.execute("insert.chart", json!({"range": "A1:C3", "type": "area", "subtype": "stacked100"})).unwrap(); + assert_eq!(kinds(&s), [ChartKind::AreaStacked100]); +} + +#[test] +fn chart_set_changes_a_chart_to_a_hundred_percent_stacked_area_and_back() { + let mut s = session(); + s.execute("insert.chart", json!({"range": "A1:C3", "type": "area", "subtype": "stacked"})).unwrap(); + assert_eq!(kinds(&s), [ChartKind::AreaStacked]); + s.execute("chart.set", json!({"type": "area", "subtype": "stacked100"})).unwrap(); + assert_eq!(kinds(&s), [ChartKind::AreaStacked100]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(kinds(&s), [ChartKind::AreaStacked]); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 385ec76ca..924a95d1b 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -2,6 +2,7 @@ mod autofit_filter_buttons; mod cf_formula_validate; +mod chart_area_stacked100; mod chart_workbook_names; mod command_sequence_fuzz; mod cross_sheet_cut; diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index aa250b9b0..c83b5d1e9 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -410,6 +410,7 @@ pub enum ChartKind { Doughnut, Area, AreaStacked, + AreaStacked100, Scatter, ScatterLines, Bubble, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e83db2fa1..76bcff5dc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2497,7 +2497,7 @@ fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mu } /// Every chart type the All Charts tab offers: `(type, subtype, label)` for `insert.chart`. -const ALL_CHARTS: [(&str, &str, &str); 20] = [ +const ALL_CHARTS: [(&str, &str, &str); 21] = [ ("column", "", "Clustered Column"), ("column", "stacked", "Stacked Column"), ("column", "stacked100", "100% Stacked Column"), @@ -2511,6 +2511,7 @@ const ALL_CHARTS: [(&str, &str, &str); 20] = [ ("doughnut", "", "Doughnut"), ("area", "", "Area"), ("area", "stacked", "Stacked Area"), + ("area", "stacked100", "100% Stacked Area"), ("scatter", "", "Scatter"), ("scatter", "lines", "Scatter with Lines"), ("radar", "", "Radar"), diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index e54416a6f..c8431cf36 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -877,6 +877,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Doughnut" => "Δακτύλιος", "Area" => "Περιοχή", "Stacked Area" => "Σωρευμένη περιοχή", + "100% Stacked Area" => "Σωρευμένη περιοχή 100%", "Scatter" => "Διασπορά", "Scatter with Lines" => "Διασπορά με γραμμές", "Radar" => "Ακτινωτό", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 65f356669..062b3c1a2 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -761,6 +761,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Doughnut" => "Anello", "Area" => "Area", "Stacked Area" => "Area in pila", + "100% Stacked Area" => "Area in pila 100%", "Scatter" => "Dispersione", "Scatter with Lines" => "Dispersione con linee", "Radar" => "Radar", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 2df4cfc11..254ce8d58 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -770,6 +770,7 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[ ("Doughnut", "ドーナツ"), ("Area", "面"), ("Stacked Area", "積み上げ面"), + ("100% Stacked Area", "100% 積み上げ面"), ("Scatter", "散布図"), ("Scatter with Lines", "直線付き散布図"), ("Radar", "レーダー"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index e3185d55a..e4792fb6a 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -755,6 +755,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Doughnut" => "Pierścieniowy", "Area" => "Warstwowy", "Stacked Area" => "Warstwowy skumulowany", + "100% Stacked Area" => "Warstwowy 100% skumulowany", "Scatter" => "Punktowy", "Scatter with Lines" => "Punktowy z liniami", "Radar" => "Radarowy", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1b56ff504..a3fbe17bb 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -772,6 +772,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Doughnut" => "Rosca", "Area" => "Área", "Stacked Area" => "Área Empilhada", + "100% Stacked Area" => "Área 100% Empilhada", "Scatter" => "Dispersão", "Scatter with Lines" => "Dispersão com Linhas", "Radar" => "Radar", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 9e0d92516..b74f98fbf 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -761,6 +761,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Doughnut" => "Inel", "Area" => "Arie", "Stacked Area" => "Arie stivuită", + "100% Stacked Area" => "Arie 100% stivuită", "Scatter" => "Dispersie", "Scatter with Lines" => "Dispersie cu linii", "Radar" => "Radar", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 97659cc12..c03625a7a 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -758,6 +758,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Doughnut" => "Doughnut", "Area" => "Area", "Stacked Area" => "Stacked Area", + "100% Stacked Area" => "100% Stacked Area", "Scatter" => "Scatter", "Scatter with Lines" => "Scatter with Lines", "Radar" => "Radar", diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index ab9cc311d..8468afef3 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1611,6 +1611,7 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { ("Stacked Line", "line", "stacked"), ("Area", "area", ""), ("Stacked Area", "area", "stacked"), + ("100% Stacked Area", "area", "stacked100"), ], ), (Icon::ChartPie, "Pie or Doughnut", "insert.chart.pie", vec![("Pie", "pie", ""), ("Doughnut", "doughnut", "")]), diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index 10841c7bd..b0ed0a126 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -558,6 +558,33 @@ fn recommended_charts_dialog_inserts_the_chosen_chart() { assert!(h.state().message.is_some()); } +#[test] +fn all_charts_inserts_a_hundred_percent_stacked_area() { + let mut s = blank(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", 10, 30], ["Q2", 30, 10]]})).unwrap(); + s.execute("selection.set", json!({"range": "A1:C3"})).unwrap(); + let mut h = harness(s); + h.state_mut().open_dialog("recommendedCharts", json!({"tab": "All Charts"})); + h.run_steps(4); + // The entry sits below the list's visible height: scroll the list with the wheel first. + let first = h.get_by_label("Pie").rect().center(); + h.input_mut().events.push(Event::PointerMoved(first)); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -10.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }); + h.run_steps(20); + h.get_by_label("100% Stacked Area").click(); + h.run_steps(2); + key(&mut h, Key::Enter, Modifiers::NONE); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + let kinds: Vec<_> = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().map(|c| c.kind).collect(); + assert_eq!(kinds, [gridcraft_engine::model::ChartKind::AreaStacked100]); +} + #[test] fn recommended_charts_command_without_a_type_opens_the_dialog() { // #1399: the ribbon button and the NR keytip ran the command directly and inserted the first diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index a9c426d8a..37bcf91b0 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -40,7 +40,9 @@ fn group_kind(g: &El) -> Option { "pieChart" | "pie3DChart" | "ofPieChart" => ChartKind::Pie, "doughnutChart" => ChartKind::Doughnut, "areaChart" | "area3DChart" => { - if stacked || pct { + if pct { + ChartKind::AreaStacked100 + } else if stacked { ChartKind::AreaStacked } else { ChartKind::Area @@ -284,7 +286,7 @@ enum G { Line { stacked: bool, markers: bool }, Pie, Doughnut, - Area { stacked: bool }, + Area { grouping: &'static str }, Scatter { lines: bool }, Bubble, Radar, @@ -303,8 +305,9 @@ fn group_for(k: ChartKind) -> G { ChartKind::LineStacked => G::Line { stacked: true, markers: false }, ChartKind::Pie => G::Pie, ChartKind::Doughnut => G::Doughnut, - ChartKind::Area => G::Area { stacked: false }, - ChartKind::AreaStacked => G::Area { stacked: true }, + ChartKind::Area => G::Area { grouping: "standard" }, + ChartKind::AreaStacked => G::Area { grouping: "stacked" }, + ChartKind::AreaStacked100 => G::Area { grouping: "percentStacked" }, ChartKind::Scatter => G::Scatter { lines: false }, ChartKind::ScatterLines => G::Scatter { lines: true }, ChartKind::Bubble => G::Bubble, @@ -671,10 +674,7 @@ fn group_xml(g: G, series: &[(usize, &Series)], ch: &Chart, axes: (u32, u32), wb G::Doughnut => { format!("{sers}{dl}") } - G::Area { stacked } => format!( - "{sers}{dl}{ax}", - if stacked { "stacked" } else { "standard" } - ), + G::Area { grouping } => format!("{sers}{dl}{ax}"), G::Scatter { .. } => format!("{sers}{dl}{ax}"), G::Bubble => { format!("{sers}{dl}{ax}") diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 5b79438db..a68ed44e8 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -429,6 +429,7 @@ fn sample() -> Workbook { ChartKind::Doughnut, ChartKind::Area, ChartKind::AreaStacked, + ChartKind::AreaStacked100, ChartKind::Scatter, ChartKind::ScatterLines, ChartKind::Bubble, diff --git a/docs/chart-parity.md b/docs/chart-parity.md index ce9f69290..f18283f45 100644 --- a/docs/chart-parity.md +++ b/docs/chart-parity.md @@ -1,6 +1,6 @@ # Chart parity -> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (stale chart notes: stock/combo, treemap/sunburst, box and waterfall kinds survive XLSX round-trip; PRs #158 and #161 merged) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-24 · **Change:** minor (100% stacked area charts) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's chart types and chart elements against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated). @@ -17,7 +17,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate | Bar | clustered, stacked, 100%, 3-D ×3 | 3 of 6 | no 3-D | | Line | line, stacked, 100% stacked, with markers ×3, 3-D line | 3 of 7 (line, with markers, stacked) | no 100% stacked; smoothed lines landed (#62) | | Pie | pie, 3-D pie, pie of pie, bar of pie, doughnut | 2 of 5 (pie, doughnut) | no exploded, pie of pie, bar of pie | -| Area | area, stacked, 100%, 3-D ×3 | 2 of 6 | no 100%, no 3-D | +| Area | area, stacked, 100%, 3-D ×3 | 3 of 6 | no 3-D; 100% stacked drawn as shares of each category's total and kept as `percentStacked` in XLSX | | X Y (scatter) | markers, smooth lines (±markers), straight lines (±markers) | 2 of 5 | no smooth lines | | Bubble | bubble, 3-D bubble | 1 of 2 | | | Stock | high-low-close, open-high-low-close, volume-HLC, volume-OHLC | 1 kind | | @@ -31,7 +31,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate | Waterfall, Funnel | 2 | 2 of 2 | | | Filled map | 1 | 0 | needs geographic boundary data (licensing decision) | | Combo | custom combinations, secondary axis | yes (line on secondary axis) | | -| **Total** | ~60 subtypes | **25 (~42%)** | usage-weighted breadth higher (~65%): column, bar, line, pie and scatter dominate | +| **Total** | ~60 subtypes | **26 (~43%)** | usage-weighted breadth higher (~65%): column, bar, line, pie and scatter dominate | ## Chart elements and formatting @@ -90,6 +90,7 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | 100% Stacked Area: inserted from the ribbon and All Charts, drawn as shares of each category's total, read and written as `percentStacked` | | 2026-10-18 | trivial | Stale notes: stock/combo, treemap/sunburst, box and waterfall kinds survive XLSX round-trip (PRs #158, #161) | | 2026-10-16 | trivial | Series that refer to defined names (`[0]!Name`, `Sheet!Name`) resolve instead of showing `#NAME?`, and are saved qualified, as Excel needs (#1686) | | 2026-10-16 | minor | Box & whisker quartiles default to exclusive, honor each series on draw and XLSX round-trip; `chart.set {quartiles}` changes the method (#1453) | From c44971fcd5bbe3bde6c2d4f26841c95cfd82a5b2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 17:45:22 -0700 Subject: [PATCH 46/56] =?UTF-8?q?Ribbon:=20Insert=20=E2=80=BA=20Column=20o?= =?UTF-8?q?r=20Bar=20offers=20100%=20Stacked=20Bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Column or Bar menu on the Insert tab listed every column and bar type of the All Charts list except 100% Stacked Bar. It now lists it after Stacked Bar, and choosing it runs insert.chart with type bar and subtype stacked100. --- crates/ui-egui/src/ribbon.rs | 1 + crates/ui-egui/tests/it/interaction.rs | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index ab9cc311d..9c4aab45e 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1599,6 +1599,7 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { ("100% Stacked Column", "column", "stacked100"), ("Clustered Bar", "bar", ""), ("Stacked Bar", "bar", "stacked"), + ("100% Stacked Bar", "bar", "stacked100"), ], ), ( diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index 10841c7bd..191276ad5 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -387,6 +387,30 @@ fn inserted_chart_is_selected_so_delete_removes_it() { assert_eq!(value(&h, "A1"), Value::Number(1.0)); } +#[test] +fn insert_column_or_bar_menu_inserts_a_hundred_percent_stacked_bar() { + // Insert › Column or Bar (keytip N, C1) lists the column and bar types of the All Charts list. + let mut s = blank(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", 10, 30], ["Q2", 30, 10]]})).unwrap(); + s.execute("selection.set", json!({"range": "A1:C3"})).unwrap(); + let mut h = harness(s); + key(&mut h, Key::F10, Modifiers::NONE); + for (k, t) in [(Key::N, "n"), (Key::C, "c"), (Key::Num1, "1")] { + h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Text(t.into())); + h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + } + h.run_steps(2); + for label in ["Clustered Column", "Stacked Column", "100% Stacked Column", "Clustered Bar", "Stacked Bar", "100% Stacked Bar"] { + assert!(h.query_by_role_and_label(egui::accesskit::Role::Button, label).is_some(), "the menu lists {label}"); + } + h.get_by_role_and_label(egui::accesskit::Role::Button, "100% Stacked Bar").click(); + h.run_steps(2); + let kinds: Vec<_> = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().map(|c| c.kind).collect(); + assert_eq!(kinds, [gridcraft_engine::model::ChartKind::BarStacked100]); +} + #[test] fn renders_every_ribbon_tab_without_panicking() { let mut s = Session::new(); From e1a12fa337bba80b589176c491c2fb5646fc35d5 Mon Sep 17 00:00:00 2001 From: Pranav <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:54:34 -0400 Subject: [PATCH 47/56] UI: theme tokens with a contrast test, ribbon overflow arrows and split button, multi-line formula bar, code editor widget (#456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UI: theme tokens for icon, reference and sheet-control colours; WCAG contrast test Tokens gains the colours the chrome was hard-coding, each with a light and a dark value: on_accent (text on an accent fill), an eight-colour category palette (theme::Cat), the paper colour inside icons, reference colours for the formula bar and for the sheet, the message box warning disc, the tab bar scroll thumb, and sheet_control_* colours that follow the sheet and so stay light in both themes. What now comes from the tokens: - icons: paper (white pages glared in dark mode) and the accent constants; icons placed on the sheet use icons::paint_on_sheet, which keeps the light colours; - reference colours (editor::REF_COLORS), which were hard to read on the dark formula field; - white text on the accent in OK, Solve, slicer tiles and the AutoSave switch, 2:1 on the dark accent; - the comments pane (a white card with light text in dark mode), gallery hover and swatch outlines, the message box disc, the tab bar thumb, chart selection frames, and the sheet's filter and validation buttons. A unit test (theme::contrast_tests) checks every token pair used by the ribbon, popups, dialogs, panes, formula bar, status bar and sheet in both themes against WCAG 2: 4.5:1 for text and 3:1 for glyphs. It caught the dark danger red (3.5:1 on hover) and the selected header text (4.1:1 light), which are adjusted. * UI: ribbon scroll arrows, large split button for Formulas › AutoSum - widgets::big_split_button: the icon runs the default action, the label and its arrow open the menu. It returns both responses, with accessible names ("AutoSum" and "AutoSum options"). big_button shares its label layout. - Formulas › AutoSum uses it: the icon inserts SUM as before, and the arrow, which used to do the same, opens the Sum / Average / Count / Max / Min / More Functions menu that Home › AutoSum already had (shared as autosum_menu). Its keytip is unchanged. - When a tab's groups are wider than the window, arrows at the card's edges scroll them; before, only the mouse wheel did and nothing showed that the tab went on. ribbon::RibbonOverflow records the groups' and the card's sizes each frame. - A test checks every tab fits the card's three rows. It found View › Show stacking four checkboxes, with Headings cut off; Show is now two columns (Ruler, Gridlines / Formula Bar, Headings). Tests: tests/ribbon_layout.rs (the split button's two parts, AutoSum's icon and menu, the scroll arrows, the card height). * UI: formula bar grows from one to five lines; code editor widget The formula bar is a top-aligned row: the Name Box and the cancel, enter and fx buttons stay on the first line while the field grows. Its height is the content's laid-out line count (Alt+Enter or wrapped text) times the font's row height, from one line (30 px, as before) to five, then the field scrolls with the caret in view. This holds while editing and while viewing a cell. The height eases over 120 ms when the line count changes, and the chevron pins it at five lines (it used to give a fixed 72 px). The in-cell editor likewise stops at five lines and scrolls. formula_bar::BarLayout records where the parts went, for tests. widgets::code_editor(ui, id, &mut text, Lang, max_lines) is a small code editor for features that edit code: the monospace font, syntax colours from Tokens::syntax (new, with light and dark values in the contrast test) for Python (keywords, strings including triple-quoted ones, numbers, comments and builtins) and LaTeX (commands, scripts and comments), Tab inserting four spaces (or indenting every selected line, Shift+Tab dedenting), Enter keeping the line's indentation (one level more after a Python colon), and a scroll area that follows the caret. The tokenizer and the edits are pure functions with tests, including hostile input that must keep spans on char boundaries. kittest (tests/formula_bar.rs) covers Alt+Enter up to eight lines with the Name Box and buttons fixed and the caret visible, the commit, viewing the committed cell, the chevron, the ease, the in-cell cap and the code editor's Tab and Enter. * UI: Shift+Tab in the code editor dedents instead of indenting The editor consumed plain Tab before Shift+Tab, and egui's key match ignores Shift when the pattern lacks it, so a Shift+Tab was taken as Tab and indented the line. Shift+Tab is now consumed first; a plain Tab still inserts spaces. * UI: the ribbon card stays inside a narrow window, so its right scroll arrow is on screen The row above the ribbon can be wider than a narrow window, and the card took that width, so the right scroll arrow was drawn past the window's edge and couldn't be clicked: below about 640 px on macOS, and at 700 px with Linux's wider default fonts, where CI's ribbon test failed. The card is now kept within the window with the same margin it has on the left. * UI: each ribbon tab keeps a scroll offset of its own The ribbon's scroll area used one identity for every tab, so scrolling an overflowing tab and switching to another overflowing one hid that tab's first groups. Each tab now has its own scroll state: a tab opened after another was scrolled starts at its left edge, and a tab keeps its offset while others are used. * UI: the in-cell editor keeps its whole width for the text and shows every line Merged with main, the grid's own test asked the in-cell editor to wrap its text at the field's full width and to show every line (the editor never scrolls); the editor widget wrapped it inside the formula bar's scroll area, which took the bar's width off the text (168 px tall where 156 was right), and the grid capped it at five lines. The in-cell editor now shows the text field on its own, through the one show_edit helper the formula bar also uses, and grows with every line; the formula bar keeps its five lines and its caret scroll. The branch's five-line test for the cell editor becomes a grows-with-every-line test. * Docs: the UI building blocks' revision rows carry the date they land The rows said 2026-10-11, older than upstream rows above them, and the ui-parity row said the in-cell editor is capped at five lines. * UI: the formula bar's view of a cell starts at the cell's top The bar's read-only view kept one scroll offset for every cell, so a cell shown after scrolling another's text started scrolled. The offset is kept per cell now. * UI: the editor's field does nothing with Enter on its own The editor handles Enter (commit) and Alt+Enter (a line break) itself, and a message box over the editor takes Enter to close. The field under it is a multi-line TextEdit since the bar grew, whose own return key would add a line if it ever saw Enter; return_key(None) keeps that from depending on where focus sits. A test shows Enter over a message box adds nothing to the entry (focus is off the field then, so it passed before too: this is hardening, not a repair). The doc comment split by the show_edit helper is mended. * Docs: ui-parity says the in-cell editor shows every line, and its revision table is whole A sentence still said the in-cell editor stops at five lines, and a blank line between the table's separator and its first row ended the table. * UI: the split button's tooltip carries the shortcut hint like every ribbon button's big_split_button built its tooltip with the bare translation, so AutoSum, which became a split button, lost its shortcut hint. It uses tip_text now; the split-button test hovers the button and looks for the hint. * UI: a selection that ends at the start of a line leaves that line alone Selecting "a\n" in "a\nb" and pressing Tab also indented b, and Shift+Tab could take its indentation: the line after the selection was counted as touched. It is touched only when the selection reaches into it. * UI: the code editor applies its keys in input order, with the typing around them The editor consumed Enter, Tab and Shift+Tab before the text field saw the frame's events, so a frame carrying typed text followed by Enter (events gather between repaints, and under key repeat) put the newline before the text, and two Enters in one frame made one line. The editor now walks the frame's events in order and applies typed text, pastes, Enter, Tab, Shift+Tab, Backspace and Delete itself, taking them from the queue; the first event it does not handle and everything after it stay for the field, which runs next, so the order holds either way. * UI: the code editor keeps the input order past the keys the text field handles The editor applied its keys up to the first event it did not handle and left that event and everything after it to the text field, which has its own Tab and Enter: select all followed by Tab in one frame replaced the code with a tab character, and since the platform sends a key event before each text event, an Enter after ordinary typing lost its indentation. The editor now applies its keys up to the first event the field acts on (a caret or selection key, a key under a command modifier, IME, a click); that event and the run after it stay for the field; from the editor's next key on, the keyboard events are held back under the editor's id for its next pass, where it puts them ahead of that pass's input and splits again. They go only to the editor they were typed into, only while it still has the focus, and only in the pass right after; otherwise they are dropped rather than typed into another widget. Printable key presses do not split the stream. Tab under a command or option modifier is taken off the queue, and Enter under Shift or Option is the editor's newline, so the field never gets a Tab or an Enter of a focused editor. * UI: the ribbon layout and formula bar tests join the crate's integration test binary Since #2372 the crate's integration tests are one binary built from tests/it/, one module per file; only tests that set process-wide state stay separate. These two set none. * UI: the formula bar's one-line height follows the font, so five lines fit on Windows One line was a fixed 30 px and the bar grew by one row height per line. The panel's and the field's margins take 12 px of that, and rounding to the pixel grid up to another, so a row taller than 17 px lost the last counted line: with the Windows UI font (17.28 px rows) five lines showed four, and bar_grows_to_five_lines_then_scrolls_with_name_box_pinned failed there. formula_bar::one_line(row_h) is 30 px unless the rows need more, and every counted line fits whatever the font. The kittest heights are computed from it rather than from 30, and a unit test checks the arithmetic for rows of 15.6 to 20 px. * UI: Paste Special's OK button uses the on-accent colour It was the one OK button still drawn white on the accent: 2.14:1 in the dark theme, where every other OK uses on_accent (7.1:1). --- ROADMAP.md | 2 + crates/ui-egui/src/cell_pictures.rs | 7 +- crates/ui-egui/src/chartview.rs | 21 +- crates/ui-egui/src/code_editor.rs | 938 +++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 14 +- crates/ui-egui/src/editor.rs | 16 +- crates/ui-egui/src/formula_bar.rs | 236 ++++-- crates/ui-egui/src/grid.rs | 33 +- crates/ui-egui/src/icons.rs | 275 ++++--- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panes.rs | 13 +- crates/ui-egui/src/ribbon.rs | 190 +++-- crates/ui-egui/src/theme.rs | 225 +++++- crates/ui-egui/src/widgets.rs | 78 +- crates/ui-egui/tests/it/formula_bar.rs | 212 +++++ crates/ui-egui/tests/it/main.rs | 2 + crates/ui-egui/tests/it/ribbon_layout.rs | 156 ++++ docs/architecture.md | 35 +- docs/ui-parity.md | 11 +- 19 files changed, 2174 insertions(+), 291 deletions(-) create mode 100644 crates/ui-egui/src/code_editor.rs create mode 100644 crates/ui-egui/tests/it/formula_bar.rs create mode 100644 crates/ui-egui/tests/it/ribbon_layout.rs diff --git a/ROADMAP.md b/ROADMAP.md index da06359b5..f11e9a008 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -181,6 +181,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up | | 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | +| 2026-10-11 | UI building blocks: theme tokens with a WCAG contrast test over both themes (icons and reference colours follow dark mode); ribbon scroll arrows and a large split button (Formulas › AutoSum opens its menu); the formula bar grows from one to five lines, then scrolls; a code editor widget with Python and LaTeX colouring | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | | 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) | @@ -254,6 +255,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) | | 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | +| 2026-10-11 | minor | UI building blocks; progress log row | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | | 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row | diff --git a/crates/ui-egui/src/cell_pictures.rs b/crates/ui-egui/src/cell_pictures.rs index 461014022..c28167c4d 100644 --- a/crates/ui-egui/src/cell_pictures.rs +++ b/crates/ui-egui/src/cell_pictures.rs @@ -130,7 +130,12 @@ pub(crate) fn paint(painter: &Painter, cell: Rect, picture: &Arc) { painter.image(texture.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } } else { - crate::icons::paint(&painter, cell.shrink(2.0), crate::icons::Icon::Picture, Color32::from_gray(130)); + crate::icons::paint_on_sheet( + &painter, + cell.shrink(2.0), + crate::icons::Icon::Picture, + crate::theme::Tokens::get(painter.ctx()).sheet_control_border, + ); } } diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index fad08ff22..fb3dbd8dd 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -8,7 +8,7 @@ use gridcraft_engine::model::{Anchor, ShapeKind, Sheet, Workbook}; use crate::SheetApp; use crate::grid::Geo; -use crate::theme; +use crate::theme::{self, Tokens}; fn rgba(c: [u8; 4]) -> Color32 { Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]) @@ -310,7 +310,8 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo } ShapeKind::Icon => { if let Some(icon) = crate::icons::from_name(&sp.text) { - crate::icons::paint(p, r, icon, if fill == Color32::TRANSPARENT { Color32::BLACK } else { fill }); + let t = Tokens::get(p.ctx()); + crate::icons::paint_on_sheet(p, r, icon, if fill == Color32::TRANSPARENT { t.cell_text } else { fill }); } label = false; } @@ -341,7 +342,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo if let Some(tex) = crate::floating_pictures::texture(p.ctx(), doc, im, r) { p.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } else { - p.rect_filled(r, 0.0, Color32::from_gray(230)); + p.rect_filled(r, 0.0, Tokens::get(p.ctx()).sheet_track); } if app.selected_chart == Some(im.id) { selection_frame(p, r); @@ -356,7 +357,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo let start = p.add(egui::Shape::Noop); // Soft shadow + white card. p.rect_filled(r.translate(vec2(0.0, 1.5)), 2.0, Color32::from_black_alpha(18)); - p.rect_filled(r, 0.0, Color32::WHITE); + p.rect_filled(r, 0.0, Tokens::get(p.ctx()).grid_bg); let data = gridcraft_chart::resolve(wb, si, ch); let m = EguiMeasure { painter: p, z: geo.z }; let prims = gridcraft_chart::render(ch, &data, ch.anchor.width, ch.anchor.height, &m); @@ -373,9 +374,10 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo && rf.sheet_name().is_none_or(|n| n.eq_ignore_ascii_case(&sh.name)) { let rr = geo.range_rect(sh, rf.range()); + let color = Tokens::get(p.ctx()).ref_color(0, true); for pane in geo.range_panes(rf.range()) { let pp = p.with_clip_rect(pane.intersect(p.clip_rect())); - pp.rect_stroke(rr, 0.0, Stroke::new(1.5, Color32::from_rgb(0x2E, 0x6F, 0xD8)), StrokeKind::Inside); + pp.rect_stroke(rr, 0.0, Stroke::new(1.5, color), StrokeKind::Inside); } } } @@ -447,7 +449,7 @@ fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usiz if !r.contains(tr.left_top()) { continue; // overflowed the card; skip (no scroll yet) } - let (fill, fg) = if *on { (tk.accent, Color32::WHITE) } else { (tk.hover, tk.text_dim) }; + let (fill, fg) = if *on { (tk.accent, tk.on_accent) } else { (tk.hover, tk.text_dim) }; p.rect_filled(*tr, 3.0 * geo.z, fill); let cp = p.with_clip_rect(tr.intersect(p.clip_rect())); cp.text(tr.left_center() + vec2(6.0 * geo.z, 0.0), Align2::LEFT_CENTER, val, theme::ui_font(12.0 * geo.z), fg); @@ -491,10 +493,11 @@ pub fn slicer_click(geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) -> } fn selection_frame(p: &Painter, r: Rect) { - p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(120)), StrokeKind::Outside); + let t = Tokens::get(p.ctx()); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Outside); for c in [r.left_top(), r.center_top(), r.right_top(), r.left_center(), r.right_center(), r.left_bottom(), r.center_bottom(), r.right_bottom()] { - p.circle_filled(c, 4.0, Color32::WHITE); - p.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::from_gray(100))); + p.circle_filled(c, 4.0, t.sheet_control_bg); + p.circle_stroke(c, 4.0, Stroke::new(1.0, t.sheet_control_border)); } let _ = Align2::CENTER_CENTER; } diff --git a/crates/ui-egui/src/code_editor.rs b/crates/ui-egui/src/code_editor.rs new file mode 100644 index 000000000..906ddf3f1 --- /dev/null +++ b/crates/ui-egui/src/code_editor.rs @@ -0,0 +1,938 @@ +//! A small code editor: monospace text with syntax colours, Tab to indent, Enter that keeps the +//! indentation, and a scroll area that follows the caret. It colours Python (for code in cells, +//! where `xl()` reads the sheet) and LaTeX (for equation source); reached as +//! `widgets::code_editor`. The tokenizer and the key edits are pure functions, tested below. + +use std::ops::Range; + +use egui::text::{CCursor, CCursorRange}; +use egui::{FontFamily, FontId, Key, Response, TextEdit, Ui}; + +use crate::theme::{self, Syntax, Tokens}; + +/// The language a code editor colours. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Lang { + Python, + Latex, + Plain, +} + +/// A coloured span's kind. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Token { + Keyword, + String, + Number, + Comment, + Builtin, +} + +/// Spaces one Tab inserts. +pub const INDENT: &str = " "; + +const PY_KEYWORDS: &[&str] = &[ + "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", "except", + "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "match", "case", "nonlocal", "not", "or", "pass", "raise", "return", + "try", "while", "with", "yield", +]; + +const PY_BUILTINS: &[&str] = &[ + "abs", + "all", + "any", + "bool", + "dict", + "enumerate", + "filter", + "float", + "format", + "int", + "isinstance", + "len", + "list", + "map", + "max", + "min", + "print", + "range", + "reversed", + "round", + "set", + "sorted", + "str", + "sum", + "tuple", + "type", + "zip", + "xl", +]; + +/// The coloured spans of `text` (byte ranges on char boundaries, in order, not overlapping). +/// Anything not covered is plain text. Never panics, whatever the input. +pub fn highlight(text: &str, lang: Lang) -> Vec<(Range, Token)> { + match lang { + Lang::Python => python(text), + Lang::Latex => latex(text), + Lang::Plain => Vec::new(), + } +} + +fn python(text: &str) -> Vec<(Range, Token)> { + let chars: Vec<(usize, char)> = text.char_indices().collect(); + let end_of = |i: usize| chars.get(i).map(|(b, _)| *b).unwrap_or(text.len()); + let mut out = Vec::new(); + let mut i = 0; + while let Some(&(start, c)) = chars.get(i) { + if c == '#' { + let mut j = i; + while chars.get(j).is_some_and(|(_, c)| *c != '\n') { + j += 1; + } + out.push((start..end_of(j), Token::Comment)); + i = j; + } else if c == '"' || c == '\'' { + let triple = chars.get(i + 1).is_some_and(|(_, d)| *d == c) && chars.get(i + 2).is_some_and(|(_, d)| *d == c); + let mut j = if triple { i + 3 } else { i + 1 }; + loop { + match chars.get(j) { + None => break, + Some((_, '\\')) => j += 2, + Some((_, '\n')) if !triple => break, + Some((_, d)) if *d == c => { + if !triple { + j += 1; + break; + } + if chars.get(j + 1).is_some_and(|(_, d)| *d == c) && chars.get(j + 2).is_some_and(|(_, d)| *d == c) { + j += 3; + break; + } + j += 1; + } + Some(_) => j += 1, + } + } + let j = j.min(chars.len()); + out.push((start..end_of(j), Token::String)); + i = j; + } else if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(|(_, d)| d.is_ascii_digit())) { + let mut j = i + 1; + while chars.get(j).is_some_and(|(_, d)| d.is_ascii_alphanumeric() || *d == '_' || *d == '.') { + j += 1; + } + out.push((start..end_of(j), Token::Number)); + i = j; + } else if c.is_alphabetic() || c == '_' { + let mut j = i + 1; + while chars.get(j).is_some_and(|(_, d)| d.is_alphanumeric() || *d == '_') { + j += 1; + } + let word = text.get(start..end_of(j)).unwrap_or(""); + if PY_KEYWORDS.contains(&word) { + out.push((start..end_of(j), Token::Keyword)); + } else if PY_BUILTINS.contains(&word) { + out.push((start..end_of(j), Token::Builtin)); + } + i = j; + } else { + i += 1; + } + } + out +} + +fn latex(text: &str) -> Vec<(Range, Token)> { + let chars: Vec<(usize, char)> = text.char_indices().collect(); + let end_of = |i: usize| chars.get(i).map(|(b, _)| *b).unwrap_or(text.len()); + let mut out = Vec::new(); + let mut i = 0; + while let Some(&(start, c)) = chars.get(i) { + match c { + '%' => { + let mut j = i; + while chars.get(j).is_some_and(|(_, c)| *c != '\n') { + j += 1; + } + out.push((start..end_of(j), Token::Comment)); + i = j; + } + '\\' => { + let mut j = i + 1; + if chars.get(j).is_some_and(|(_, d)| d.is_ascii_alphabetic()) { + while chars.get(j).is_some_and(|(_, d)| d.is_ascii_alphabetic()) { + j += 1; + } + } else if chars.get(j).is_some() { + j += 1; // a control symbol such as \{ or \% + } + out.push((start..end_of(j), Token::Keyword)); + i = j; + } + '^' | '_' | '&' => { + out.push((start..end_of(i + 1), Token::Builtin)); + i += 1; + } + d if d.is_ascii_digit() => { + let mut j = i + 1; + while chars.get(j).is_some_and(|(_, d)| d.is_ascii_digit() || *d == '.') { + j += 1; + } + out.push((start..end_of(j), Token::Number)); + i = j; + } + _ => i += 1, + } + } + out +} + +fn color(s: &Syntax, token: Token) -> egui::Color32 { + match token { + Token::Keyword => s.keyword, + Token::String => s.string, + Token::Number => s.number, + Token::Comment => s.comment, + Token::Builtin => s.builtin, + } +} + +/// The code editor's font: the monospace face at 12.5 px. +pub fn font() -> FontId { + FontId::new(12.5, FontFamily::Name(theme::MONO.into())) +} + +/// A layout job for `text` with syntax colours. +pub fn layout_job(text: &str, lang: Lang, font: &FontId, plain: egui::Color32, syntax: &Syntax, wrap_width: f32) -> egui::text::LayoutJob { + let mut job = egui::text::LayoutJob::default(); + let mut pos = 0usize; + for (range, token) in highlight(text, lang) { + if range.start < pos { + continue; + } + if let (Some(before), Some(span)) = (text.get(pos..range.start), text.get(range.clone())) { + job.append(before, 0.0, egui::TextFormat::simple(font.clone(), plain)); + job.append(span, 0.0, egui::TextFormat::simple(font.clone(), color(syntax, token))); + pos = range.end; + } + } + job.append(text.get(pos..).unwrap_or(""), 0.0, egui::TextFormat::simple(font.clone(), plain)); + job.wrap.max_width = wrap_width; + job +} + +fn byte_of(text: &str, char_index: usize) -> usize { + text.char_indices().nth(char_index).map(|(b, _)| b).unwrap_or(text.len()) +} + +/// Start (char index) of the line holding char `at`. +fn line_start(text: &str, at: usize) -> usize { + let b = byte_of(text, at); + text.get(..b).and_then(|s| s.rfind('\n')).map(|nl| text.get(..nl + 1).map(|s| s.chars().count()).unwrap_or(0)).unwrap_or(0) +} + +/// What a key did to the text: the new caret (and selection anchor), as char indices. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Edit { + pub anchor: usize, + pub caret: usize, +} + +/// Enter: a newline that keeps the current line's indentation (one level more after a Python `:`). +/// `sel` is the selection as char indices (anchor, caret); it is replaced. +pub fn newline(text: &mut String, sel: (usize, usize), lang: Lang) -> Edit { + let count = text.chars().count(); + let (a, b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count)); + let ls = line_start(text, a); + let line_before: String = text.chars().skip(ls).take(a - ls.min(a)).collect(); + let mut indent: String = line_before.chars().take_while(|c| *c == ' ' || *c == '\t').collect(); + if lang == Lang::Python && line_before.trim_end().ends_with(':') { + indent.push_str(INDENT); + } + let insert = format!("\n{indent}"); + let (ba, bb) = (byte_of(text, a), byte_of(text, b)); + text.replace_range(ba..bb, &insert); + let caret = a + insert.chars().count(); + Edit { anchor: caret, caret } +} + +/// Typed text (or a paste) replacing the selection. +pub fn insert(text: &mut String, sel: (usize, usize), s: &str) -> Edit { + let count = text.chars().count(); + let (a, b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count)); + let (ba, bb) = (byte_of(text, a), byte_of(text, b)); + text.replace_range(ba..bb, s); + let caret = a + s.chars().count(); + Edit { anchor: caret, caret } +} + +/// Backspace (`back`) or Delete: the selection goes, or the character before or after the caret. +pub fn delete(text: &mut String, sel: (usize, usize), back: bool) -> Edit { + let count = text.chars().count(); + let (mut a, mut b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count)); + if a == b { + if back { + a = a.saturating_sub(1); + } else { + b = (b + 1).min(count); + } + } + let (ba, bb) = (byte_of(text, a), byte_of(text, b)); + text.replace_range(ba..bb, ""); + Edit { anchor: a, caret: a } +} + +/// An edit the editor makes itself, read off an input event by `classify`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EditKey<'a> { + Insert(&'a str), + Enter, + Tab { dedent: bool }, + Backspace, + Delete, +} + +/// What one input event means to the editor. See `apply_in_order`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Class<'a> { + /// The editor applies it: typed text, a paste, Enter, Tab, Shift+Tab, Backspace, Delete. + Edit(EditKey<'a>), + /// The editor takes it off the queue and does nothing: Tab under a command or option + /// modifier, which the field would turn into a tab character in place of the selection. + Dropped, + /// The text field acts on it: a caret or selection key, a command shortcut (select all, + /// undo, word deletion), IME, Copy, Cut, or a click, which the field reads from the pointer + /// state. + Field, + /// Neither reads it from the queue: a key release, a printable key press (its character + /// arrives as the `Text` event after it, also under Shift or Option), a pointer move, Escape + /// (the focus system acts on it when the pass begins). + Other, +} + +fn classify(ev: &egui::Event) -> Class<'_> { + use egui::Event; + match ev { + // The field treats a lone newline as the Enter key's business, and ignores it. + Event::Text(t) if t != "\n" && t != "\r" => Class::Edit(EditKey::Insert(t)), + Event::Text(_) => Class::Other, + Event::Paste(t) => Class::Edit(EditKey::Insert(t)), + Event::Key { key, pressed: true, modifiers, .. } => { + let command = modifiers.command || modifiers.ctrl || modifiers.mac_cmd; + match key { + // The field makes a newline of Enter under any modifier but a command one, and + // a tab character of Tab under every modifier; the editor takes all of those. + Key::Enter if !command => Class::Edit(EditKey::Enter), + Key::Tab if !command && !modifiers.alt => Class::Edit(EditKey::Tab { dedent: modifiers.shift }), + Key::Tab => Class::Dropped, + Key::Backspace if modifiers.is_none() => Class::Edit(EditKey::Backspace), + Key::Delete if modifiers.is_none() => Class::Edit(EditKey::Delete), + // The keys egui's text field reads: caret and selection moves, word and line + // deletion, and its command shortcuts (select all, undo, redo, the Emacs keys). + Key::Enter | Key::Backspace | Key::Delete => Class::Field, + Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | Key::Home | Key::End => Class::Field, + _ if command => Class::Field, + _ => Class::Other, + } + } + Event::Copy | Event::Cut | Event::Ime(_) | Event::PointerButton { pressed: true, .. } => Class::Field, + _ => Class::Other, + } +} + +/// Whether an event is part of the keyboard stream, whose order the editor keeps. Pointer, +/// touch, scroll and window events are not: egui folds them into its input state when the +/// pass begins, so they act in the pass they arrive in whatever their place among the keys. +fn is_keyboard(ev: &egui::Event) -> bool { + use egui::Event; + matches!(ev, Event::Key { .. } | Event::Text(_) | Event::Paste(_) | Event::Copy | Event::Cut | Event::Ime(_)) +} + +fn apply(key: EditKey<'_>, text: &mut String, sel: (usize, usize), lang: Lang) -> Edit { + match key { + EditKey::Insert(s) => insert(text, sel, s), + EditKey::Enter => newline(text, sel, lang), + EditKey::Tab { dedent } => indent(text, sel, dedent), + EditKey::Backspace => delete(text, sel, true), + EditKey::Delete => delete(text, sel, false), + } +} + +/// What `apply_in_order` did with a pass's events. +#[derive(Debug, Default, PartialEq)] +pub struct Applied { + /// Whether the text or the selection changed. + pub edited: bool, + /// The keyboard events held back for the next pass, in order: the editor's first key after + /// an event the text field acts on, and every keyboard event after that key. + pub held: Vec, +} + +/// Applies the pass's input to `text` in the order it came. A pass can carry several events +/// (typing between repaints, key repeat), and two readers take them: this editor, for typed +/// text, pastes, Enter, Tab, Shift+Tab, Backspace and Delete, and then egui's text field, for +/// the caret and selection keys, command shortcuts, IME and clicks. The editor runs first, so +/// it can only apply its keys up to the first event the field acts on: its edits before that +/// event are applied and taken from the queue, the field's event and the run after it stay in +/// the queue for the field, and from the editor's next key on the keyboard events are held +/// back (`Applied::held`) for the next pass, where `code_editor` puts them ahead of that +/// pass's input and they go through this again. So select all, Tab in one pass indents every +/// line in two passes, and the field never gets an Enter or a Tab of a focused editor. +/// Printable key presses do not split the stream: the field reads the `Text` event that +/// follows each one, not the key. `sel` is the selection as char indices (anchor, caret), +/// updated. +pub fn apply_in_order(events: &mut Vec, text: &mut String, sel: &mut (usize, usize), lang: Lang) -> Applied { + let mut out = Applied::default(); + // An event the field acts on has gone by: the editor's keys after it wait for the next pass. + let mut field_seen = false; + // A key is waiting: every keyboard event after it waits too, so the order holds. + let mut holding = false; + let mut keep = Vec::with_capacity(events.len()); + for ev in std::mem::take(events) { + if holding && is_keyboard(&ev) { + out.held.push(ev); + continue; + } + match classify(&ev) { + Class::Edit(_) if field_seen => { + holding = true; + out.held.push(ev); + } + Class::Edit(key) => { + let e = apply(key, text, *sel, lang); + *sel = (e.anchor, e.caret); + out.edited = true; + } + Class::Dropped => {} + Class::Field => { + field_seen = true; + keep.push(ev); + } + Class::Other => keep.push(ev), + } + } + *events = keep; + out +} + +/// The keyboard events a code editor held back, with the pass that held them. +#[derive(Clone, Debug, Default)] +struct Held { + pass: u64, + events: Vec, +} + +fn held_id(id: egui::Id) -> egui::Id { + id.with("held") +} + +/// The events the editor `id` held back in the previous pass, to go ahead of this pass's input +/// (see `apply_in_order`). Only the editor they were typed into gets them, only while it still +/// has the focus, and only in the pass right after: once the focus has moved on, or the editor +/// was not shown in between, they are dropped rather than typed into another widget. +fn take_held(ui: &Ui, id: egui::Id, focused: bool) -> Vec { + let pass = ui.ctx().cumulative_pass_nr(); + ui.ctx() + .data_mut(|d| d.remove_temp::(held_id(id))) + .filter(|h| focused && h.pass.checked_add(1) == Some(pass)) + .map(|h| h.events) + .unwrap_or_default() +} + +/// Holds `events` back for the editor's next pass, and asks for that pass. +fn hold(ui: &Ui, id: egui::Id, events: Vec) { + let pass = ui.ctx().cumulative_pass_nr(); + ui.ctx().data_mut(|d| d.insert_temp(held_id(id), Held { pass, events })); + ui.ctx().request_repaint(); +} + +/// Tab (indent) or Shift+Tab (dedent). A selection over several lines indents or dedents each +/// line; otherwise Tab inserts four spaces at the caret. +pub fn indent(text: &mut String, sel: (usize, usize), dedent: bool) -> Edit { + let count = text.chars().count(); + let (a, b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count)); + let multi = text.chars().skip(a).take(b - a).any(|c| c == '\n'); + if !dedent && !multi { + let (ba, bb) = (byte_of(text, a), byte_of(text, b)); + text.replace_range(ba..bb, INDENT); + let caret = a + INDENT.len(); + return Edit { anchor: caret, caret }; + } + // Line starts (char indices) of every line the selection touches. A selection that ends at + // the start of the next line (`"a\n"` in `"a\nb"`) does not touch that line. + let mut starts = vec![line_start(text, a)]; + for (i, c) in text.chars().enumerate().skip(a).take(b - a) { + if c == '\n' && i + 1 < b { + starts.push(i + 1); + } + } + let (mut na, mut nb) = (a, b); + // Work from the last line back so earlier indices stay valid. + for &s in starts.iter().rev() { + let bs = byte_of(text, s); + let n = if dedent { + let n = text.get(bs..).map(|rest| rest.chars().take(INDENT.len()).take_while(|c| *c == ' ').count()).unwrap_or(0); + text.replace_range(bs..bs + n, ""); + n + } else { + text.insert_str(bs, INDENT); + 0 + }; + let shift = |p: usize| { + if dedent { + if p > s { p - n.min(p - s) } else { p } + } else if p >= s { + p + INDENT.len() + } else { + p + } + }; + na = shift(na); + nb = shift(nb); + } + if sel.1 >= sel.0 { Edit { anchor: na, caret: nb } } else { Edit { anchor: nb, caret: na } } +} + +/// The code editor. `max_lines` rows are shown before it scrolls; the scroll area follows the +/// caret. +pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max_lines: usize) -> Response { + let t = Tokens::get(ui.ctx()); + let font = font(); + let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font)); + let max_lines = max_lines.clamp(1, 200); + // The input the editor handles itself (Enter with indentation, Tab, Shift+Tab), applied in + // input order with the typing around it, before the text field sees the rest; what had to + // wait for the field in the previous pass comes first. + let focused = ui.memory(|m| m.has_focus(id)); + let held = take_held(ui, id, focused); + let mut edited = None; + if focused { + let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let count = text.chars().count(); + let mut sel = state.cursor.char_range().map(|r| (r.secondary.index.into(), r.primary.index.into())).unwrap_or((count, count)); + let applied = ui.input_mut(|i| { + if !held.is_empty() { + let mut events = held; + events.append(&mut i.events); + i.events = events; + } + apply_in_order(&mut i.events, text, &mut sel, lang) + }); + if applied.edited { + state.cursor.set_char_range(Some(CCursorRange::two(CCursor::new(sel.0), CCursor::new(sel.1)))); + state.store(ui.ctx(), id); + edited = Some(Edit { anchor: sel.0, caret: sel.1 }); + } + if !applied.held.is_empty() { + hold(ui, id, applied.held); + } + } + let syntax = t.syntax; + let plain = t.text; + let mut layouter = |ui: &Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| { + let job = layout_job(buf.as_str(), lang, &self::font(), plain, &syntax, wrap_width); + ui.fonts_mut(|f| f.layout_job(job)) + }; + let frame_h = row_h * max_lines as f32 + 8.0; + let mut response = None; + egui::Frame::new().fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(4.0).inner_margin(4.0).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt(id.with("scroll")).max_height(frame_h - 8.0).auto_shrink([false, true]).show(ui, |ui| { + let out = TextEdit::multiline(text) + .id(id) + .font(font.clone()) + .code_editor() + .frame(egui::Frame::NONE) + .desired_width(f32::INFINITY) + .desired_rows(1) + .lock_focus(true) + .layouter(&mut layouter) + .show(ui); + if let Some(e) = edited { + let caret = out.galley.pos_from_cursor(CCursor::new(e.caret)).translate(out.galley_pos.to_vec2()); + ui.scroll_to_rect(caret, None); + } + let mut r = out.response.response; + if edited.is_some() { + r.mark_changed(); + } + response = Some(r); + }); + }); + response.unwrap_or_else(|| ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover())) +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::Modifiers; + + fn spans(text: &str, lang: Lang) -> Vec<(&str, Token)> { + highlight(text, lang).into_iter().map(|(r, k)| (text.get(r).unwrap_or("?"), k)).collect() + } + + #[test] + fn python_tokens() { + let s = spans("def f(x):\n return len(x) + 1.5 # done\nxl(\"A1:B2\")", Lang::Python); + assert_eq!( + s, + vec![ + ("def", Token::Keyword), + ("return", Token::Keyword), + ("len", Token::Builtin), + ("1.5", Token::Number), + ("# done", Token::Comment), + ("xl", Token::Builtin), + ("\"A1:B2\"", Token::String), + ] + ); + assert_eq!(spans("s = '''a\n'b'\nc'''", Lang::Python), vec![("'''a\n'b'\nc'''", Token::String)]); + assert_eq!(spans("x = 'it\\'s'", Lang::Python), vec![("'it\\'s'", Token::String)]); + assert_eq!(spans("hello", Lang::Plain), vec![]); + } + + #[test] + fn latex_tokens() { + let s = spans("\\frac{a^2}{3} % half\n\\{", Lang::Latex); + assert_eq!( + s, + vec![ + ("\\frac", Token::Keyword), + ("^", Token::Builtin), + ("2", Token::Number), + ("3", Token::Number), + ("% half", Token::Comment), + ("\\{", Token::Keyword), + ] + ); + } + + #[test] + fn hostile_input_never_panics_and_spans_stay_on_char_boundaries() { + let inputs = [ + "", + "'", + "\"", + "'''", + "\"\"\"x", + "#", + "\\", + "%", + "1", + ".", + "é'ü\\", + "θ = 'π' # ∑", + "\u{1F600}'\u{1F600}", + "'\\", + "\\\u{1F600}", + "\u{200B}\n\t'''\n", + "0x_é", + "a.b.c", + "____", + ]; + for text in inputs { + for lang in [Lang::Python, Lang::Latex, Lang::Plain] { + let mut last = 0; + for (r, _) in highlight(text, lang) { + assert!(r.start >= last && r.start <= r.end && r.end <= text.len(), "{text:?} {lang:?} {r:?}"); + assert!(text.is_char_boundary(r.start) && text.is_char_boundary(r.end), "{text:?} {lang:?} {r:?}"); + last = r.end; + } + let job = layout_job(text, lang, &FontId::default(), egui::Color32::BLACK, &Tokens::light().syntax, f32::INFINITY); + assert_eq!(job.text, text, "every character is laid out once"); + } + } + } + + #[test] + fn enter_keeps_indentation_and_indents_after_a_colon() { + let mut s = " x = 1".to_string(); + let e = newline(&mut s, (9, 9), Lang::Python); + assert_eq!(s, " x = 1\n "); + assert_eq!(e.caret, 14); + let mut s = "if x:".to_string(); + let e = newline(&mut s, (5, 5), Lang::Python); + assert_eq!(s, "if x:\n "); + assert_eq!(e.caret, 10); + // Plain text keeps the indentation but adds none after a colon. + let mut s = " a:".to_string(); + newline(&mut s, (4, 4), Lang::Plain); + assert_eq!(s, " a:\n "); + // A selection is replaced; out-of-range carets are clamped. + let mut s = "abcdef".to_string(); + let e = newline(&mut s, (4, 2), Lang::Plain); + assert_eq!((s.as_str(), e.caret), ("ab\nef", 3)); + let mut s = "é".to_string(); + let e = newline(&mut s, (99, 99), Lang::Plain); + assert_eq!((s.as_str(), e.caret), ("é\n", 2)); + } + + #[test] + fn shift_tab_in_the_editor_dedents_rather_than_indents() { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, text: &mut String| { + if ui.ctx().cumulative_pass_nr() == 0 { + let mut fonts = egui::FontDefinitions::default(); + let mono = fonts.families.get(&egui::FontFamily::Monospace).cloned().unwrap_or_default(); + fonts.families.insert(egui::FontFamily::Name(theme::MONO.into()), mono); + ui.ctx().set_fonts(fonts); + return; + } + let id = egui::Id::new("code"); + ui.memory_mut(|m| m.request_focus(id)); + code_editor(ui, id, text, Lang::Python, 5); + }, + " x".to_string(), + ); + h.run_steps(2); + let tab = |modifiers| egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers }; + h.input_mut().events.push(tab(Modifiers::SHIFT)); + h.run_steps(1); + assert_eq!(h.state(), " x"); + // A plain Tab still inserts spaces at the caret (the end). + h.input_mut().events.push(tab(Modifiers::NONE)); + h.run_steps(1); + assert_eq!(h.state(), " x "); + } + + /// Gives the context the editor's monospace family (the test has no craft fonts). + fn install_mono(ctx: &egui::Context) { + let mut fonts = egui::FontDefinitions::default(); + let mono = fonts.families.get(&egui::FontFamily::Monospace).cloned().unwrap_or_default(); + fonts.families.insert(egui::FontFamily::Name(theme::MONO.into()), mono); + ctx.set_fonts(fonts); + } + + fn editor_harness(text: &str) -> egui_kittest::Harness<'static, String> { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, text: &mut String| { + if ui.ctx().cumulative_pass_nr() == 0 { + install_mono(ui.ctx()); + return; + } + let id = egui::Id::new("code"); + ui.memory_mut(|m| m.request_focus(id)); + code_editor(ui, id, text, Lang::Python, 5); + }, + text.to_string(), + ); + h.run_steps(2); + h + } + + fn key(key: Key, modifiers: Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } + } + + #[test] + fn batched_input_applies_in_order_and_keeps_every_key() { + // Events gather between repaints (typing, key repeat). The text typed before an Enter + // comes first, and two Enters make two lines. + let mut h = editor_harness(""); + h.input_mut().events.extend([egui::Event::Text("if x:".into()), key(Key::Enter, Modifiers::NONE), key(Key::Enter, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n "); + // Backspace and Tab in the same frame as the typing around them. + h.input_mut().events.extend([ + egui::Event::Text("y = 1".into()), + key(Key::Backspace, Modifiers::NONE), + egui::Event::Text("2".into()), + key(Key::Tab, Modifiers::NONE), + ]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n y = 2 "); + // An event the field acts on (an arrow) keeps the order too: the text typed before it + // is applied first, the field moves the caret, and the Enter after it waits for the + // next pass, where the editor applies it at the moved caret, with the indentation. + h.input_mut().events.extend([egui::Event::Text("a".into()), key(Key::ArrowLeft, Modifiers::NONE), key(Key::Enter, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n y = 2 a", "the Enter waits for the arrow"); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n y = 2 \n a"); + } + + #[test] + fn a_selection_ending_at_a_line_start_leaves_that_line_alone() { + // Selecting "a\n" in "a\nb" ends at the start of the second line, which is not selected. + let mut s = "a\nb".to_string(); + indent(&mut s, (0, 2), false); + assert_eq!(s, " a\nb"); + let mut s = " a\n b".to_string(); + indent(&mut s, (0, 6), true); + assert_eq!(s, "a\n b"); + // One character into the second line, both lines are touched. + let mut s = "a\nb".to_string(); + indent(&mut s, (0, 3), false); + assert_eq!(s, " a\n b"); + } + + #[test] + fn typed_text_and_deletions_replace_the_selection() { + let mut s = "abcd".to_string(); + assert_eq!(insert(&mut s, (3, 1), "X"), Edit { anchor: 2, caret: 2 }); + assert_eq!(s, "aXd"); + assert_eq!(delete(&mut s, (2, 2), true), Edit { anchor: 1, caret: 1 }); + assert_eq!(s, "ad"); + assert_eq!(delete(&mut s, (1, 1), false), Edit { anchor: 1, caret: 1 }); + assert_eq!(s, "a"); + assert_eq!(delete(&mut s, (0, 0), true), Edit { anchor: 0, caret: 0 }); + assert_eq!(delete(&mut s, (9, 9), false), Edit { anchor: 1, caret: 1 }); + assert_eq!(s, "a"); + let mut s = "éa".to_string(); + delete(&mut s, (2, 0), true); + assert_eq!(s, ""); + // The queue keeps what the editor does not handle, and everything after it. + let mut events = vec![ + egui::Event::Text("x".into()), + egui::Event::Text("\n".into()), + key(Key::ArrowLeft, Modifiers::NONE), + key(Key::Enter, Modifiers::NONE), + ]; + let (mut s, mut sel) = (String::new(), (0, 0)); + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert!(out.edited); + assert_eq!((s.as_str(), sel), ("x", (1, 1))); + assert_eq!(events.len(), 2, "the lone newline and the arrow stay for the field: {events:?}"); + assert_eq!(out.held, vec![key(Key::Enter, Modifiers::NONE)], "the Enter after the arrow waits for the next pass"); + let mut events = vec![egui::Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }]; + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert!(!out.edited && out.held.is_empty(), "a key release is nothing to apply"); + assert_eq!(events.len(), 1); + } + + #[test] + fn select_all_then_tab_in_one_pass_indents_every_line() { + // Cmd+A is the field's, Tab the editor's: the Tab waits for the pass after the + // selection and indents the selected lines, rather than reaching the field, which would + // replace the selection with a tab character. + let mut h = editor_harness("a\nb"); + h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "a\nb", "the Tab waits for the selection"); + h.run_steps(1); + assert_eq!(h.state(), " a\n b"); + h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::SHIFT)]); + h.run_steps(2); + assert_eq!(h.state(), "a\nb"); + } + + #[test] + fn a_keystroke_sequence_as_the_platform_sends_it_keeps_the_editors_enter() { + // egui-winit sends a Key event before each Text event. Those presses are nobody's to + // read, so they do not hand the Enter to the field, which knows no indentation. + let mut h = editor_harness(""); + let typed = |k, c: &str, m| [key(k, m), egui::Event::Text(c.into())]; + let mut events = Vec::new(); + events.extend(typed(Key::I, "i", Modifiers::NONE)); + events.extend(typed(Key::F, "f", Modifiers::NONE)); + events.extend(typed(Key::Space, " ", Modifiers::NONE)); + events.extend(typed(Key::X, "x", Modifiers::NONE)); + events.extend(typed(Key::Colon, ":", Modifiers::SHIFT)); + events.push(key(Key::Enter, Modifiers::NONE)); + h.input_mut().events.extend(events); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n "); + // A character typed with Option (macOS) comes as a key under that modifier and its text; + // the field reads neither the key nor Option, so the Enter after it is still the editor's. + h.input_mut().events.extend([key(Key::E, Modifiers::ALT), egui::Event::Text("\u{e9}".into()), key(Key::Enter, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \u{e9}\n "); + } + + #[test] + fn tab_and_enter_under_modifiers_never_reach_the_field() { + // The field would make a tab character of Ctrl+Tab (in place of the selection) and a + // newline of Shift+Enter; Cmd+Enter is nobody's here and stays for a shortcut. + let mut events = vec![key(Key::Tab, Modifiers::CTRL), key(Key::Enter, Modifiers::SHIFT), key(Key::Enter, Modifiers::COMMAND)]; + let (mut s, mut sel) = ("if x:".to_string(), (5, 5)); + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert_eq!(s, "if x:\n "); + assert_eq!(events, vec![key(Key::Enter, Modifiers::COMMAND)]); + assert!(out.held.is_empty()); + } + + #[test] + fn events_after_a_held_key_wait_with_it_and_pointer_events_do_not() { + let mut events = vec![ + key(Key::X, Modifiers::NONE), + egui::Event::Text("x".into()), + key(Key::A, Modifiers::COMMAND), + key(Key::Tab, Modifiers::NONE), + egui::Event::PointerMoved(egui::Pos2::ZERO), + key(Key::ArrowLeft, Modifiers::NONE), + egui::Event::Text("y".into()), + ]; + let (mut s, mut sel) = (String::new(), (0, 0)); + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert_eq!((s.as_str(), sel), ("x", (1, 1)), "the typing before the select-all is applied"); + assert_eq!(events, vec![key(Key::X, Modifiers::NONE), key(Key::A, Modifiers::COMMAND), egui::Event::PointerMoved(egui::Pos2::ZERO)]); + assert_eq!(out.held, vec![key(Key::Tab, Modifiers::NONE), key(Key::ArrowLeft, Modifiers::NONE), egui::Event::Text("y".into())]); + } + + #[test] + fn held_back_events_land_in_the_next_pass_only() { + let mut h = editor_harness(""); + let id = egui::Id::new("code"); + let held = |pass, c: &str| Held { pass, events: vec![egui::Event::Text(c.into())] }; + // What the pass just completed held back comes in with the next one. + let last = h.ctx.cumulative_pass_nr() - 1; + h.ctx.data_mut(|d| d.insert_temp(held_id(id), held(last, "a"))); + h.run_steps(1); + assert_eq!(h.state(), "a"); + // Anything older is dropped rather than typed out of context, and cleared. + h.ctx.data_mut(|d| d.insert_temp(held_id(id), held(last, "b"))); + h.run_steps(1); + assert_eq!(h.state(), "a"); + assert!(h.ctx.data(|d| d.get_temp::(held_id(id))).is_none()); + } + + #[test] + fn held_back_events_go_nowhere_once_the_editor_lost_the_focus() { + // Typed into the editor, they must not land in whatever has the focus next. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, s: &mut (String, String, bool)| { + if ui.ctx().cumulative_pass_nr() == 0 { + install_mono(ui.ctx()); + return; + } + let (code, other) = (egui::Id::new("code"), egui::Id::new("other")); + ui.memory_mut(|m| m.request_focus(if s.2 { code } else { other })); + code_editor(ui, code, &mut s.0, Lang::Python, 5); + ui.add(TextEdit::singleline(&mut s.1).id(other)); + }, + (String::new(), String::new(), true), + ); + h.run_steps(2); + h.input_mut().events.extend([key(Key::ArrowLeft, Modifiers::NONE), egui::Event::Text("x".into())]); + h.run_steps(1); + assert_eq!(h.state().0, "", "the x waits for the arrow"); + h.state_mut().2 = false; + h.run_steps(2); + assert_eq!((h.state().0.as_str(), h.state().1.as_str()), ("", "")); + } + + #[test] + fn a_repeat_pass_of_the_frame_counts_as_the_next_pass() { + // When a widget asks egui to discard and repeat the pass, the repeat pass gets the + // held-back keys, with the selection the field made in the first pass. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, s: &mut (String, bool)| { + if ui.ctx().cumulative_pass_nr() == 0 { + install_mono(ui.ctx()); + return; + } + let id = egui::Id::new("code"); + ui.memory_mut(|m| m.request_focus(id)); + code_editor(ui, id, &mut s.0, Lang::Python, 5); + if std::mem::take(&mut s.1) { + ui.ctx().request_discard("the test repeats this pass"); + } + }, + ("a\nb".to_string(), false), + ); + h.run_steps(2); + h.state_mut().1 = true; + h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state().0, " a\n b"); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e83db2fa1..33b4e8fc7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1592,7 +1592,7 @@ fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: *confirm = true; } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE)).fill(t.accent)); + let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent)); if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { *confirm = true; } @@ -1675,10 +1675,8 @@ fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) { ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let lang = crate::i18n::current(ui.ctx()); - let ok = ui.add( - egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE)) - .fill(crate::theme::Tokens::get(ui.ctx()).accent), - ); + let t = crate::theme::Tokens::get(ui.ctx()); + let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent)); if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { *confirm = true; } @@ -3066,8 +3064,8 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool ); ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let solve = - ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(Color32::WHITE)).fill(crate::theme::Tokens::get(ui.ctx()).accent)); + let t = crate::theme::Tokens::get(ui.ctx()); + let solve = ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(t.on_accent)).fill(t.accent)); if solve.clicked() && !text(d, "objective").trim().is_empty() { *confirm = true; } @@ -3142,7 +3140,7 @@ fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bo let word = it["word"].as_str().unwrap_or("").to_string(); let cell = it["cell"].as_str().unwrap_or("").to_string(); ui.label(format!("Not in Dictionary ({cell}):")); - ui.label(egui::RichText::new(&word).strong().color(Color32::from_rgb(0xC4, 0x2B, 0x1C))); + ui.label(egui::RichText::new(&word).strong().color(crate::theme::Tokens::get(ui.ctx()).danger)); let sugg: Vec = it["suggestions"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(); ui.label("Suggestions:"); diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index 8984ad53c..72afcf282 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -1,20 +1,10 @@ //! In-cell / formula-bar editing state: point mode, reference colouring, function //! autocomplete and argument hints. -use egui::Color32; use gridcraft_engine::core::{CellRef, RangeRef}; -/// Colours for references in a formula being edited (our own palette). -pub const REF_COLORS: [Color32; 8] = [ - Color32::from_rgb(0x2E, 0x6F, 0xD8), - Color32::from_rgb(0xD0, 0x3C, 0x3C), - Color32::from_rgb(0x7A, 0x4F, 0xC9), - Color32::from_rgb(0x2B, 0x9A, 0x4A), - Color32::from_rgb(0xC2, 0x5E, 0x1E), - Color32::from_rgb(0x1D, 0x92, 0xA6), - Color32::from_rgb(0xB8, 0x3B, 0x8E), - Color32::from_rgb(0x8A, 0x7A, 0x1F), -]; +/// How many distinct reference colours a formula cycles through (`Tokens::ref_colors`). +pub const REF_COLOR_COUNT: usize = 8; #[derive(Clone, Debug)] pub struct EditState { @@ -293,7 +283,7 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option, seen.len() - 1 } }; - out.push((start + offset, end + offset, range, sheet, idx % REF_COLORS.len())); + out.push((start + offset, end + offset, range, sheet, idx % REF_COLOR_COUNT)); continue; } i += 1; diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index b88699f57..20c8ea496 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -1,65 +1,149 @@ //! The formula bar (Name Box, ✕ ✓ fx, formula field) and the shared cell editor widget. -use egui::{Color32, FontId, Key, Rect, Sense, Stroke, StrokeKind, TextEdit, pos2, vec2}; +use egui::{FontId, Key, Rect, Sense, Stroke, StrokeKind, TextEdit, pos2, vec2}; use serde_json::json; use crate::SheetApp; -use crate::editor::{REF_COLORS, formula_refs}; +use crate::editor::formula_refs; use crate::icons::{self, Icon}; use crate::theme::{self, Tokens}; +/// Lines the formula bar (and the in-cell editor) grows to before it scrolls. +pub const MAX_LINES: usize = 5; +/// The bar's height at one line. +const ONE_LINE: f32 = 30.0; +/// What the bar adds around the field's rows: the panel's margins (3 + 3), the field's (3 + 3) +/// and two pixels for the rounding to the pixel grid. +const PAD: f32 = 14.0; + +/// The bar's height for one line of a font whose rows are `row_h` tall: Excel's 30 px, unless +/// the rows need more (the Windows UI font's row is 17.28 px). Every line counted must fit in +/// the field, whatever the font; `n` lines take `one_line(row_h) + (n - 1) * row_h`. +pub fn one_line(row_h: f32) -> f32 { + ONE_LINE.max(row_h + PAD) +} +/// How long the bar takes to grow or shrink to a new line count. +const EASE_SECONDS: f32 = 0.12; + +/// Where the formula bar put its parts last frame (for tests). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BarLayout { + pub name_box: Rect, + /// ✕ ✓ fx. + pub buttons: Rect, + pub field: Rect, + pub height: f32, + /// Laid-out lines of the content (wrapped to the field), and how many show without scrolling. + pub lines: usize, + pub visible_lines: usize, + /// The caret while editing in the bar. + pub caret: Option, +} + +impl Default for BarLayout { + fn default() -> Self { + BarLayout { name_box: Rect::NOTHING, buttons: Rect::NOTHING, field: Rect::NOTHING, height: 0.0, lines: 1, visible_lines: 1, caret: None } + } +} + +impl BarLayout { + pub fn id() -> egui::Id { + egui::Id::new("formula_bar.layout") + } + + /// Last frame's layout. + pub fn get(ctx: &egui::Context) -> BarLayout { + ctx.data(|d| d.get_temp(Self::id())).unwrap_or_default() + } +} + +/// What the editor widget laid out: its line count and caret. +#[derive(Clone, Copy, Debug, Default)] +pub struct EditorInfo { + pub lines: usize, + pub caret: Option, +} + pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let expanded = app.ui.formula_bar_expanded; - let h = if expanded { 72.0 } else { 30.0 }; + let font = theme::ui_font(13.0); + let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font)); + let last = BarLayout::get(ui.ctx()); + // Height follows the content's line count (last frame's), 1 to MAX_LINES; the chevron pins it + // at MAX_LINES. It eases only when that target changes. + let lines = if app.ui.formula_bar_expanded { MAX_LINES } else { last.lines.clamp(1, MAX_LINES) }; + let target = one_line(row_h) + (lines - 1) as f32 * row_h; + let h = ui.ctx().animate_value_with_time(egui::Id::new("formula_bar.height"), target, EASE_SECONDS); + let mut layout = BarLayout { height: h, ..Default::default() }; egui::Panel::top("formula_bar") .exact_size(h) .frame(egui::Frame::NONE.fill(t.window).inner_margin(egui::Margin { left: 8, right: 8, top: 3, bottom: 3 })) .show(ui, |ui| { - ui.horizontal(|ui| { + // Top-aligned: the Name Box and buttons stay on the first line as the field grows. + ui.with_layout(egui::Layout::left_to_right(egui::Align::Min), |ui| { ui.spacing_mut().item_spacing.x = 4.0; - name_box(app, ui, &t); + layout.name_box = name_box(app, ui, &t); ui.add_space(4.0); let editing = app.editor.is_some(); let c = if editing { t.text } else { t.text_disabled }; - if crate::widgets::icon_button(ui, Icon::Close, c, "Cancel", vec2(22.0, 22.0)).clicked() && editing { + let cancel = crate::widgets::icon_button(ui, Icon::Close, c, "Cancel", vec2(22.0, 24.0)); + if cancel.clicked() && editing { app.cancel_edit(); } - if crate::widgets::icon_button(ui, Icon::Check, c, "Enter", vec2(22.0, 22.0)).clicked() && editing { + if crate::widgets::icon_button(ui, Icon::Check, c, "Enter", vec2(22.0, 24.0)).clicked() && editing { app.commit_edit(0, 0, false, false); } - if crate::widgets::icon_button(ui, Icon::Fx, t.text, "Insert Function", vec2(26.0, 22.0)).clicked() { + let fx = crate::widgets::icon_button(ui, Icon::Fx, t.text, "Insert Function", vec2(26.0, 24.0)); + if fx.clicked() { app.open_dialog("insertFunction", json!({})); } + layout.buttons = cancel.rect.union(fx.rect); ui.add_space(4.0); - // The formula field. + // The formula field fills the rest, as tall as the bar. let avail = ui.available_width() - 24.0; - let field = Rect::from_min_size(ui.cursor().min, vec2(avail, h - 6.0)); + let field = Rect::from_min_size(ui.cursor().min, vec2(avail, ui.available_height())); ui.painter().rect_filled(field, 4.0, t.input_bg); ui.painter().rect_stroke(field, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field.shrink2(vec2(6.0, 3.0)))); + let inner = field.shrink2(vec2(6.0, 3.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner)); let id = egui::Id::new("gridcraft.formula_bar"); + // A spill child shows its anchor's formula muted and read-only. let read_only = active_spill_anchor(app).is_some(); - if app.editor.is_some() { - editor_widget(app, &mut child, id, theme::ui_font(13.0), expanded, field.width() - 12.0); + let info = if app.editor.is_some() { + editor_widget(app, &mut child, id, font.clone(), true, inner.width(), inner.height()) } else { // Show the active cell's content; clicking starts editing in the bar. let text = active_input(app); let color = if read_only { t.text_disabled } else { t.text }; - let resp = child - .add(egui::Label::new(egui::RichText::new(text).font(theme::ui_font(13.0)).color(color)).truncate().sense(Sense::click())); - let click = child.interact(field, id.with("click"), Sense::click()); - if !read_only && (resp.clicked() || click.clicked()) { + let galley = child.painter().layout(text, font.clone(), color, inner.width()); + let lines = galley.rows.len(); + // Each cell's view starts at its top: the offset is kept per cell, not per bar. + let shown = app.session.active().map(|d| (d.uid, d.wb.active_sheet, d.selection.active.row, d.selection.active.col)); + egui::ScrollArea::vertical().id_salt(id.with(("view", shown))).max_height(inner.height()).auto_shrink([false, true]).show( + &mut child, + |ui| { + if ui.add(egui::Label::new(galley).sense(Sense::click())).clicked() && !read_only { + app.begin_edit(None, true); + } + }, + ); + if !read_only && ui.interact(field, id.with("click"), Sense::click()).clicked() { app.begin_edit(None, true); } - } - ui.allocate_space(vec2(avail, h - 6.0)); - let chevron = if expanded { Icon::ChevronUp } else { Icon::Chevron }; - if crate::widgets::icon_button(ui, chevron, t.text_dim, "Expand formula bar", vec2(20.0, 22.0)).clicked() { + EditorInfo { lines, caret: None } + }; + layout.field = field; + layout.lines = info.lines.max(1); + layout.visible_lines = ((inner.height() / row_h).floor() as usize).max(1); + layout.caret = info.caret; + ui.allocate_space(vec2(avail, field.height())); + let chevron = if app.ui.formula_bar_expanded { Icon::ChevronUp } else { Icon::Chevron }; + if crate::widgets::icon_button(ui, chevron, t.text_dim, "Expand formula bar", vec2(20.0, 24.0)).clicked() { app.ui.formula_bar_expanded = !app.ui.formula_bar_expanded; } }); }); + ui.ctx().data_mut(|d| d.insert_temp(BarLayout::id(), layout)); } /// Resolve ownership on every frame; never cache a spill range in presentation state. @@ -101,7 +185,7 @@ fn active_input(app: &SheetApp) -> String { String::new() } -fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) { +fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Rect { let id = egui::Id::new("gridcraft.name_box"); let shown = app.name_box_text(); let mut text = app.name_box.clone().unwrap_or(shown.clone()); @@ -158,6 +242,7 @@ fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) { } } }); + rect } /// Backspace and Delete take back the AutoComplete proposal (Excel's selected rest), not a typed character. @@ -172,9 +257,33 @@ fn entry_shrank(chars_before: usize, text: &str) -> bool { text.chars().count() < chars_before } -/// The text editor used in the cell and in the formula bar (they share `app.editor`). -pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32) { - let Some(mut ed) = app.editor.take() else { return }; +/// The editor's text field, the same in the cell and in the formula bar. Enter is the editor's +/// own key (it commits, and Alt+Enter breaks the line), so the field itself does nothing with it: +/// a message box over the editor takes Enter to close, and the field must not add a line then. +fn show_edit<'t>( + edit: TextEdit<'t>, + ui: &mut egui::Ui, + id: egui::Id, + font: FontId, + width: f32, + layouter: &'t mut dyn FnMut(&egui::Ui, &dyn egui::TextBuffer, f32) -> std::sync::Arc, +) -> egui::text_edit::TextEditOutput { + edit.id(id) + .frame(egui::Frame::NONE) + .font(font) + .desired_width(width) + .desired_rows(1) + .layouter(layouter) + .lock_focus(true) + .return_key(None) + .event_filter(egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) + .show(ui) +} + +/// The text editor used in the cell and in the formula bar (they share `app.editor`). It shows +/// `max_height` of text and scrolls beyond that, keeping the caret in view. +pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32, max_height: f32) -> EditorInfo { + let Some(mut ed) = app.editor.take() else { return EditorInfo::default() }; let mine = ed.from_formula_bar == (id == egui::Id::new("gridcraft.formula_bar")); // Keys the editor handles itself (before the TextEdit sees them), only where it has focus. let focused = ui.ctx().memory(|m| m.focused()); @@ -293,8 +402,9 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: } // The text widget. let refs = formula_refs(&ed.text); - let text_color = Tokens::get(ui.ctx()).text; - let cell_text_color = if id == egui::Id::new("gridcraft.cell_editor") { Color32::BLACK } else { text_color }; + let t = Tokens::get(ui.ctx()); + let on_sheet = id == egui::Id::new("gridcraft.cell_editor"); + let cell_text_color = if on_sheet { t.cell_text } else { t.text }; let font2 = font.clone(); let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| { let s = buf.as_str(); @@ -307,7 +417,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: continue; } job.append(s.get(pos..a).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), cell_text_color)); - job.append(s.get(a..b).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), REF_COLORS[idx])); + job.append(s.get(a..b).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), t.ref_color(idx, on_sheet))); pos = b; } job.append(s.get(pos..).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), cell_text_color)); @@ -315,24 +425,32 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ui.fonts_mut(|f| f.layout_job(job)) }; let chars_before = ed.text.chars().count(); - // 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) - .font(font) - .desired_width(width) - .layouter(&mut layouter) - .lock_focus(true) - .event_filter(egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) - .show(ui); - let resp = output.response.clone(); let synced = mine && (ed.request_focus || ed.sync_caret); + let edit = if multiline || ed.text.contains('\n') { TextEdit::multiline(&mut ed.text) } else { TextEdit::singleline(&mut ed.text) }; + let caret_index = ed.caret; + // The in-cell editor grows with its text and never scrolls, so the field keeps its whole width + // for the text (a scroll area would take the bar's width off it and wrap the text narrower + // than the cell does). The formula bar shows its lines and scrolls past them. + let mut output = if on_sheet { + show_edit(edit, ui, id, font, width, &mut layouter) + } else { + egui::ScrollArea::vertical() + .id_salt(id.with("scroll")) + .max_height(max_height.max(1.0)) + .auto_shrink([false, true]) + .show(ui, |ui| { + let output = show_edit(edit, ui, id, font, width, &mut layouter); + // A caret moved by our own keys (Alt+Enter, F4, point mode) isn't the TextEdit's + // doing, so it doesn't scroll to it itself. + if synced { + let caret = output.galley.pos_from_cursor(egui::text::CCursor::new(caret_index)).translate(output.galley_pos.to_vec2()); + ui.scroll_to_rect(caret.expand(2.0), None); + } + output + }) + .inner + }; + let resp = output.response.clone(); if synced { if ed.request_focus { resp.request_focus(); @@ -388,11 +506,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: let g = ui.painter().layout_no_wrap( rest.to_string(), output.galley.job.sections.first().map(|s| s.format.font_id.clone()).unwrap_or_default(), - Color32::WHITE, + t.on_accent, ); let r = egui::Rect::from_min_size(pos, g.size()); - ui.painter().rect_filled(r, 0.0, Color32::from_rgb(0x2E, 0x6F, 0xD8)); - ui.painter().galley(pos, g, Color32::WHITE); + ui.painter().rect_filled(r, 0.0, t.accent); + ui.painter().galley(pos, g, t.on_accent); } // Autocomplete list and argument hint under the editor. if mine && ui.is_rect_visible(resp.rect) && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) { @@ -456,6 +574,8 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: }); }); } + let lines = output.galley.rows.len().max(1); + let caret = Some(output.galley.pos_from_cursor(egui::text::CCursor::new(ed.caret)).translate(output.galley_pos.to_vec2())); app.editor = Some(ed); if cancel { app.cancel_edit(); @@ -466,6 +586,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: app.commit_edit(dr, dc, array, fill); } } + EditorInfo { lines, caret } } fn cycle_anchor(ed: &mut crate::editor::EditState) { @@ -686,4 +807,21 @@ mod tests { assert!(entry_shrank(4, "Som")); assert!(!entry_shrank(4, "Same")); } + + #[test] + fn one_line_fits_a_row_of_any_font() { + // macOS and Linux UI fonts: rows of 15 to 16 px keep Excel's 30 px bar. + assert_eq!(one_line(15.6), 30.0); + assert_eq!(one_line(16.0), 30.0); + // The Windows UI font's 17.28 px rows need more: the panel's and the field's margins take + // 12 px, and rounding to the pixel grid up to another one, so 30 px held only four rows + // of five (a tester's finding). Every counted line must fit in what is left. + for row_h in [17.0_f32, 17.28, 18.0, 20.0] { + for lines in 1..=MAX_LINES { + let bar = one_line(row_h) + (lines - 1) as f32 * row_h; + let inner = bar - 13.0; + assert!((inner / row_h).floor() as usize >= lines, "{lines} lines of {row_h} px in a {bar} px bar"); + } + } + } } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index b485104b1..a656d90ef 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -12,7 +12,7 @@ use gridcraft_engine::model::{BorderLine, BorderStyle, Color, HAlign, LineIndex, use serde_json::json; use crate::SheetApp; -use crate::editor::{EditState, REF_COLORS}; +use crate::editor::EditState; use crate::theme::{self, Tokens}; pub const COL_HEADER_H: f32 = 21.0; @@ -1227,7 +1227,7 @@ pub(crate) fn paint_quadrant( StrokeKind::Inside, ); if on { - crate::icons::paint(p, b.shrink(1.5), crate::icons::Icon::Check, Color32::WHITE); + crate::icons::paint_on_sheet(p, b.shrink(1.5), crate::icons::Icon::Check, Color32::WHITE); } continue; } @@ -1558,9 +1558,15 @@ pub fn table_headings(geo: &Geo, sh: &Sheet, active: CellRef) -> Vec