From c01e46619a76ddce2d4a717c3b212681bc2c4314 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 20 Oct 2026 03:36:51 +0200 Subject: [PATCH 001/212] 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 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 002/212] 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 003/212] 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 004/212] 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 848b0e658a3bf622abae0c724fbba0358facd751 Mon Sep 17 00:00:00 2001 From: Dmussi Date: Sun, 25 Oct 2026 23:24:26 -0400 Subject: [PATCH 005/212] 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 006/212] 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 007/212] 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 008/212] 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 009/212] 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 010/212] 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 011/212] 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 012/212] 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 013/212] 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 014/212] 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 015/212] 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 016/212] 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 017/212] 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 018/212] 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 019/212] 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 020/212] 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 021/212] 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 022/212] 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 023/212] 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 024/212] 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 025/212] 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 026/212] 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 027/212] 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 028/212] 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 029/212] 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 030/212] 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 031/212] 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 032/212] 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 033/212] 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 034/212] 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 035/212] 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 036/212] 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 037/212] 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 038/212] 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 039/212] 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 040/212] 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 041/212] 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 042/212] 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 043/212] 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 044/212] 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 045/212] 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 046/212] 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 047/212] 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 048/212] 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 049/212] 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 050/212] 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 051/212] 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 052/212] 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 053/212] 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 054/212] 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 055/212] 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 056/212] 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 057/212] 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 058/212] 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 059/212] 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 060/212] 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 061/212] 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 062/212] 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 063/212] 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 064/212] 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 065/212] 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 066/212] 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 067/212] 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 068/212] 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 069/212] 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 070/212] 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 071/212] 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 072/212] 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 073/212] 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 074/212] 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 075/212] 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 076/212] 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 077/212] 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 078/212] 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 079/212] 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 080/212] 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 081/212] 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 082/212] 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 083/212] 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 084/212] 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 085/212] 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 086/212] 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 087/212] 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 088/212] 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 089/212] 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 090/212] 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 091/212] 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 092/212] 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 093/212] 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 094/212] 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 095/212] 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 096/212] 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 097/212] 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 098/212] 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 099/212] 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 100/212] 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 101/212] 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 102/212] 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 103/212] 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 104/212] 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 105/212] 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 106/212] 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 107/212] 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 108/212] 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 109/212] 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 110/212] 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 111/212] 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 112/212] 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 113/212] 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 114/212] 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 115/212] 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 116/212] 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 117/212] 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 118/212] 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 119/212] 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 120/212] 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 121/212] 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 122/212] 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 123/212] 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 124/212] 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 125/212] 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 126/212] =?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 127/212] 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 128/212] 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 129/212] 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 130/212] 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 131/212] 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 132/212] 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 133/212] 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 134/212] 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 135/212] 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 136/212] 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 137/212] #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 138/212] 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 139/212] #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 140/212] #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 141/212] 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 142/212] 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 143/212] 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 144/212] 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 145/212] 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 146/212] 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 147/212] 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 148/212] 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 149/212] 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 150/212] 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 151/212] #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 152/212] #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 153/212] #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 154/212] 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 155/212] #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 156/212] 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 157/212] 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 158/212] #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 159/212] 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 160/212] #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 161/212] 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 162/212] 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 163/212] 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 164/212] 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 165/212] 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 166/212] 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 167/212] 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 168/212] 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}", + "\"", + "'", + "<", + ">", + "&", + "&", + "]]>", + "