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 }} diff --git a/Cargo.lock b/Cargo.lock index 5da7c781d..80cf2de70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1937,7 +1937,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.14.2" +version = "0.15.0" dependencies = [ "arboard", "chrono", @@ -1964,7 +1964,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.14.2" +version = "0.15.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1976,7 +1976,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.14.2" +version = "0.15.0" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1987,7 +1987,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.14.2" +version = "0.15.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1999,7 +1999,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.14.2" +version = "0.15.0" dependencies = [ "chrono", "js-sys", @@ -2010,7 +2010,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.14.2" +version = "0.15.0" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -2032,7 +2032,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.14.2" +version = "0.15.0" dependencies = [ "gridcraft-core", "serde", @@ -2041,7 +2041,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.14.2" +version = "0.15.0" dependencies = [ "gridcraft-core", "regex", @@ -2049,7 +2049,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.14.2" +version = "0.15.0" dependencies = [ "gridcraft-engine", "log", @@ -2058,7 +2058,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.14.2" +version = "0.15.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -2070,18 +2070,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.14.2" +version = "0.15.0" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.14.2" +version = "0.15.0" [[package]] name = "gridcraft-ui-egui" -version = "0.14.2" +version = "0.15.0" dependencies = [ "craft-ui", "craft-update", @@ -2106,7 +2106,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.14.2" +version = "0.15.0" dependencies = [ "eframe", "egui", @@ -2123,7 +2123,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.14.2" +version = "0.15.0" dependencies = [ "aes", "gridcraft-core", @@ -6404,7 +6404,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.14.2" +version = "0.15.0" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index a43312950..0ce2e8f0b 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.2" +version = "0.15.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" 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. diff --git a/ROADMAP.md b/ROADMAP.md index 1a42a5fd6..b89aafc3d 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 (form controls: buttons, sliders and spin buttons; earlier: floating objects: one selection, eight resize handles, keyboard delete; 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) +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** minor (form controls: buttons, sliders and spin buttons; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **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,8 +135,13 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| -| 2026-10-31 | Form controls: Insert › Controls places buttons, sliders (scroll bars) and spin buttons on the sheet. A slider or spin button holds a whole number from 0 to 30000 in a linked cell (a drag or a held arrow is one undo step, and editing the cell moves the control); a button runs a macro or one command through the macro policy, asking first in a workbook opened from a file; Assign Action and Format Control edit them, and XLSX files read and write them (control properties, `` and VML), alongside the form controls GridCraft keeps unchanged | -| 2026-10-31 | Floating objects: charts, pictures, shapes and slicers share one selection with a frame of eight resize handles (each moves only its own edges, also on right-to-left sheets); Delete or Backspace deletes the selected object and Escape drops the selection; slicers follow row and column edits and sorts like the other objects; chart series follow cut-moves and sheet renames and deletes; a copied sheet gives its objects fresh ids; `ui.inspect` reports `selectedObject` and `ui.object.select` selects any object (#190, #213) | +| 2026-11-01 | Form controls: Insert › Controls places buttons, sliders (scroll bars) and spin buttons on the sheet. A slider or spin button holds a whole number from 0 to 30000 in a linked cell (a drag or a held arrow is one undo step, and editing the cell moves the control); a button runs a macro or one command through the macro policy, asking first in a workbook opened from a file; Assign Action and Format Control edit them, and XLSX files read and write them (control properties, `` and VML), alongside the form controls GridCraft keeps unchanged | +| 2026-11-01 | Floating objects: charts, pictures, shapes and slicers share one selection with a frame of eight resize handles (each moves only its own edges, also on right-to-left sheets); Delete or Backspace deletes the selected object and Escape drops the selection; slicers follow row and column edits and sorts like the other objects; chart series follow cut-moves and sheet renames and deletes; a copied sheet gives its objects fresh ids; `ui.inspect` reports `selectedObject` and `ui.object.select` selects any object (#190, #213) | +| 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-31 | Esc in File › Info, Print or Options first leaves the text field; the next Esc returns to the sheet (#3035) | +| 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-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 | | 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`) | @@ -169,6 +174,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison | | 2026-10-14 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift | | 2026-10-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 | @@ -246,8 +252,12 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| -| 2026-10-31 | minor | Form controls; catalog 276 / 292 → 280 / 296 (Developer › Controls); progress log row | -| 2026-10-31 | minor | Progress log: one selection and eight resize handles for every floating object | +| 2026-11-01 | minor | Form controls; catalog 276 / 292 → 280 / 296 (Developer › Controls); progress log row | +| 2026-11-01 | minor | Progress log: one selection and eight resize handles for every floating object | +| 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-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) | +| 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 | | 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row | @@ -266,6 +276,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 | Progress log: lookups compare numbers exactly | | 2026-10-14 | minor | Progress log: Analysis ToolPak value arguments reject multi-cell references | | 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row | 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); + } +} 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/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 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(); diff --git a/crates/engine/src/cmd/data/sort.rs b/crates/engine/src/cmd/data/sort.rs index d335a7695..491e3d51e 100644 --- a/crates/engine/src/cmd/data/sort.rs +++ b/crates/engine/src/cmd/data/sort.rs @@ -272,7 +272,9 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec Anchor { if a.mode == AnchorMode::Absolute { return a; @@ -290,7 +292,10 @@ fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: b let y1 = sh.row_top(nr) + a.dy as f64; let x2 = sh.col_left(nbc) + tdx as f64; let y2 = sh.row_top(nbr) + tdy as f64; - Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32, mode: a.mode } + // An extent that is not positive means the sort put the bottom-right line before the + // top-left one on that axis: the object keeps its old size there. + let fit = |extent: f64, old: f32| if extent > 0.0 { extent as f32 } else { old.max(0.0) }; + Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: fit(x2 - x1, a.width), height: fit(y2 - y1, a.height), mode: a.mode } } else { Anchor { cell: CellRef::new(nr, nc), ..a } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 4b5c98a8b..6e4e04613 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}; @@ -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 ), @@ -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/edit/cell.rs b/crates/engine/src/cmd/edit/cell.rs index c57afae97..8eb9075bf 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)?; @@ -183,6 +188,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 +221,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 +247,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. 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. + 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 +290,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..3f3a2fb6d 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -192,6 +192,74 @@ 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("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)); +} + +/// 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/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 8dfda7601..dc629040c 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![ @@ -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/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/cmd/solver.rs b/crates/engine/src/cmd/solver.rs index 2cb3da434..95c0cb146 100644 --- a/crates/engine/src/cmd/solver.rs +++ b/crates/engine/src/cmd/solver.rs @@ -36,6 +36,7 @@ use gridcraft_core::split_sheet; mod evolutionary; mod lp; mod model; +mod model_range; mod nlp; mod problem; mod reports; @@ -44,12 +45,15 @@ use evolutionary::{EvoEnd, evolutionary}; use lp::{Outcome, Sense, branch_and_bound, linearize, solve_lp}; pub use model::saved_model; use model::{Goal, Method, Model, model_params, parse_model, save_model}; +use model_range::{load_model_cells, reset_model, save_model_cells}; use nlp::{nlp, project}; use problem::{Evaluator, as_num, build_problem}; use reports::{ReportInput, insert_report_selections, prepare_reports, report, report_kinds, write_reports}; const CMD: &str = "data.solver"; const REPORT_CMD: &str = "data.solverReport"; +const SAVE_CMD: &str = "data.solverSaveModel"; +const LOAD_CMD: &str = "data.solverLoadModel"; /// Excel's limit on decision variables. const MAX_VARS: usize = 200; /// Most scalar constraints (after expanding ranges). @@ -74,7 +78,7 @@ pub fn specs() -> Vec { "Solver…", ["Data", "Analysis"], None, - "{objective: \"B10\" (formula cell), goal?: max|min|value (default max), value?: number (for goal value), variables: \"B2:B4,D1\" (≤200 constant cells, same sheet), constraints?: [\"B5 <= 10\", \"B2:B4 >= C2:C4\", \"B2 int\", \"B3 bin\", {lhs: \"B6\", op: \"=\", rhs: 3 | \"C6\"}], nonNegative?: bool (default true: variables without a lower bound get 0), method?: grg|simplex|evolutionary (default grg), populationSize?: 100, mutationRate?: 0.075, randomSeed?: 0 (a fixed seed, so runs repeat), convergence?: 0.0001, generationsWithoutImprovement?: 50, requireBounds?: bool (default true: every variable needs both bounds; false searches within ±100 of the start) (Evolutionary options), keep?: bool (default true; false restores the original values), saveModel?: bool (default true: hidden solver_* names), maxEvaluations?: 20000, integerOptimality?: percent (default 0), saved?: bool (solve the sheet's saved model; other params override), reports?: [answer|limits] (written on new sheets when a solution is found)} → {status: optimal|converged|cannotImprove|iterationLimit|infeasible|unbounded|notLinear, message, objective, values: {cell: value}, original: {cell: value}, model: the model as params, evaluations, changed, reports?: [sheet names]}. No `objective` → {model: the saved model} (the menu opens the dialog).", + "{objective: \"B10\" (formula cell), goal?: max|min|value (default max), value?: number (for goal value), variables: \"B2:B4,D1\" (≤200 constant cells, same sheet), constraints?: [\"B5 <= 10\", \"B2:B4 >= C2:C4\", \"B2 int\", \"B3 bin\", {lhs: \"B6\", op: \"=\", rhs: 3 | \"C6\"}], nonNegative?: bool (default true: variables without a lower bound get 0), method?: grg|simplex|evolutionary (default grg), populationSize?: 100, mutationRate?: 0.075, randomSeed?: 0 (a fixed seed, so runs repeat), convergence?: 0.0001, generationsWithoutImprovement?: 50, requireBounds?: bool (default true: every variable needs both bounds; false searches within ±100 of the start) (Evolutionary options), keep?: bool (default true; false restores the original values), saveModel?: bool (default true: hidden names, solver_* as Excel names them, with the options; the evaluation limit and generations without improvement, which Excel lacks, as gridcraft_solver_evals and gridcraft_solver_stall), maxEvaluations?: 20000 (at most 200000), integerOptimality?: percent (default 0), saved?: bool (solve the sheet's saved model; other params override), reports?: [answer|limits] (written on new sheets when a solution is found)} → {status: optimal|converged|cannotImprove|iterationLimit|infeasible|unbounded|notLinear, message, objective, values: {cell: value}, original: {cell: value}, model: the model as params, evaluations, changed, reports?: [sheet names]}. No `objective` → {model: the saved model} (the menu opens the dialog).", has_doc, run ), @@ -87,6 +91,33 @@ pub fn specs() -> Vec { has_doc, report ), + cmd!( + "data.solverSaveModel", + "Save Model", + [], + None, + "{to: \"H1\" (the first cell, on the model's sheet), model?: data.solver params (default the sheet's saved model), overwrite?: false} → {range}. Writes the model down a column as formulas: =MAX(objective), =MIN(objective) or =objective=value; =COUNT(variables); one =AND(relation) per constraint (=AND(x=INT(x)) for integer, =AND((x=0)+(x=1)) for binary), which shows TRUE while it holds; and the options as an array constant {method (1 GRG Nonlinear, 2 Simplex LP, 3 Evolutionary), non-negative (1 yes, 2 no), population size, mutation rate, random seed, require bounds (1 yes, 2 no), convergence, generations without improvement, maximum evaluations, integer optimality %}, which spills into the cells to its right. Refused, with the sheet unchanged, when the model's cells, content, another formula's spilled values, merged cells or a table are in the way of the options' spill, or it would run off the sheet, and when the column would cover another formula's spilled values. A column that holds a value or a formula (a model saved there before included) is replaced only with overwrite: true; without it the command is refused (\"already holds data\"), as Excel asks first. One undo step.", + has_doc, + save_model_cells + ), + cmd!( + "data.solverLoadModel", + "Load Model", + [], + None, + "{from: \"H1:H6\" (a range on the active sheet; one cell reads down to the first cell without a formula; a range needs a formula in every cell up to its last formula, and may end in empty cells)} → {model}. Reads a model written by Save Model and makes it the sheet's Solver model. The model must end with its options row ({…}), which Save Model writes last; a model cut short (a cell before the options row without a formula, or the options row cleared) is refused and the sheet's model stays as it was.", + has_doc, + load_model_cells + ), + cmd!( + "data.solverReset", + "Reset All", + [], + None, + "{} → {removed, masked}. Removes the active sheet's Solver model. A model read from workbook-level names stays for the other sheets and is masked on this one by an empty sheet-level solver_opt (masked: true).", + has_doc, + reset_model + ), ] } @@ -182,12 +213,7 @@ fn run(s: &mut Session, p: &Json) -> Result { return Ok(json!({"model": model})); } let model = parse_model(s, ¶ms)?; - let budget = params - .get("maxEvaluations") - .and_then(Json::as_f64) - .filter(|v| v.is_finite() && *v >= 1.0) - .map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS)); - let gap = params.get("integerOptimality").and_then(Json::as_f64).filter(|v| v.is_finite()).map_or(0.0, |v| v.clamp(0.0, 100.0) / 100.0); + let (budget, gap) = (model.max_evals, model.gap); let keep = bool_param(¶ms, "keep").unwrap_or(true); let save = bool_param(¶ms, "saveModel").unwrap_or(true); let reports = report_kinds(CMD, ¶ms)?; diff --git a/crates/engine/src/cmd/solver/model.rs b/crates/engine/src/cmd/solver/model.rs index d959b5907..d6b7d9b8c 100644 --- a/crates/engine/src/cmd/solver/model.rs +++ b/crates/engine/src/cmd/solver/model.rs @@ -1,6 +1,6 @@ use super::{ - CMD, CellRef, DefinedName, EngineError, Json, MAX_CONSTRAINTS, MAX_POPULATION, MAX_VARS, MIN_POPULATION, RangeRef, Result, Session, Workbook, - bad, bool_param, f64_param, json, split_sheet, str_param, + CMD, CellRef, DEFAULT_EVALS, DefinedName, EngineError, Json, MAX_CONSTRAINTS, MAX_EVALS, MAX_POPULATION, MAX_VARS, MIN_POPULATION, RangeRef, + Result, Session, Workbook, bad, bool_param, f64_param, json, split_sheet, str_param, }; // ---------------------------------------------------------------- model @@ -128,6 +128,10 @@ pub(super) struct Model { pub(super) non_negative: bool, pub(super) method: Method, pub(super) evo: EvoOptions, + /// The evaluation limit (in place of Excel's iterations and time limits). + pub(super) max_evals: u64, + /// Integer Optimality, as a fraction. + pub(super) gap: f64, } /// Cells of `"B2:B4,D1"` on one sheet (row-major per area, in order), at most `cap`. @@ -288,13 +292,24 @@ pub(super) fn parse_model(s: &Session, p: &Json) -> Result { "evolutionary" | "evo" | "genetic" => Method::Evolutionary, m => return Err(bad(CMD, format!("unknown method `{m}` (grg, simplex or evolutionary)"))), }; - Ok(Model { sheet, objective, goal, vars, vars_text, cons, non_negative, method, evo: evo_options(p) }) + let num = |k: &str| p.get(k).and_then(Json::as_f64).or_else(|| str_param(p, k).and_then(num_text)).filter(|v| v.is_finite()); + let max_evals = num("maxEvaluations").filter(|v| *v >= 1.0).map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS)); + // Integer Optimality is in percent: "5" and "5%" both mean 5 % (number parsing would read + // "5%" as 0.05). + let percent = p + .get("integerOptimality") + .and_then(Json::as_f64) + .or_else(|| str_param(p, "integerOptimality").and_then(|t| num_text(t.trim().trim_end_matches('%')))) + .filter(|v| v.is_finite()); + let gap = percent.map_or(0.0, |v| v.clamp(0.0, 100.0) / 100.0); + Ok(Model { sheet, objective, goal, vars, vars_text, cons, non_negative, method, evo: evo_options(p), max_evals, gap }) } // ---------------------------------------------------------------- saved model -fn ref_text(sheet_name: &str, cells: &str) -> String { - let q = crate::pivot::quote_sheet(sheet_name); +/// `"B2:B4,D1"` as absolute references, each qualified by `sheet_name` when given. +fn abs_text(sheet_name: Option<&str>, cells: &str) -> String { + let q = sheet_name.map(|n| format!("{}!", crate::pivot::quote_sheet(n))).unwrap_or_default(); cells .split(',') .map(str::trim) @@ -302,16 +317,49 @@ fn ref_text(sheet_name: &str, cells: &str) -> String { .filter_map(|part| { let r = RangeRef::parse(split_sheet(part).1.trim())?; let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1); - Some(if r.is_single() { format!("{q}!{}", abs(r.start)) } else { format!("{q}!{}:{}", abs(r.start), abs(r.end)) }) + Some(if r.is_single() { format!("{q}{}", abs(r.start)) } else { format!("{q}{}:{}", abs(r.start), abs(r.end)) }) }) .collect::>() .join(",") } +fn ref_text(sheet_name: &str, cells: &str) -> String { + abs_text(Some(sheet_name), cells) +} + +/// The evaluation limit and the generations without improvement have no Excel equivalent: +/// Excel's `solver_itr` counts iterations (100 by default) and `solver_mni` seconds, so a +/// workbook from Excel would cut GridCraft's search short. They are saved under GridCraft's own +/// names, which Excel ignores. +const EVALS_NAME: &str = "gridcraft_solver_evals"; +const STALL_NAME: &str = "gridcraft_solver_stall"; + +/// The names of a saved model after `solver_`, as Excel's Solver writes them (GridCraft writes +/// some of them). Saving a model and Reset All remove these and no other `solver_` names. +const SOLVER_KEYS: [&str; 28] = [ + "adj", "cvg", "drv", "eng", "est", "itr", "lin", "mip", "mni", "mrt", "msl", "neg", "nod", "num", "nwt", "opt", "pre", "rbv", "rlx", "rsd", + "scl", "sho", "ssz", "tim", "tol", "typ", "val", "ver", +]; + +/// Whether `name` is one of the names of a saved model: Excel's Solver names (`solver_opt`, +/// `solver_lhs1` and so on) and GridCraft's own. A user's name such as `solver_budget` is not. +pub(super) fn is_model_name(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + if name == EVALS_NAME || name == STALL_NAME { + return true; + } + let Some(key) = name.strip_prefix("solver_") else { return false }; + if SOLVER_KEYS.contains(&key) { + return true; + } + // One name per constraint: solver_lhs1, solver_rel1, solver_rhs1, … + ["lhs", "rel", "rhs"].iter().filter_map(|p| key.strip_prefix(p)).any(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) +} + /// The model as hidden names scoped to its sheet (replacing any saved before). pub(super) fn save_model(wb: &mut Workbook, m: &Model) { let Some(sheet_name) = wb.sheet(m.sheet).map(|s| s.name.clone()) else { return }; - wb.names.retain(|n| !(n.scope == Some(m.sheet) && n.name.to_ascii_lowercase().starts_with("solver_"))); + wb.names.retain(|n| !(n.scope == Some(m.sheet) && is_model_name(&n.name))); let mut put = |name: String, formula: String| { wb.names.push(DefinedName { name, scope: Some(m.sheet), formula, comment: String::new(), hidden: true }); }; @@ -349,26 +397,31 @@ pub(super) fn save_model(wb: &mut Workbook, m: &Model) { Method::Evolutionary => "3", }; put("solver_eng".into(), eng.into()); - if m.method == Method::Evolutionary { - put("solver_ssz".into(), m.evo.population.to_string()); - put("solver_mrt".into(), m.evo.mutation.to_string()); - put("solver_rsd".into(), m.evo.seed.to_string()); - put("solver_rbv".into(), if m.evo.require_bounds { "1" } else { "2" }.into()); - } + put(EVALS_NAME.into(), m.max_evals.to_string()); + put("solver_tol".into(), m.gap.to_string()); + // The Evolutionary options are kept whatever the method, as Solver Options sets them, so + // switching methods doesn't lose them. + put("solver_ssz".into(), m.evo.population.to_string()); + put("solver_mrt".into(), m.evo.mutation.to_string()); + put("solver_rsd".into(), m.evo.seed.to_string()); + put("solver_rbv".into(), if m.evo.require_bounds { "1" } else { "2" }.into()); + put("solver_cvg".into(), m.evo.convergence.to_string()); + put(STALL_NAME.into(), m.evo.stall.to_string()); put("solver_ver".into(), "3".into()); } /// The saved model of a sheet as `data.solver` params, if any. +/// +/// A sheet's own (sheet-level) model wins over one saved as workbook-level names, and its names +/// are read alone, so the two never mix. A sheet-level `solver_opt` of `""` (written by Reset +/// All over a workbook-level model) means the sheet has no model. pub fn saved_model(wb: &Workbook, sheet: usize) -> Option { - let get = |n: &str| { - wb.names - .iter() - .find(|d| d.name.eq_ignore_ascii_case(n) && (d.scope == Some(sheet) || d.scope.is_none())) - .map(|d| d.formula.trim().trim_start_matches('=').to_string()) - }; + let named = |n: &str, scope: Option| wb.names.iter().find(|d| d.name.eq_ignore_ascii_case(n) && d.scope == scope); + let scope = if named("solver_opt", Some(sheet)).is_some() { Some(sheet) } else { None }; + let get = |n: &str| named(n, scope).map(|d| d.formula.trim().trim_start_matches('=').to_string()); let local = |t: String| -> String { t.split(',').map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::>().join(",") }; let objective = local(get("solver_opt")?); - if objective.is_empty() { + if objective.trim_matches('"').trim().is_empty() { return None; } let goal = match get("solver_typ").as_deref().map(str::trim) { @@ -407,11 +460,21 @@ pub fn saved_model(wb: &Workbook, sheet: usize) -> Option { "nonNegative": get("solver_neg").as_deref().map(str::trim) != Some("2"), "method": method, }); - for (name, key) in [("solver_ssz", "populationSize"), ("solver_mrt", "mutationRate"), ("solver_rsd", "randomSeed")] { + for (name, key) in [ + ("solver_ssz", "populationSize"), + ("solver_mrt", "mutationRate"), + ("solver_rsd", "randomSeed"), + ("solver_cvg", "convergence"), + (STALL_NAME, "generationsWithoutImprovement"), + (EVALS_NAME, "maxEvaluations"), + ] { if let Some(v) = get(name).and_then(|v| num_text(&v)) { - model[key] = json!(v); + model[key] = number(v); } } + if let Some(v) = get("solver_tol").and_then(|v| num_text(&v)) { + model["integerOptimality"] = number(percent(v)); + } if let Some(v) = get("solver_rbv") { model["requireBounds"] = json!(v.trim() != "2"); } @@ -448,15 +511,200 @@ pub(super) fn model_params(wb: &Workbook, m: &Model) -> Json { "constraints": constraints, "nonNegative": m.non_negative, "method": method, + "maxEvaluations": m.max_evals, + "integerOptimality": number(percent(m.gap)), }); - if m.method == Method::Evolutionary { - let e = &m.evo; - out["populationSize"] = json!(e.population); - out["mutationRate"] = json!(e.mutation); - out["randomSeed"] = json!(e.seed); - out["convergence"] = json!(e.convergence); - out["generationsWithoutImprovement"] = json!(e.stall); - out["requireBounds"] = json!(e.require_bounds); - } + let e = &m.evo; + out["populationSize"] = json!(e.population); + out["mutationRate"] = json!(e.mutation); + out["randomSeed"] = json!(e.seed); + out["convergence"] = json!(e.convergence); + out["generationsWithoutImprovement"] = json!(e.stall); + out["requireBounds"] = json!(e.require_bounds); out } + +// ---------------------------------------------------------------- model on cells + +/// The cells Save Model writes, top to bottom, as formula text. +pub(super) fn model_formulas(m: &Model) -> Result> { + let one_area = |t: &str| !t.contains(','); + let mut rows = Vec::with_capacity(m.cons.len() + 3); + let obj = abs_text(None, &m.objective.a1()); + rows.push(match m.goal { + Goal::Max => format!("=MAX({obj})"), + Goal::Min => format!("=MIN({obj})"), + Goal::Value(v) => format!("={obj}={}", gridcraft_core::number_to_text(v)), + }); + rows.push(format!("=COUNT({})", abs_text(None, &m.vars_text))); + for c in &m.cons { + if !one_area(&c.lhs_text) || !one_area(&c.rhs_text) { + return Err(EngineError::Other("Save Model needs each side of a constraint to be one range.".into())); + } + let lhs = abs_text(None, &c.lhs_text); + rows.push(match (c.rel, &c.rhs) { + (Rel::Int, _) => format!("=AND({lhs}=INT({lhs}))"), + (Rel::Bin, _) => format!("=AND(({lhs}=0)+({lhs}=1))"), + (rel, Rhs::Cells(_)) => format!("=AND({lhs}{}{})", rel.text(), abs_text(None, &c.rhs_text)), + (rel, Rhs::Const(v)) => format!("=AND({lhs}{}{})", rel.text(), gridcraft_core::number_to_text(*v)), + (rel, Rhs::None) => format!("=AND({lhs}{}0)", rel.text()), + }); + } + let e = &m.evo; + let n = gridcraft_core::number_to_text; + let eng = match m.method { + Method::Grg => 1, + Method::Simplex => 2, + Method::Evolutionary => 3, + }; + let yes = |b: bool| if b { 1 } else { 2 }; + rows.push(format!( + "={{{eng},{},{},{},{},{},{},{},{},{}}}", + yes(m.non_negative), + e.population, + n(e.mutation), + e.seed, + yes(e.require_bounds), + n(e.convergence), + e.stall, + m.max_evals, + n(percent(m.gap)) + )); + Ok(rows) +} + +/// `NAME(inner)` → inner, ignoring case. +fn call_args(t: &str, name: &str) -> Option { + let head = t.get(..name.len() + 1)?; + (head.eq_ignore_ascii_case(&format!("{name}(")) && t.ends_with(')')).then(|| t.get(name.len() + 1..t.len() - 1).map(str::to_string))? +} + +/// References without sheet names or `$`. +fn local_refs(t: &str) -> String { + t.split(',').map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::>().join(",") +} + +fn not_a_model() -> EngineError { + EngineError::Other("The range doesn't hold a Solver model saved with Save Model.".into()) +} + +/// How many values the options row of Save Model holds at least: method, non-negative, +/// population size, mutation rate, random seed, require bounds, convergence and generations +/// without improvement. +const OPTIONS_FIELDS: usize = 8; + +/// The options row of Save Model, `{method, non-negative, …}`, into `model`. Refused when it +/// holds fewer than [`OPTIONS_FIELDS`] values or a value that isn't a number: a row cut short +/// or edited by hand would otherwise load with default options in place of the saved ones. +fn read_options_row(model: &mut Json, list: &str) -> Result<()> { + let incomplete = || { + EngineError::Other(format!( + "The model's options row doesn't hold the {OPTIONS_FIELDS} or more numbers Save Model writes, so the range isn't a complete model saved with Save Model." + )) + }; + // Every value is checked; only the first ones are kept (the row is one formula, so its + // length is bounded by the formula's). + let mut v: Vec = Vec::with_capacity(16); + let mut count = 0usize; + for t in list.split([',', ';']) { + let x = num_text(t).ok_or_else(incomplete)?; + count = count.saturating_add(1); + if v.len() < 16 { + v.push(x); + } + } + if count < OPTIONS_FIELDS { + return Err(incomplete()); + } + let get = |k: usize| v.get(k).copied(); + model["method"] = json!(match get(0).map(|x| x as i64) { + Some(2) => "simplex", + Some(3) => "evolutionary", + _ => "grg", + }); + model["nonNegative"] = json!(get(1) != Some(2.0)); + for (k, key) in [ + (2, "populationSize"), + (3, "mutationRate"), + (4, "randomSeed"), + (6, "convergence"), + (7, "generationsWithoutImprovement"), + (8, "maxEvaluations"), + (9, "integerOptimality"), + ] { + if let Some(x) = get(k) { + model[key] = json!(x); + } + } + model["requireBounds"] = json!(get(5) != Some(2.0)); + Ok(()) +} + +/// One constraint row of Save Model (`AND(relation)`) as a `data.solver` constraint. +fn read_constraint_row(t: String) -> Result { + let t = call_args(&t, "AND").unwrap_or(t); + if let Some((x, y)) = t.split_once("=INT(").or_else(|| t.split_once("=int(")) { + if y.strip_suffix(')') != Some(x) { + return Err(not_a_model()); + } + return Ok(format!("{} int", local_refs(x))); + } + if let Some(x) = t.strip_prefix('(').and_then(|x| x.strip_suffix("=1)")).and_then(|x| x.split_once("=0)+(")).filter(|(a, b)| a == b) { + return Ok(format!("{} bin", local_refs(x.0))); + } + let (l, rel, r) = split_constraint(&t).ok_or_else(not_a_model)?; + let r = r.trim_matches('"'); + Ok(match r.to_ascii_lowercase().as_str() { + "integer" => format!("{} int", local_refs(&l)), + "binary" => format!("{} bin", local_refs(&l)), + _ if num_text(r).is_some() => format!("{} {} {r}", local_refs(&l), rel.text()), + _ => format!("{} {} {}", local_refs(&l), rel.text(), local_refs(r)), + }) +} + +/// Reads formula text written by Save Model back into `data.solver` params. +pub(super) fn parse_model_formulas(rows: &[String]) -> Result { + let body = |t: &str| t.trim().trim_start_matches('=').replace(' ', ""); + let first = body(rows.first().ok_or_else(not_a_model)?); + let (goal, objective, value) = if let Some(o) = call_args(&first, "MAX") { + ("max", o, 0.0) + } else if let Some(o) = call_args(&first, "MIN") { + ("min", o, 0.0) + } else { + let (o, v) = first.split_once('=').ok_or_else(not_a_model)?; + ("value", o.to_string(), num_text(v).ok_or_else(not_a_model)?) + }; + let variables = call_args(&body(rows.get(1).ok_or_else(not_a_model)?), "COUNT").ok_or_else(not_a_model)?; + let mut model = json!({"objective": local_refs(&objective), "goal": goal, "value": value, "variables": local_refs(&variables)}); + let mut constraints = Vec::new(); + let rest = rows.get(2..).unwrap_or(&[]); + for (i, row) in rest.iter().enumerate() { + let t = body(row); + if let Some(list) = t.strip_prefix('{').and_then(|x| x.strip_suffix('}')) { + // The options: the last row. + if i + 1 != rest.len() { + return Err(not_a_model()); + } + read_options_row(&mut model, list)?; + break; + } + constraints.push(json!(read_constraint_row(t)?)); + } + model["constraints"] = json!(constraints); + Ok(model) +} + +/// Integer Optimality as a percent from its stored fraction. The product is rounded to 1E-10 %, +/// so a fraction such as 0.07 reads back as 7 and not 7.000000000000001. +fn percent(fraction: f64) -> f64 { + let p = fraction * 100.0; + let rounded = (p * 1e10).round() / 1e10; + if rounded.is_finite() { rounded } else { p } +} + +/// A number read from a hidden name as JSON: a whole number as an integer, so the Solver dialog +/// shows 20000 and not 20000.0. +fn number(v: f64) -> Json { + // Whole numbers below 2^53 are exact as integers. + if v.fract() == 0.0 && v.abs() < 9_007_199_254_740_992.0 { json!(v as i64) } else { json!(v) } +} diff --git a/crates/engine/src/cmd/solver/model_range.rs b/crates/engine/src/cmd/solver/model_range.rs new file mode 100644 index 000000000..7d47b7176 --- /dev/null +++ b/crates/engine/src/cmd/solver/model_range.rs @@ -0,0 +1,305 @@ +//! Load/Save Model and Reset All: the model written on cells of its sheet, read back from them, +//! and removed from the sheet. + +use super::model::{Rhs, is_model_name, model_formulas, parse_model, parse_model_formulas, save_model, saved_model}; +use super::{ + Cell, CellRef, DefinedName, EngineError, Json, LOAD_CMD, MAX_CONSTRAINTS, RangeRef, Result, SAVE_CMD, Session, Sheet, Value, bad, bool_param, + edit, json, split_sheet, str_param, +}; +use crate::cmd::analysis::OVERWRITE_ERROR; +use crate::cmd::cell_param_on; + +/// Most cells a model written by Save Model takes: the objective, the variables, the options +/// and one per constraint. +const MAX_MODEL_ROWS: usize = MAX_CONSTRAINTS + 3; + +fn is_options_row(formula: &str) -> bool { + formula.trim().trim_start_matches('=').trim_start().starts_with('{') +} + +/// The formulas from `start` down: to the first cell without a formula, or through the options +/// row (`{…}`) that ends a model written by Save Model; at most [`MAX_MODEL_ROWS`] cells. +fn formulas_down(sheet: &Sheet, start: CellRef) -> Vec<(CellRef, String)> { + let mut out = Vec::new(); + for k in 0..MAX_MODEL_ROWS as u32 { + let Some(c) = start.row.checked_add(k).map(|row| CellRef::new(row, start.col)) else { break }; + let Some(f) = sheet.cell(c).and_then(|x| x.formula.as_ref()).map(|f| f.text.clone()) else { break }; + let last = is_options_row(&f); + out.push((c, f)); + if last { + break; + } + } + out +} + +/// The cells of a model written by Save Model that starts at `start` (none if the cells there +/// don't hold one). +fn saved_model_cells(sheet: &Sheet, start: CellRef) -> Vec { + let old = formulas_down(sheet, start); + let rows: Vec = old.iter().map(|(_, f)| f.clone()).collect(); + if rows.last().is_some_and(|f| is_options_row(f)) && parse_model_formulas(&rows).is_ok() { + old.into_iter().map(|(c, _)| c).collect() + } else { + Vec::new() + } +} + +/// How many cells the options row of Save Model (`={…}`, one row) spans. +fn options_width(formula: &str) -> u32 { + u32::try_from(formula.split(',').count()).unwrap_or(u32::MAX) +} + +/// The options row is an array constant that spills `width - 1` cells to the right of `anchor`. +/// Refused, before anything is written, when the spill would run off the sheet or be blocked +/// (the anchor would show `#SPILL!`): a cell there holds content or another formula's spilled +/// values, or a merged area or a table meets the row. `ours` are the cells Save Model +/// writes or clears (a model saved at the same place before, whose options spill may be +/// replaced); `used` are the model's own cells, which the spill must not cover. +fn check_options_spill(sheet: &Sheet, anchor: CellRef, width: u32, ours: &[CellRef], used: &[CellRef]) -> Result<()> { + if width <= 1 { + return Ok(()); + } + let end = anchor.col.checked_add(width - 1).filter(|c| *c < gridcraft_core::MAX_COLS).ok_or_else(|| { + EngineError::Other(format!( + "The saved model's options need {width} cells to the right from {}; choose another place to save it.", + anchor.a1() + )) + })?; + let full = RangeRef::new(anchor, CellRef::new(anchor.row, end)); + let spill = RangeRef::new(CellRef::new(anchor.row, anchor.col.saturating_add(1)), CellRef::new(anchor.row, end)); + let blocked = + |why: String| EngineError::Other(format!("The saved model's options fill {}, but {why}; choose another place to save it.", spill.a1())); + if let Some(c) = used.iter().find(|c| spill.contains(**c)) { + return Err(blocked(format!("{} is a cell of the model", c.a1()))); + } + for c in spill.iter() { + if sheet.cells.has(c) { + return Err(blocked(format!("{} isn't empty", c.a1()))); + } + if sheet.spill.contains_key(&c) && sheet.spill_anchor(c).is_none_or(|a| !ours.contains(&a)) { + return Err(blocked(format!("{} holds the values of another formula", c.a1()))); + } + } + if sheet.merges.iter().any(|m| m.intersects(&full)) { + return Err(blocked("merged cells are in the way".into())); + } + // A dynamic array can't spill into a table. + if sheet.tables.iter().any(|t| t.range.intersects(&full)) { + return Err(blocked("a table is in the way".into())); + } + Ok(()) +} + +/// Refused when a cell of the model's column holds another formula's spilled values: the +/// formula written there would block that spill (the other formula would show `#SPILL!`). +/// `ours` are as in [`check_options_spill`]: a model saved at the same place before may be +/// replaced. +fn check_column_spills(sheet: &Sheet, column: RangeRef, ours: &[CellRef]) -> Result<()> { + for c in column.iter() { + if sheet.spill.contains_key(&c) && sheet.spill_anchor(c).is_none_or(|a| !ours.contains(&a)) { + return Err(EngineError::Other(format!( + "The saved model would cover {}, which holds the values of another formula; choose another place to save it.", + c.a1() + ))); + } + } + Ok(()) +} + +/// Refused, unless `overwrite`, when a cell of the model's column holds a value or a formula +/// (a model saved there before included): Save Model then asks before it replaces data, as +/// Excel does. The message carries [`OVERWRITE_ERROR`], which the dialog turns into its +/// question. +fn check_column_empty(sheet: &Sheet, column: RangeRef, overwrite: bool) -> Result<()> { + let taken = column.iter().find(|c| sheet.cell(*c).is_some_and(|x| !x.value.is_empty() || x.formula.is_some())); + match taken { + Some(c) if !overwrite => Err(EngineError::Other(format!( + "The range {} {OVERWRITE_ERROR} ({} isn't empty); pass overwrite: true to replace it.", + column.a1(), + c.a1() + ))), + _ => Ok(()), + } +} + +/// `data.solverSaveModel`: writes the model on cells of its sheet, in one step. Saving over a +/// longer model saved at the same cell clears the rest of it, so Load Model reads the new one. +pub(super) fn save_model_cells(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let params = match p.get("model") { + Some(m @ Json::Object(_)) => m.clone(), + Some(Json::Null) | None => { + saved_model(&d.wb, d.wb.active_sheet()).ok_or_else(|| EngineError::Other("This sheet has no saved Solver model.".into()))? + } + Some(_) => return Err(bad(SAVE_CMD, "`model` must be data.solver params")), + }; + let model = parse_model(s, ¶ms)?; + let to = cell_param_on(s, p, "to", model.sheet)?.ok_or_else(|| bad(SAVE_CMD, "missing `to` (the first cell of the saved model)"))?; + let rows = model_formulas(&model)?; + let count = u32::try_from(rows.len()).unwrap_or(u32::MAX); + let last = to + .row + .checked_add(count.saturating_sub(1)) + .filter(|r| *r < gridcraft_core::MAX_ROWS) + .ok_or_else(|| EngineError::Other("The saved model doesn't fit below that cell.".into()))?; + let range = RangeRef::new(to, CellRef::new(last, to.col)); + // The model's own cells are never overwritten. + let mut used = vec![model.objective]; + used.extend(&model.vars); + for c in &model.cons { + used.extend(&c.lhs); + if let Rhs::Cells(r) = &c.rhs { + used.extend(r); + } + } + if used.iter().any(|c| range.contains(*c)) { + return Err(EngineError::Other(format!("{} overlaps the model's cells; choose another place to save it.", range.a1()))); + } + let sheet = model.sheet; + let sh = s.doc()?.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + // What is left of a model saved here before; the new model's own cells stay. + let stale: Vec = saved_model_cells(sh, to).into_iter().filter(|c| !range.contains(*c) && !used.contains(c)).collect(); + let ours: Vec = range.iter().chain(stale.iter().copied()).collect(); + let width = rows.last().map_or(1, |f| options_width(f)); + check_column_spills(sh, range, &ours)?; + check_options_spill(sh, CellRef::new(last, to.col), width, &ours, &used)?; + // Last, so the question is asked only when replacing the data would succeed. + check_column_empty(sh, range, bool_param(p, "overwrite").unwrap_or(false))?; + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + let cells: Vec = range.iter().collect(); + for (c, f) in cells.iter().zip(&rows) { + let mut cell = Cell::formula(gridcraft_model::Formula::new(f)); + cell.style = sh.style_id(*c); + sh.set_cell(*c, cell); + } + for c in &stale { + let style = sh.style_id(*c); + sh.set_cell(*c, Cell { value: Value::Empty, formula: None, style }); + } + for c in cells.into_iter().chain(stale) { + cx.touch(sheet, c); + } + Ok(()) + })?; + Ok(json!({"range": range.a1()})) +} + +/// The formulas of a model range, in order. Every cell up to the last formula must hold one: a +/// cell without a formula there (a constraint overwritten by a constant, say) would shift the +/// rows below it and drop a constraint or the options. Empty cells after the last formula are +/// allowed. +fn cells_with_formulas(r: RangeRef, formula: impl Fn(CellRef) -> Option) -> Result> { + let cells: Vec<(CellRef, Option)> = r.iter().map(|c| (c, formula(c))).collect(); + let end = cells.iter().rposition(|(_, f)| f.is_some()).map_or(0, |i| i + 1); + let mut rows = Vec::with_capacity(end); + for (c, f) in cells.into_iter().take(end) { + let f = f.ok_or_else(|| EngineError::Other(format!("{} holds no formula, so the range isn't a model saved with Save Model.", c.a1())))?; + rows.push(f); + } + Ok(rows) +} + +/// A model read down from `start` must end with its options row, which Save Model always +/// writes last: a cell without a formula before it (a constraint cleared, say) would otherwise +/// end the read early and load the rows above it as the whole model, with default options. +fn complete_from_cell(start: CellRef, down: &[(CellRef, String)]) -> Result<()> { + match down.last() { + Some((_, f)) if is_options_row(f) => Ok(()), + _ if down.len() >= MAX_MODEL_ROWS => Err(EngineError::Other(format!( + "No options row ends the model within {MAX_MODEL_ROWS} cells of {}, so it isn't a model saved with Save Model.", + start.a1() + ))), + last => { + let gap = last.map_or(Some(start), |(c, _)| c.row.checked_add(1).map(|row| CellRef::new(row, c.col))); + Err(EngineError::Other(match gap { + Some(gap) => format!( + "{} holds no formula before the model's options row, so {} doesn't start a complete model saved with Save Model.", + gap.a1(), + start.a1() + ), + None => format!("{} doesn't start a complete model saved with Save Model.", start.a1()), + })) + } + } +} + +/// `data.solverLoadModel`: makes a model written by Save Model the active sheet's model. +pub(super) fn load_model_cells(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let active = d.wb.active_sheet(); + let text = str_param(p, "from").ok_or_else(|| bad(LOAD_CMD, "missing `from` (the saved model's range)"))?; + let (sh, body) = split_sheet(text); + if sh.is_some_and(|n| d.wb.sheet_index(&n) != Some(active)) { + return Err(EngineError::Other("Load Model reads a model saved on the active sheet.".into())); + } + let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(LOAD_CMD, format!("not a range: `{text}`")))?; + let cap = MAX_MODEL_ROWS; + let sheet = d.wb.sheet(active).ok_or(EngineError::NoDocument)?; + let formula = |c: CellRef| sheet.cell(c).and_then(|x| x.formula.as_ref()).map(|f| f.text.clone()); + let rows: Vec = if r.is_single() { + let down = formulas_down(sheet, r.start); + complete_from_cell(r.start, &down)?; + down.into_iter().map(|(_, f)| f).collect() + } else { + if r.count() > cap as u64 { + return Err(EngineError::Other(format!("The range is too large for a saved model (at most {cap} cells)."))); + } + let rows = cells_with_formulas(r, formula)?; + if !rows.last().is_some_and(|f| is_options_row(f)) { + return Err(EngineError::Other(format!( + "{} doesn't end with the model's options row, so it isn't a complete model saved with Save Model.", + r.a1() + ))); + } + rows + }; + let params = parse_model_formulas(&rows)?; + let model = parse_model(s, ¶ms)?; + if model.sheet != active { + return Err(EngineError::Other("Load Model reads a model of the active sheet.".into())); + } + edit(s, |cx| { + save_model(&mut cx.wb, &model); + Ok(()) + })?; + Ok(json!({"model": saved_model(&s.doc()?.wb, active)})) +} + +/// Whether a defined name belongs to the saved model of `sheet`. +fn is_sheet_model_name(n: &DefinedName, sheet: usize) -> bool { + n.scope == Some(sheet) && is_model_name(&n.name) +} + +/// `data.solverReset`: Reset All removes the active sheet's model. A model the sheet reads from +/// workbook-level names (which other sheets may read too) is left in place and masked by an +/// empty sheet-level `solver_opt`, so no other sheet's model changes. +pub(super) fn reset_model(s: &mut Session, _p: &Json) -> Result { + let d = s.doc()?; + let active = d.wb.active_sheet(); + let removed = d.wb.names.iter().filter(|n| is_sheet_model_name(n, active)).count(); + let mut after = d.wb.names.clone(); + after.retain(|n| !is_sheet_model_name(n, active)); + let masked = { + let mut probe = (*d.wb).clone(); + probe.names = after; + saved_model(&probe, active).is_some() + }; + if removed > 0 || masked { + edit(s, |cx| { + cx.wb.names.retain(|n| !is_sheet_model_name(n, active)); + if masked { + cx.wb.names.push(DefinedName { + name: "solver_opt".into(), + scope: Some(active), + formula: "\"\"".into(), + comment: String::new(), + hidden: true, + }); + } + Ok(()) + })?; + } + Ok(json!({"removed": removed, "masked": masked})) +} diff --git a/crates/engine/src/cmd/solver/reports.rs b/crates/engine/src/cmd/solver/reports.rs index eade2789b..d3916b896 100644 --- a/crates/engine/src/cmd/solver/reports.rs +++ b/crates/engine/src/cmd/solver/reports.rs @@ -2,8 +2,8 @@ use super::lp::Outcome; use super::model::{Constraint, Goal, Method, Model, Rel, Rhs, parse_model, saved_model}; use super::problem::{Evaluator, Problem, as_num, build_problem}; use super::{ - Cell, CellRef, Ctx, DEFAULT_EVALS, DocState, EngineError, Json, MAX_EVALS, PRECISION, REPORT_CMD, Result, Session, Sheet, Trial, Value, bad, - edit, json, optimise, str_param, + Cell, CellRef, Ctx, DocState, EngineError, Json, PRECISION, REPORT_CMD, Result, Session, Sheet, Trial, Value, bad, edit, json, optimise, + str_param, }; // ---------------------------------------------------------------- reports @@ -362,11 +362,7 @@ pub(super) fn report(s: &mut Session, p: &Json) -> Result { }; let solution = point("values")?; let original = if p.get("original").is_some() { point("original")? } else { solution.clone() }; - let budget = params - .get("maxEvaluations") - .and_then(Json::as_f64) - .filter(|v| v.is_finite() && *v >= 1.0) - .map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS)); + let budget = model.max_evals; let message = str_param(p, "message").unwrap_or("").chars().take(500).collect(); let inp = ReportInput { model: &model, prob: &prob, original, solution, message, budget }; let built = prepare_reports(s.doc()?, &kinds, &inp)?; diff --git a/crates/engine/src/cmd/solver/tests.rs b/crates/engine/src/cmd/solver/tests.rs index 3f570fc6e..816f38ceb 100644 --- a/crates/engine/src/cmd/solver/tests.rs +++ b/crates/engine/src/cmd/solver/tests.rs @@ -400,6 +400,538 @@ fn solver_report_insertion_keeps_variable_edit_guards() { } } +fn formula(s: &Session, a1: &str) -> String { + let d = s.doc().unwrap(); + d.wb.active().unwrap().cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()).unwrap_or_default() +} + +#[test] +fn save_and_load_model_round_trip_through_cells() { + let mut s = s(); + set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("B3", "=A1+2*A2"), ("C2", "24")]); + let model = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= C2", "B3 <= 6", "A1:A2 int", "A2 bin", "A1 >= 1"], "method": "simplex"}); + s.execute("data.solver", model.clone()).unwrap(); + let out = s.execute("data.solverSaveModel", json!({"to": "H1"})).unwrap(); + assert_eq!(out["range"], "H1:H8"); + let rows: Vec = (1..=8).map(|r| formula(&s, &format!("H{r}"))).collect(); + assert_eq!( + rows, + [ + "MAX($B$1)", + "COUNT($A$1:$A$2)", + "AND($B$2<=$C$2)", + "AND($B$3<=6)", + "AND($A$1:$A$2=INT($A$1:$A$2))", + "AND(($A$2=0)+($A$2=1))", + "AND($A$1>=1)", + "{2,1,100,0.075,0,1,0.0001,50,20000,0}", + ] + ); + // The constraint cells show whether each constraint holds at the solution (4, 0). + assert_eq!(at(&s, "Sheet1", "H3"), Value::Bool(true)); + assert_eq!(at(&s, "Sheet1", "H2"), Value::number(2.0)); + // Reset All removes the model; Load Model restores it from the range or its first cell. + assert_eq!(s.execute("data.solverReset", json!({})).unwrap()["removed"].as_u64().map(|n| n > 0), Some(true)); + assert!(s.execute("data.solver", json!({})).unwrap()["model"].is_null()); + assert_eq!(s.execute("data.solverReset", json!({})).unwrap()["removed"], 0); + for from in ["H1:H8", "H1"] { + let loaded = s.execute("data.solverLoadModel", json!({"from": from})).unwrap(); + assert_eq!(loaded["model"]["objective"], "B1", "{loaded}"); + assert_eq!(loaded["model"]["constraints"], json!(["B2 <= C2", "B3 <= 6", "A1:A2 int", "A2 bin", "A1 >= 1"])); + assert_eq!(loaded["model"]["method"], "simplex"); + } + set(&mut s, &[("A1", "0"), ("A2", "0")]); + let r = s.execute("data.solver", json!({"saved": true})).unwrap(); + assert_eq!((r["status"].as_str(), n(&s, "A1"), n(&s, "A2")), (Some("optimal"), 4.0, 0.0), "{r}"); + // A Value Of goal and the Evolutionary options survive too; an explicit model is saved + // as given. + let evo = json!({"objective": "B1", "goal": "value", "value": -2.5, "variables": "A1:A2", "constraints": ["A1:A2 <= 10"], "nonNegative": false, "method": "evolutionary", "populationSize": 40, "randomSeed": 3, "requireBounds": false}); + assert_eq!(s.execute("data.solverSaveModel", json!({"to": "J10", "model": evo})).unwrap()["range"], "J10:J13"); + assert_eq!(formula(&s, "J10"), "$B$1=-2.5"); + let m = s.execute("data.solverLoadModel", json!({"from": "J10:J13"})).unwrap()["model"].clone(); + assert_eq!((m["goal"].as_str(), m["value"].as_f64(), m["nonNegative"].as_bool()), (Some("value"), Some(-2.5), Some(false)), "{m}"); + assert_eq!((m["method"].as_str(), m["populationSize"].as_f64(), m["randomSeed"].as_f64()), (Some("evolutionary"), Some(40.0), Some(3.0))); + assert_eq!(m["requireBounds"], false); + // Each command is one undo step. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.execute("data.solver", json!({})).unwrap()["model"]["method"], "simplex"); + // Refused: no model or no target, overlapping the model, off the sheet, ranges that + // hold no model, other sheets and oversized ranges. + set(&mut s, &[("L1", "=1+1"), ("L2", "=SUM(A1:A2)")]); + for (cmd, p) in [ + ("data.solverSaveModel", json!({})), + ("data.solverSaveModel", json!({"to": "A1"})), + ("data.solverSaveModel", json!({"to": "B3"})), + ("data.solverSaveModel", json!({"to": "A1048575"})), + ("data.solverSaveModel", json!({"to": "H1", "model": {"objective": "B1", "variables": "A1,A2", "constraints": ["A1,A2 <= 3"]}})), + ("data.solverSaveModel", json!({"to": "H1", "model": "B1"})), + ("data.solverLoadModel", json!({})), + ("data.solverLoadModel", json!({"from": "L1:L2"})), + ("data.solverLoadModel", json!({"from": "Z1"})), + ("data.solverLoadModel", json!({"from": "H2:H8"})), + ("data.solverLoadModel", json!({"from": "H1:H1048576"})), + ("data.solverLoadModel", json!({"from": "Nope!H1"})), + ] { + assert!(s.execute(cmd, p.clone()).is_err(), "{cmd} {p}"); + } + s.execute("data.solverReset", json!({})).unwrap(); + assert!(s.execute("data.solverSaveModel", json!({"to": "H1"})).is_err(), "no model to save"); +} + +#[test] +fn reset_all_masks_a_workbook_level_model_and_keeps_other_sheets_models() { + let mut s = s(); + set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]); + s.execute("home.insertSheet", json!({"name": "Two"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + let (one, two) = { + let wb = &s.doc().unwrap().wb; + (wb.sheet_index("Sheet1").unwrap(), wb.sheet_index("Two").unwrap()) + }; + // A model saved as workbook-level names, which every sheet reads. + { + let d = s.doc_mut().unwrap(); + let wb = std::sync::Arc::make_mut(&mut d.wb); + for (name, formula) in [ + ("solver_opt", "Sheet1!$B$1"), + ("solver_adj", "Sheet1!$A$1"), + ("solver_typ", "1"), + ("solver_num", "0"), + ("solver_eng", "2"), + ("solver_ssz", "500"), + ] { + wb.names.push(gridcraft_model::DefinedName { + name: name.into(), + scope: None, + formula: formula.into(), + comment: String::new(), + hidden: true, + }); + } + } + let model = |s: &Session, sheet: usize| super::saved_model(&s.doc().unwrap().wb, sheet); + assert_eq!(model(&s, one).map(|m| m["objective"].clone()), Some(json!("B1"))); + // Reset All empties this sheet's model, which the dialog then shows, and no other sheet's. + let r = s.execute("data.solverReset", json!({})).unwrap(); + assert_eq!(model(&s, one), None, "{r}"); + assert_eq!(r["masked"], true, "{r}"); + assert!(s.execute("data.solver", json!({})).unwrap()["model"].is_null()); + assert_eq!(model(&s, two).map(|m| m["objective"].clone()), Some(json!("B1"))); + assert_eq!(s.doc().unwrap().wb.names.iter().filter(|n| n.scope.is_none()).count(), 6); + // Undo brings the model back with its options; the mask survives XLSX. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(model(&s, one).and_then(|m| m["populationSize"].as_f64()), Some(500.0)); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(model(&s, one), None); + let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "r.xlsx", "base64": b64})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + assert_eq!(model(&s, one), None); + assert!(model(&s, two).is_some()); + // A model saved on the sheet replaces the mask and is read alone: the workbook-level + // population size doesn't leak into it. + s.execute("data.solver", json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 3"], "method": "simplex"})).unwrap(); + let m = model(&s, one).unwrap(); + assert_eq!((m["objective"].as_str(), m["populationSize"].as_f64()), (Some("B1"), Some(100.0)), "{m}"); + assert_eq!(s.execute("data.solverReset", json!({})).unwrap()["masked"], true); + assert_eq!(model(&s, one), None); +} + +#[test] +fn saving_a_shorter_model_over_a_longer_one_clears_the_rest() { + let mut s = s(); + set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("B3", "=A1+2*A2"), ("H9", "=1+1")]); + let long = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24", "B3 <= 6", "A1 <= 3", "A2 <= 3"]}); + let short = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24"]}); + assert_eq!(s.execute("data.solverSaveModel", json!({"to": "H1", "model": long})).unwrap()["range"], "H1:H7"); + assert_eq!(s.execute("data.solverSaveModel", json!({"to": "H1", "model": short, "overwrite": true})).unwrap()["range"], "H1:H4"); + // The old model's last rows are cleared; a formula below it stays. + for r in 5..=7 { + assert_eq!(formula(&s, &format!("H{r}")), "", "H{r}"); + } + assert_eq!(formula(&s, "H9"), "1+1"); + let loaded = s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap(); + assert_eq!(loaded["model"]["constraints"], json!(["B2 <= 24"]), "{loaded}"); + // Reading down from one cell stops at the options row even with formulas right below it. + set(&mut s, &[("H5", "=2+2")]); + let loaded = s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap(); + assert_eq!(loaded["model"]["constraints"], json!(["B2 <= 24"]), "{loaded}"); + // Saving over cells that hold no model leaves the cells below them as they are. + assert_eq!(s.execute("data.solverSaveModel", json!({"to": "H8", "model": short, "overwrite": true})).unwrap()["range"], "H8:H11"); + assert_eq!(formula(&s, "H5"), "2+2"); +} + +#[test] +fn save_model_asks_before_it_replaces_data_and_is_one_undo_step() { + let mut s = s(); + set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("H2", "kept"), ("H3", "=1+1")]); + let model = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24"]}); + // A value or a formula in the column: refused, the sheet unchanged, the cell named. + let e = s.execute("data.solverSaveModel", json!({"to": "H1", "model": model.clone()})).unwrap_err().to_string(); + assert!(e.contains(crate::cmd::analysis::OVERWRITE_ERROR) && e.contains("H1:H4") && e.contains("H2"), "{e}"); + assert_eq!((at(&s, "Sheet1", "H2"), formula(&s, "H3"), formula(&s, "H1")), (Value::text("kept"), "1+1".into(), String::new())); + // So is a model saved there before, and `overwrite` only as a true boolean. + assert!(s.execute("data.solverSaveModel", json!({"to": "J10", "model": model.clone()})).is_ok()); + for overwrite in [json!(null), json!(false), json!("true"), json!(1)] { + let e = s.execute("data.solverSaveModel", json!({"to": "J10", "model": model.clone(), "overwrite": overwrite})).unwrap_err(); + assert!(e.to_string().contains(crate::cmd::analysis::OVERWRITE_ERROR), "{overwrite}: {e}"); + } + // overwrite: true replaces the data in one undo step, which brings it all back. + let out = s.execute("data.solverSaveModel", json!({"to": "H1", "model": model, "overwrite": true})).unwrap(); + assert_eq!(out["range"], "H1:H4"); + assert_eq!((formula(&s, "H1"), formula(&s, "H2"), formula(&s, "H3")), ("MAX($B$1)".into(), "COUNT($A$1:$A$2)".into(), "AND($B$2<=24)".into())); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!( + (at(&s, "Sheet1", "H2"), formula(&s, "H3"), formula(&s, "H1"), formula(&s, "H4")), + (Value::text("kept"), "1+1".into(), String::new(), String::new()) + ); + assert_eq!(formula(&s, "J10"), "MAX($B$1)"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(formula(&s, "H2"), "COUNT($A$1:$A$2)"); +} + +#[test] +fn saving_and_resetting_a_model_keep_the_users_own_solver_names() { + let mut s = s(); + set(&mut s, &[("A1", "0"), ("B1", "=ABS(A1-3)"), ("C1", "100")]); + let name = |name: &str, scope: Option, formula: &str, hidden: bool| gridcraft_model::DefinedName { + name: name.into(), + scope, + formula: formula.into(), + comment: String::new(), + hidden, + }; + { + let d = s.doc_mut().unwrap(); + let wb = std::sync::Arc::make_mut(&mut d.wb); + // The user's own names, on the sheet and the workbook, and Excel's Solver names the + // model replaces. + wb.names.push(name("solver_budget", Some(0), "Sheet1!$C$1", false)); + wb.names.push(name("Solver_Lhs_Total", Some(0), "Sheet1!$C$1", false)); + wb.names.push(name("solver_budget", None, "Sheet1!$C$1", false)); + wb.names.push(name("solver_lin", Some(0), "2", true)); + wb.names.push(name("solver_lhs12", Some(0), "Sheet1!$A$1", true)); + } + let names = |s: &Session| -> Vec { + let mut v: Vec = s.doc().unwrap().wb.names.iter().map(|n| format!("{}@{:?}", n.name, n.scope)).collect(); + v.sort(); + v + }; + let mine = ["Solver_Lhs_Total@Some(0)", "solver_budget@None", "solver_budget@Some(0)"]; + let model = json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["A1 <= 4"], "method": "simplex"}); + s.execute("data.solver", model.clone()).unwrap(); + let after = names(&s); + assert!(mine.iter().all(|n| after.contains(&n.to_string())), "{after:?}"); + assert!(!after.iter().any(|n| n.starts_with("solver_lin") || n.starts_with("solver_lhs12")), "{after:?}"); + // Load Model saves the model again; Reset All removes it and nothing else. + s.execute("data.solverSaveModel", json!({"to": "H1"})).unwrap(); + s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap(); + assert!(mine.iter().all(|n| names(&s).contains(&n.to_string())), "{:?}", names(&s)); + let model_names = names(&s).len() - mine.len(); + let r = s.execute("data.solverReset", json!({})).unwrap(); + assert_eq!((r["removed"].as_u64(), r["masked"].as_bool()), (Some(model_names as u64), Some(false)), "{r}"); + assert_eq!(names(&s), mine); + assert!(s.execute("data.solver", json!({})).unwrap()["model"].is_null()); +} + +#[test] +fn save_model_refuses_a_place_where_the_options_cannot_spill() { + let mut s = s(); + set(&mut s, &[("B1", "=ABS(A1-3)"), ("H24", "=SEQUENCE(1,3)"), ("K30", "=SEQUENCE(3)"), ("M50", "=SEQUENCE(3)")]); + // Tables whose empty data rows meet an options row: one in its way, one holding the + // options cell. + for range in ["T59:U62", "W69:W71"] { + s.execute("insert.table", json!({"range": range, "header": true})).unwrap(); + } + let model = json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["F4 <= 5"]}); + let save = |s: &mut Session, to: &str| s.execute("data.solverSaveModel", json!({"to": to, "model": model.clone()})); + // The options row (H4) spills into I4:Q4: a value in its last cell is in the way. + set(&mut s, &[("Q4", "x")]); + let e = save(&mut s, "H1").unwrap_err().to_string(); + assert!(e.contains("I4:Q4") && e.contains("Q4 isn't empty"), "{e}"); + assert_eq!((formula(&s, "H1"), formula(&s, "H4")), (String::new(), String::new())); + assert!(s.execute("data.solver", json!({})).unwrap()["model"].is_null()); + // With the row clear the options spill, and saving again at the same cell replaces them. + set(&mut s, &[("Q4", "")]); + for overwrite in [false, true] { + let p = json!({"to": "H1", "model": model.clone(), "overwrite": overwrite}); + assert_eq!(s.execute("data.solverSaveModel", p).unwrap()["range"], "H1:H4"); + assert_eq!( + (at(&s, "Sheet1", "H4"), at(&s, "Sheet1", "I4"), at(&s, "Sheet1", "O4"), at(&s, "Sheet1", "Q4")), + (Value::number(1.0), Value::number(1.0), Value::number(50.0), Value::number(0.0)) + ); + } + // Refused: in the options row, a formula (H24), another formula's spilled values (K31, + // from K30), a cell of the model (F4), merged cells, a table and the sheet's last columns; + // a column over another formula's spilled values (I24 of H24:J24, M51 of M50:M52) or with + // its options cell in a table. + s.execute("home.mergeCells", json!({"range": "S8:T9"})).unwrap(); + for (to, why) in [ + ("G21", "H24 isn't empty"), + ("J28", "K31 holds the values of another formula"), + ("E1", "F4 is a cell of the model"), + ("R5", "merged"), + ("S57", "T60:AB60, but a table is in the way"), + ("XFA1", "need 10 cells"), + ("I21", "cover I24, which holds the values of another formula"), + ("M51", "cover M51, which holds the values of another formula"), + ("W67", "X70:AF70, but a table is in the way"), + ] { + let e = save(&mut s, to).unwrap_err().to_string(); + assert!(e.contains(why), "{to}: {e}"); + assert_eq!(formula(&s, to), "", "{to}"); + } + assert_eq!( + (at(&s, "Sheet1", "J24"), at(&s, "Sheet1", "K32"), at(&s, "Sheet1", "M52")), + (Value::number(3.0), Value::number(3.0), Value::number(3.0)) + ); +} + +#[test] +fn load_model_refuses_a_range_with_a_cell_without_a_formula() { + let mut s = s(); + set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("B3", "=A1+2*A2")]); + let model = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24", "B3 <= 6", "A1 <= 3"], "method": "simplex"}); + assert_eq!(s.execute("data.solverSaveModel", json!({"to": "H1", "model": model})).unwrap()["range"], "H1:H6"); + // A range with empty cells after the model loads it. + let loaded = s.execute("data.solverLoadModel", json!({"from": "H1:H9"})).unwrap(); + assert_eq!(loaded["model"]["constraints"], json!(["B2 <= 24", "B3 <= 6", "A1 <= 3"]), "{loaded}"); + s.execute("data.solverReset", json!({})).unwrap(); + // A constraint overwritten by a constant would drop that constraint; a gap before the + // options row would drop the options. Both are refused, naming the cell. + for (cell, input) in [("H4", "7"), ("H5", "")] { + let before = formula(&s, cell); + set(&mut s, &[(cell, input)]); + let e = s.execute("data.solverLoadModel", json!({"from": "H1:H6"})).unwrap_err().to_string(); + assert!(e.contains(cell), "{e}"); + assert!(s.execute("data.solver", json!({})).unwrap()["model"].is_null(), "{cell}"); + set(&mut s, &[(cell, &format!("={before}"))]); + } + assert!(s.execute("data.solverLoadModel", json!({"from": "H1:H6"})).is_ok()); +} + +#[test] +fn load_model_refuses_a_model_cut_short_and_keeps_the_old_one() { + let mut s = s(); + set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("B3", "=A1+2*A2")]); + let model = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24", "B3 <= 6", "A1 <= 3"], "method": "simplex"}); + s.execute("data.solver", model).unwrap(); + assert_eq!(s.execute("data.solverSaveModel", json!({"to": "H1"})).unwrap()["range"], "H1:H6"); + let kept = |s: &mut Session| { + let m = s.execute("data.solver", json!({})).unwrap()["model"].clone(); + assert_eq!((m["constraints"].clone(), m["method"].clone()), (json!(["B2 <= 24", "B3 <= 6", "A1 <= 3"]), json!("simplex")), "{m}"); + }; + // A constraint cleared (H4), the options row cleared (H6) or replaced by a constant: from + // the first cell or as a range, the rows above the gap are not loaded as the whole model. + for (cell, input) in [("H4", ""), ("H6", ""), ("H6", "7")] { + let before = formula(&s, cell); + set(&mut s, &[(cell, input)]); + for from in ["H1", "H1:H6", "H1:H9"] { + let e = s.execute("data.solverLoadModel", json!({"from": from})).unwrap_err().to_string(); + assert!(e.contains("complete model") || e.contains(cell), "{cell} {from}: {e}"); + kept(&mut s); + } + set(&mut s, &[(cell, &format!("={before}"))]); + } + // An options row cut short or edited by hand: fewer than the eight values Save Model + // writes, or a value that isn't a number (among the eight, after them or after the sixteenth). + let options = formula(&s, "H6"); + for row in [ + "={1}", + "={2,1,100,0.075,0,1,0.0001}", + "={2,1,100,0.075,0,1,\"x\",50}", + "={2,TRUE,100,0.075,0,1,0.0001,50}", + "={2,1,100,0.075,0,1,0.0001,50,\"x\"}", + "={2,1,100,0.075,0,1,0.0001,50,9,10,11,12,13,14,15,16,\"x\"}", + ] { + set(&mut s, &[("H6", row)]); + for from in ["H1", "H1:H6"] { + let e = s.execute("data.solverLoadModel", json!({"from": from})).unwrap_err().to_string(); + assert!(e.contains("complete model"), "{row} {from}: {e}"); + kept(&mut s); + } + } + // More values than eight (as a later version may write) still load. + set(&mut s, &[("H6", &format!("={}", options.trim_end_matches('}').to_string() + ",7,8}"))]); + assert!(s.execute("data.solverLoadModel", json!({"from": "H1"})).is_ok()); + kept(&mut s); + set(&mut s, &[("H6", &format!("={options}"))]); + // The gap is named when reading down from one cell. + set(&mut s, &[("H4", "")]); + let e = s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap_err().to_string(); + assert!(e.contains("H4 holds no formula"), "{e}"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.execute("data.solverLoadModel", json!({"from": "H1"})).is_ok()); + kept(&mut s); +} + +#[test] +fn load_model_reads_a_bounded_column_and_refuses_malformed_cells() { + let mut s = s(); + set(&mut s, &[("B1", "=ABS(A1-3)")]); + let put = |s: &mut Session, col: u32, rows: std::ops::Range, f: &dyn Fn(u32) -> String| { + let d = s.doc_mut().unwrap(); + let sh = std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut d.wb).sheets[0]); + for r in rows { + sh.set_cell(CellRef::new(r, col), gridcraft_model::Cell::formula(gridcraft_model::Formula::new(&f(r)))); + } + }; + // A column of formulas far longer than any model: read from its first cell, the read stops + // after the most rows a model takes and the column is refused; as a range, it is refused + // before any cell is read. + put(&mut s, 15, 0..5_000, &|r| format!("{r}+1")); + for from in ["P1", "P1:P5000", "P1:P1048576", "A1:XFD1048576"] { + assert!(s.execute("data.solverLoadModel", json!({"from": from})).is_err(), "{from}"); + } + // Malformed and hostile cells and ranges: an error each, never a panic, no model. + let bad = ["AND(", "{", "{1e400,2", "MAX(Nope!A1)", "COUNT($A$1:$A$2", "AND($B$1<=", "MAX($B$1)&\"x\""]; + put(&mut s, 17, 0..bad.len() as u32, &|r| bad[r as usize].to_string()); + put(&mut s, 18, 0..1, &|_| format!("AND({})", "(".repeat(10_000))); + for r in 1..=bad.len() { + assert!(s.execute("data.solverLoadModel", json!({"from": format!("R{r}")})).is_err(), "R{r}"); + } + for from in ["S1", "", ":", "P1:", "H0", "Sheet9!H1", "'Sheet1'!R1:R7", "R7:R1"] { + assert!(s.execute("data.solverLoadModel", json!({"from": from})).is_err(), "{from}"); + } + assert!(s.execute("data.solverLoadModel", json!({"from": 7})).is_err()); + assert!(s.execute("data.solver", json!({})).unwrap()["model"].is_null()); +} + +#[test] +fn load_model_keeps_the_evolutionary_convergence_and_generations() { + let mut s = s(); + set(&mut s, &[("B1", "=ABS(A1-3)")]); + let evo = json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["A1 <= 4"], "method": "evolutionary", "convergence": 0.001, "generationsWithoutImprovement": 20}); + s.execute("data.solverSaveModel", json!({"to": "H1", "model": evo})).unwrap(); + let m = s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap()["model"].clone(); + assert_eq!((m["convergence"].as_f64(), m["generationsWithoutImprovement"].as_f64()), (Some(0.001), Some(20.0)), "{m}"); + // They survive XLSX; Excel's time limit (solver_mni, in seconds) isn't written or read. + let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "c.xlsx", "base64": b64})).unwrap(); + let m = s.execute("data.solver", json!({})).unwrap()["model"].clone(); + assert_eq!((m["convergence"].as_f64(), m["generationsWithoutImprovement"].as_f64()), (Some(0.001), Some(20.0)), "{m}"); + assert!(!s.doc().unwrap().wb.names.iter().any(|n| n.name == "solver_mni")); + // Reset All removes the GridCraft name with the rest. + s.execute("data.solverReset", json!({})).unwrap(); + assert!(s.doc().unwrap().wb.names.iter().all(|n| n.scope != Some(0))); +} + +#[test] +fn solver_options_are_saved_with_the_model() { + let mut s = s(); + set(&mut s, &[("B1", "=10*A1+13*A2"), ("B2", "=5*A1+6*A2")]); + let p = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 10", "A1:A2 bin"], "method": "evolutionary", "maxEvaluations": "3000", "integerOptimality": 5, "convergence": 0.001, "generationsWithoutImprovement": 20, "mutationRate": "0.1"}); + let r = s.execute("data.solver", p).unwrap(); + assert!(r["evaluations"].as_u64().unwrap() <= 3001, "{r}"); + // The solve's own result gives whole numbers as integers too. + for k in ["maxEvaluations", "integerOptimality", "generationsWithoutImprovement", "populationSize", "randomSeed"] { + assert!(r["model"][k].is_i64() || r["model"][k].is_u64(), "{k}: {r}"); + } + // The options survive XLSX in the hidden names, as given or as text. + let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "o.xlsx", "base64": b64})).unwrap(); + let m = s.execute("data.solver", json!({})).unwrap()["model"].clone(); + let f = |k: &str| m[k].as_f64(); + assert_eq!((f("maxEvaluations"), f("integerOptimality"), f("convergence")), (Some(3000.0), Some(5.0), Some(0.001)), "{m}"); + assert_eq!((f("generationsWithoutImprovement"), f("mutationRate")), (Some(20.0), Some(0.1))); + // Whole numbers read back as integers, so the Solver dialog shows 3000 and not 3000.0. + for k in ["maxEvaluations", "integerOptimality", "generationsWithoutImprovement", "populationSize", "randomSeed"] { + assert!(m[k].is_i64(), "{k}: {m}"); + } + assert_eq!(m["maxEvaluations"].to_string(), "3000"); + // Out-of-range options are clamped, not refused. + let r = s.execute("data.solver", json!({"saved": true, "maxEvaluations": "1e300", "integerOptimality": -4})).unwrap(); + assert_eq!((r["model"]["maxEvaluations"].as_f64(), r["model"]["integerOptimality"].as_f64()), (Some(200_000.0), Some(0.0))); +} + +#[test] +fn excel_iteration_and_time_limits_are_not_read_as_gridcraft_options() { + let mut s = s(); + set(&mut s, &[("A1", "0"), ("B1", "=ABS(A1-3)")]); + let model = json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["A1 <= 4"], "method": "evolutionary"}); + s.execute("data.solver", model.clone()).unwrap(); + let names = |s: &Session| -> Vec { + let d = s.doc().unwrap(); + d.wb.names.iter().filter(|n| n.scope == Some(0)).map(|n| n.name.clone()).collect() + }; + // GridCraft's evaluation limit and generations go under its own names, not Excel's. + let saved = names(&s); + assert!(saved.iter().all(|n| n != "solver_itr" && n != "solver_mni"), "{saved:?}"); + assert!(saved.iter().any(|n| n == "gridcraft_solver_evals") && saved.iter().any(|n| n == "gridcraft_solver_stall"), "{saved:?}"); + // Excel's own limits, as a workbook from Excel holds them: 100 iterations, 30 seconds. + { + let d = s.doc_mut().unwrap(); + let wb = std::sync::Arc::make_mut(&mut d.wb); + wb.names.retain(|n| !n.name.starts_with("gridcraft_")); + for (name, formula) in [("solver_itr", "100"), ("solver_mni", "30")] { + wb.names.push(gridcraft_model::DefinedName { + name: name.into(), + scope: Some(0), + formula: formula.into(), + comment: String::new(), + hidden: true, + }); + } + } + let m = s.execute("data.solver", json!({})).unwrap()["model"].clone(); + assert!(m["maxEvaluations"].is_null() && m["generationsWithoutImprovement"].is_null(), "{m}"); + set(&mut s, &[("A1", "0")]); + let r = s.execute("data.solver", json!({"saved": true})).unwrap(); + assert!(r["evaluations"].as_u64().unwrap() > 100, "{r}"); + assert!((n(&s, "A1") - 3.0).abs() < 1e-3, "{r}"); + // Saving again replaces GridCraft's names; Reset All removes them with the rest. Simplex + // LP refuses the nonlinear ABS model, but the model is saved before the solve. + let r = s.execute( + "data.solver", + json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["A1 <= 4"], "method": "simplex", "maxEvaluations": 700, "keep": false}), + ); + let m = super::saved_model(&s.doc().unwrap().wb, 0).unwrap(); + assert_eq!((m["method"].as_str(), m["maxEvaluations"].as_f64()), (Some("simplex"), Some(700.0)), "{m} {r:?}"); + let saved = names(&s); + assert_eq!( + saved.iter().filter(|n| n.starts_with("gridcraft_")).collect::>(), + ["gridcraft_solver_evals", "gridcraft_solver_stall"], + "{saved:?}" + ); + s.execute("data.solver", model).unwrap(); + s.execute("data.solverReset", json!({})).unwrap(); + assert!(names(&s).is_empty(), "{:?}", names(&s)); +} + +#[test] +fn integer_optimality_reads_a_percent_sign_as_percent() { + let mut s = s(); + set(&mut s, &[("B1", "=3*A1")]); + for (given, want) in [(json!("5%"), 5.0), (json!(" 2.5 % "), 2.5), (json!("5"), 5.0), (json!(5), 5.0), (json!("150%"), 100.0)] { + let p = json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4", "A1 int"], "method": "simplex", "integerOptimality": given}); + let r = s.execute("data.solver", p).unwrap(); + assert_eq!(r["model"]["integerOptimality"].as_f64(), Some(want), "{given}: {r}"); + } + // Percents whose fraction is not exact in binary read back as given, from the solve's + // result and from the saved model. + for given in [7.0, 29.0, 0.1] { + let p = json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4", "A1 int"], "method": "simplex", "integerOptimality": given}); + let r = s.execute("data.solver", p).unwrap(); + assert_eq!(r["model"]["integerOptimality"].as_f64(), Some(given), "{r}"); + let m = s.execute("data.solver", json!({})).unwrap()["model"].clone(); + assert_eq!(m["integerOptimality"].as_f64(), Some(given), "{m}"); + } +} + +#[test] +fn evolutionary_options_survive_a_solve_with_another_method() { + let mut s = s(); + set(&mut s, &[("B1", "=3*A1")]); + let p = json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4"], "method": "grg", "populationSize": 40, "mutationRate": 0.2, "randomSeed": 7, "convergence": 0.001, "generationsWithoutImprovement": 25, "requireBounds": false}); + let r = s.execute("data.solver", p).unwrap(); + assert_eq!(r["model"]["populationSize"].as_f64(), Some(40.0), "{r}"); + // The saved model keeps them for the next time the method is Evolutionary. + let m = s.execute("data.solver", json!({})).unwrap()["model"].clone(); + let f = |k: &str| m[k].as_f64(); + assert_eq!((f("populationSize"), f("mutationRate"), f("randomSeed")), (Some(40.0), Some(0.2), Some(7.0)), "{m}"); + assert_eq!((f("convergence"), f("generationsWithoutImprovement"), m["requireBounds"].as_bool()), (Some(0.001), Some(25.0), Some(false))); +} + #[test] fn limits_report_of_a_nonlinear_model() { // The largest rectangle with perimeter at most 20 (5 × 5): with the height held at 5, the diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 8351bb3f4..7d4ab1fec 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -155,7 +155,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}"), @@ -184,17 +185,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..e4841b562 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"))] @@ -561,3 +575,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/engine/src/lib.rs b/crates/engine/src/lib.rs index 3ae4ac95a..4ea043a68 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(), } } } @@ -558,6 +564,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; @@ -790,6 +807,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/src/tests.rs b/crates/engine/src/tests.rs index c4c7d3d99..62c8a9e31 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})); @@ -2403,6 +2414,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(); @@ -5236,3 +5277,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"); +} diff --git a/crates/engine/src/tests/objects.rs b/crates/engine/src/tests/objects.rs index 3d420be16..da60238ae 100644 --- a/crates/engine/src/tests/objects.rs +++ b/crates/engine/src/tests/objects.rs @@ -1,7 +1,7 @@ //! Floating objects of every kind: found, moved, listed, stacked and deleted by `{kind, id}`. use gridcraft_core::CellRef; -use gridcraft_model::{AnchorMode, ObjKind, ObjRef, Sheet}; +use gridcraft_model::{Anchor, AnchorMode, ObjKind, ObjRef, Sheet}; use serde_json::json; use super::s; @@ -142,6 +142,80 @@ fn every_kind_follows_row_and_column_edits_and_sorts() { assert_eq!(cell_of(&s, pic), "T2"); } +/// A picture at `cell` with anchor `mode`, offset `(dx, dy)` and size `(width, height)`, added +/// straight to the model like `add_image`, so a sort that follows is the only step to undo. +fn picture(s: &mut Session, cell: &str, mode: AnchorMode, dx: f32, dy: f32, width: f32, height: f32) -> ObjRef { + let id = super::add_image(s, cell, mode); + let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); + if let Some(img) = wb.sheet_mut(0).unwrap().images.iter_mut().find(|i| i.id == id) { + img.anchor = Anchor { dx, dy, width, height, ..img.anchor }; + } + ObjRef::new(ObjKind::Image, id) +} + +/// The anchor of `o` as (cell, dx, dy, width, height). +fn anchor_of(s: &Session, o: ObjRef) -> (String, f32, f32, f32, f32) { + let a = sheet(s, 0).object_anchor(o).unwrap(); + (a.cell.a1(), a.dx, a.dy, a.width, a.height) +} + +/// Sorting rows under a picture follows its anchor mode, as Excel's "Move and size with cells", +/// "Move but don't size with cells" and "Don't move or size with cells" do. +#[test] +fn sorting_rows_under_a_picture_follows_its_anchor_mode() { + let mut s = s(); + // Rows 2..5 hold 3, 1, 4, 2; sorted ascending they read 1, 2, 3, 4, so row 2 moves to row 4, + // row 3 to row 2, row 4 to row 5 and row 5 to row 3. Default rows are 20 pt tall. + s.execute("range.setValues", json!({"range": "A1", "values": [["k"], [3], [1], [4], [2]]})).unwrap(); + // Three pictures inside one row each (3 pt down, 12 pt tall), one per anchor mode. + let sized = picture(&mut s, "A2", AnchorMode::MoveAndSize, 5.0, 3.0, 30.0, 12.0); + let moved = picture(&mut s, "A3", AnchorMode::MoveOnly, 5.0, 3.0, 30.0, 12.0); + let pinned = picture(&mut s, "A4", AnchorMode::Absolute, 5.0, 3.0, 30.0, 12.0); + // A "move and size" picture spanning rows 2 and 3: its top is 10 pt into row 2 (y 30) and + // its bottom 15 pt into row 3 (y 55). The sort puts row 3 above row 2. + let tall = picture(&mut s, "A2", AnchorMode::MoveAndSize, 5.0, 10.0, 30.0, 25.0); + let before: Vec<_> = [sized, moved, pinned, tall].iter().map(|&o| anchor_of(&s, o)).collect(); + s.execute("selection.set", json!({"range": "A1:A5"})).unwrap(); + s.execute("data.sortAscending", json!({"header": true, "column": "A"})).unwrap(); + assert_eq!( + anchor_of(&s, sized), + ("A4".into(), 5.0, 3.0, 30.0, 12.0), + "move and size: travels with row 2 to row 4, re-fitted to the same 20 pt row" + ); + assert_eq!(anchor_of(&s, moved), ("A2".into(), 5.0, 3.0, 30.0, 12.0), "move only: travels with row 3 to row 2 at its own size"); + assert_eq!(anchor_of(&s, pinned), ("A4".into(), 5.0, 3.0, 30.0, 12.0), "absolute: stays while the cells move under it"); + // The tall picture's bottom row now sorts above its top row, so there is nothing to stretch + // it between: it travels with its top-left row and keeps its size instead of collapsing. + assert_eq!(anchor_of(&s, tall), ("A4".into(), 5.0, 10.0, 30.0, 25.0), "two rows swapped: moves with its top row at its old size"); + s.execute("edit.undo", json!({})).unwrap(); + let after: Vec<_> = [sized, moved, pinned, tall].iter().map(|&o| anchor_of(&s, o)).collect(); + assert_eq!(after, before, "undo restores every anchor"); +} + +/// A sort by columns follows the anchor mode the same way. +#[test] +fn sorting_columns_under_a_picture_follows_its_anchor_mode() { + let mut s = s(); + // Row 10 holds a header in A and 3, 1, 2 in B..D; sorted ascending, column B moves to D, + // C to B and D to C. Default columns are 64 pt wide. + s.execute("range.setValues", json!({"range": "A10", "values": [["k", 3, 1, 2]]})).unwrap(); + let sized = picture(&mut s, "B10", AnchorMode::MoveAndSize, 5.0, 3.0, 30.0, 12.0); + let moved = picture(&mut s, "C10", AnchorMode::MoveOnly, 5.0, 3.0, 30.0, 12.0); + let pinned = picture(&mut s, "D10", AnchorMode::Absolute, 5.0, 3.0, 30.0, 12.0); + // A "move and size" picture spanning columns B and C: from x 104 (40 pt into B) to x 154 + // (26 pt into C). The sort puts column C before column B. + let wide = picture(&mut s, "B10", AnchorMode::MoveAndSize, 40.0, 3.0, 50.0, 12.0); + let before: Vec<_> = [sized, moved, pinned, wide].iter().map(|&o| anchor_of(&s, o)).collect(); + s.execute("data.sort", json!({"range": "A10:D10", "header": true, "orientation": "columns", "keys": [{"row": 10}]})).unwrap(); + assert_eq!(anchor_of(&s, sized), ("D10".into(), 5.0, 3.0, 30.0, 12.0), "move and size: travels with column B to D at the same width"); + assert_eq!(anchor_of(&s, moved), ("B10".into(), 5.0, 3.0, 30.0, 12.0), "move only: travels with column C to B at its own size"); + assert_eq!(anchor_of(&s, pinned), ("D10".into(), 5.0, 3.0, 30.0, 12.0), "absolute: stays"); + assert_eq!(anchor_of(&s, wide), ("D10".into(), 40.0, 3.0, 50.0, 12.0), "two columns swapped: moves with its left column at its old size"); + s.execute("edit.undo", json!({})).unwrap(); + let after: Vec<_> = [sized, moved, pinned, wide].iter().map(|&o| anchor_of(&s, o)).collect(); + assert_eq!(after, before, "undo restores every anchor"); +} + #[test] fn slicers_follow_inserted_rows_like_other_objects() { let mut s = s(); 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:?}"); + } +} 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/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 f6a3b2efa..7c84d553b 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 503d4771c..92449eb26 100644 --- a/crates/functions/src/math/tests.rs +++ b/crates/functions/src/math/tests.rs @@ -140,16 +140,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/crates/functions/src/stat/descriptive.rs b/crates/functions/src/stat/descriptive.rs index dcf80d276..f4e4b0160 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 6fc4059e7..ba59116cd 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -227,10 +227,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_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); } #[test] 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..87f7fea5d 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("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"], ), @@ -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/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/sheet.rs b/crates/model/src/sheet.rs index 70b504174..09388e8de 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -713,6 +713,96 @@ mod tests { assert_eq!(s.col_at(-5.0), 0); assert_eq!(s.col_left(2), 128.0); } + + use crate::features::{ChartKind, LegendPos, ShapeFormat, ShapeKind}; + + /// Adds a floating object of `kind` with id `id` at `anchor`. The match is exhaustive on + /// purpose: a new kind of floating object does not compile here until this test covers it, + /// and the counts in `object_anchor_accessors_visit_the_same_objects_in_the_same_order` + /// then catch an accessor that was not taught about it. + fn push_object(s: &mut Sheet, kind: ObjKind, id: u32, anchor: Anchor) { + match kind { + ObjKind::Chart => s.charts.push(Chart { + id, + kind: ChartKind::default(), + anchor, + title: None, + series: vec![], + legend: LegendPos::default(), + data_labels: false, + gridlines: false, + style: 0, + x_title: None, + y_title: None, + source: None, + by_rows: false, + pivot: None, + }), + ObjKind::Image => s.images.push(Image { id, anchor, data: Arc::new(vec![]), mime: String::new(), alt: String::new() }), + ObjKind::Shape => s.shapes.push(Shape { + id, + kind: ShapeKind::default(), + anchor, + fill: Color::default(), + line: Color::default(), + text: String::new(), + format: ShapeFormat::default(), + }), + ObjKind::Slicer => { + s.slicers.push(Slicer { id, table: String::new(), column: String::new(), caption: String::new(), anchor, selected: None, columns: 1 }) + } + ObjKind::Control => s.controls.push(FormControl { id, anchor, ..Default::default() }), + } + } + + /// Sorting, and inserting or deleting rows and columns, read every anchor through + /// `object_anchors`, compute the new ones and write them back through `object_anchors_mut` + /// in step. The two must visit the same objects in the same order, or an object receives + /// another object's anchor; a kind that reaches only one of them would break that silently. + #[test] + fn object_anchor_accessors_visit_the_same_objects_in_the_same_order() { + let mut s = Sheet::new("S"); + // Two objects of every kind, inserted in a mixed order, with ids unrelated to the kind. + // Each anchor cell's row carries the id, so an anchor read back names its object. + let inserted = [ + (ObjKind::Slicer, 7), + (ObjKind::Chart, 3), + (ObjKind::Image, 11), + (ObjKind::Shape, 2), + (ObjKind::Chart, 9), + (ObjKind::Slicer, 1), + (ObjKind::Image, 4), + (ObjKind::Control, 6), + (ObjKind::Shape, 12), + (ObjKind::Control, 10), + ]; + for &(kind, id) in &inserted { + push_object(&mut s, kind, id, Anchor { cell: CellRef::new(id, 0), ..Default::default() }); + } + let per_kind = s.charts.len() + s.images.len() + s.shapes.len() + s.slicers.len() + s.controls.len(); + assert_eq!(per_kind, inserted.len()); + assert_eq!(s.object_anchors().count(), per_kind, "object_anchors visits every collection"); + assert_eq!(s.object_anchors_mut().count(), per_kind, "object_anchors_mut visits every collection"); + // Mark each anchor with its position through the mutable accessor; the shared accessor + // reads the marks back in the same sequence. + for (i, a) in s.object_anchors_mut().enumerate() { + a.dx = i as f32; + } + let marks: Vec = s.object_anchors().map(|a| a.dx).collect(); + assert_eq!(marks, (0..per_kind).map(|i| i as f32).collect::>()); + // The sequence is the documented one (charts, images, shapes, slicers, controls, each in insertion + // order) and every object appears in it once. + let documented: Vec = + ObjKind::ALL.iter().flat_map(|&k| inserted.iter().filter(move |(kind, _)| *kind == k).map(|&(_, id)| id)).collect(); + let visited: Vec = s.object_anchors().map(|a| a.cell.row).collect(); + assert_eq!(visited, documented); + // The mark written through the bulk accessor landed on the object `object_anchor` finds + // by kind and id. + for &(kind, id) in &inserted { + let position = documented.iter().position(|&x| x == id).map(|p| p as f32); + assert_eq!(s.object_anchor(ObjRef::new(kind, id)).map(|a| a.dx), position, "{kind:?} {id}"); + } + } } // CellRef is a struct, so encode the sparse map as entries for JSON as well as other formats. diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index dca068155..92f490aca 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -30,6 +30,51 @@ 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). 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 +/// 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 { + /// 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() + } +} + /// File › Options › Formulas › Calculation options, stored per workbook (``). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct CalcSettings { @@ -150,6 +195,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 +332,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/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 2225f0e4f..3d1dc73c4 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/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/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/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/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 411992c27..7ca0fd6a2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -663,19 +663,31 @@ impl Dialog { d } "solver" => { - // Starts from the sheet's saved model, else the active cell as objective. + // Starts from the sheet's saved model, else the active cell as objective; fields + // given when opening it go over either. let saved = app.session.active().and_then(|doc| gridcraft_engine::cmd::solver::saved_model(&doc.wb, doc.wb.active_sheet())); let mut values = json!({"objective": active, "goal": "max", "value": "0", "variables": "", "constraints": [], "nonNegative": true, "method": "grg"}); - if let (Some(m), Some(v)) = (saved.as_ref().and_then(Json::as_object), values.as_object_mut()) { - for (k, x) in m { - v.insert(k.clone(), if k == "value" { json!(x.to_string()) } else { x.clone() }); + for m in [saved.as_ref(), Some(¶ms)].into_iter().flatten().filter_map(Json::as_object) { + if let Some(v) = values.as_object_mut() { + for (k, x) in m { + v.insert( + k.clone(), + if k == "value" { json!(x.as_str().map_or_else(|| x.to_string(), str::to_string)) } else { x.clone() }, + ); + } } } if let Some(v) = values.as_object_mut() { v.insert("newOp".into(), json!("<=")); } + let options = values.get("optionsView").and_then(Json::as_bool) == Some(true); let mut d = Dialog::custom("solver", "Solver Parameters", values); d.command = Some("data.solver"); + // Straight into the Solver Options: the same setup as the Options button, so the + // fields show their defaults and Cancel restores what was there. + if options { + analysis::open_solver_options(&mut d); + } d } "solverResults" => Dialog::custom("solverResults", "Solver Results", params.clone()), @@ -853,7 +865,13 @@ 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(); + 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"})); d.tab = if p("replace").as_bool() == Some(true) { "Replace".into() } else { "Find".into() }; diff --git a/crates/ui-egui/src/dialogs/analysis.rs b/crates/ui-egui/src/dialogs/analysis.rs index cc0dc2f9a..40e4028cb 100644 --- a/crates/ui-egui/src/dialogs/analysis.rs +++ b/crates/ui-egui/src/dialogs/analysis.rs @@ -1,4 +1,4 @@ -use super::show::{form_fields, ok_cancel}; +use super::show::{build_params, form_fields, ok_cancel}; use super::{Dialog, Field, Json, Map, SheetApp, json, vec2}; pub(super) fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { @@ -169,9 +169,122 @@ pub(super) fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Di ok_cancel(ui, confirm, open); } +/// The Solver Options fields: (key, label, default). +const SOLVER_OPTIONS: [(&str, &str, &str); 7] = [ + ("maxEvaluations", "Max Evaluations:", "20000"), + ("integerOptimality", "Integer Optimality (%):", "0"), + ("convergence", "Convergence:", "0.0001"), + ("mutationRate", "Mutation Rate:", "0.075"), + ("populationSize", "Population Size:", "100"), + ("randomSeed", "Random Seed:", "0"), + ("generationsWithoutImprovement", "Maximum Generations without Improvement:", "50"), +]; + +/// Data › Solver › Options: the Solver Options in the Solver Parameters window. OK keeps the +/// changes for the next solve; Cancel restores them. +fn solver_options(ui: &mut egui::Ui, d: &mut Dialog, edit: impl Fn(&mut egui::Ui, &mut Dialog, &str, f32, &str)) { + let mut done = false; + egui::Grid::new("solverOptions").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { + for (title, keys) in [("All Methods", &SOLVER_OPTIONS[..2]), ("Evolutionary", &SOLVER_OPTIONS[2..])] { + ui.label(egui::RichText::new(title).strong()); + ui.end_row(); + for (key, label, _) in keys { + ui.label(*label); + edit(ui, d, key, 100.0, label); + ui.end_row(); + } + } + }); + let mut bounds = d.values.get("requireBounds").and_then(Json::as_bool).unwrap_or(true); + if ui.checkbox(&mut bounds, "Require Bounds on Variables").changed() { + d.values.insert("requireBounds".into(), json!(bounds)); + } + ui.add_space(8.0); + // A row as tall as the buttons (see `ok_cancel`): a right-to-left layout straight in the + // window takes all the height it is offered. + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(" Cancel ").clicked() { + cancel_solver_options(d); + } + if ui.button(" OK ").clicked() { + done = true; + } + }); + }); + if done { + close_solver_options(d); + } +} + +/// Whether the Solver dialog shows its Solver Options. +pub(super) fn in_solver_options(d: &Dialog) -> bool { + d.name == "solver" && d.values.get("optionsView").and_then(Json::as_bool) == Some(true) +} + +/// Solver Options › Cancel (also Escape or closing the window there): back to the parameters +/// with the options as they were. +pub(super) fn cancel_solver_options(d: &mut Dialog) { + if let Some(old) = d.values.get("optionsBackup").and_then(Json::as_object).cloned() { + for (k, v) in old { + d.values.insert(k, v); + } + } + close_solver_options(d); +} + +/// Solver Parameters › Options (also opening the dialog with `optionsView`): shows the Solver +/// Options, each field with its value or its default, and keeps the values for Cancel. +pub(super) fn open_solver_options(d: &mut Dialog) { + let mut backup = serde_json::Map::new(); + for (key, _, default) in SOLVER_OPTIONS { + let v = d.values.get(key).cloned().unwrap_or_else(|| json!(default)); + backup.insert(key.into(), v.clone()); + d.values.insert(key.into(), v); + } + backup.insert("requireBounds".into(), d.values.get("requireBounds").cloned().unwrap_or(json!(true))); + d.values.insert("optionsBackup".into(), Json::Object(backup)); + d.values.insert("optionsView".into(), json!(true)); + d.title = "Solver Options".into(); +} + +fn close_solver_options(d: &mut Dialog) { + d.values.insert("optionsView".into(), json!(false)); + d.values.remove("optionsBackup"); + d.title = "Solver Parameters".into(); +} + +/// Removes the model's options (the Solver Options fields and Require Bounds) from the form. A +/// key that is absent means the option's default, so Reset All and Load Model remove them all +/// before a model of their own; an option of the model before would otherwise go with the next +/// solve. +fn clear_solver_options(d: &mut Dialog) { + for (key, _, _) in SOLVER_OPTIONS { + d.values.remove(key); + } + d.values.remove("requireBounds"); +} + +/// Save Model from the Solver Parameters form: writes the form's model down from `range`. A +/// range that holds data is replaced only with `overwrite`; without it the form asks first. +fn save_solver_model(app: &mut SheetApp, d: &mut Dialog, range: &str, overwrite: bool) { + d.values.remove("askModelOverwrite"); + let note = match app.run("data.solverSaveModel", json!({"to": range.trim(), "model": build_params(d), "overwrite": overwrite})) { + Ok(r) => format!("The model was saved to {}.", r.get("range").and_then(Json::as_str).unwrap_or("")), + Err(e) if !overwrite && e.contains(gridcraft_engine::cmd::analysis::OVERWRITE_ERROR) => { + d.values.insert("askModelOverwrite".into(), json!(range.trim())); + crate::clean_error(&e) + } + Err(e) => crate::clean_error(&e), + }; + d.values.insert("modelNote".into(), json!(note)); +} + /// Data › Solver: the Solver Parameters form. Solve runs `data.solver` through the dialog's -/// command; the result opens Solver Results. -pub(super) fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { +/// command; the result opens Solver Results. Reset All clears the form and the sheet's model; +/// Load/Save reads or writes the model on cells of the sheet; Options shows the Solver Options +/// in the same window. +pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { let lang = crate::i18n::current(ui.ctx()); let text = |d: &Dialog, k: &str| d.values.get(k).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).unwrap_or_default(); let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32, label: &str| { @@ -180,6 +293,10 @@ pub(super) fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open d.values.insert(k.into(), json!(t)); } }; + if d.values.get("optionsView").and_then(Json::as_bool) == Some(true) { + solver_options(ui, d, edit); + return; + } ui.horizontal(|ui| { ui.label("Set Objective:"); edit(ui, d, "objective", 200.0, "Set Objective:"); @@ -282,16 +399,107 @@ pub(super) fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open }) .small(), ); + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui.button("Reset All").clicked() { + match app.run("data.solverReset", json!({})) { + Ok(_) => { + for (k, v) in [ + ("objective", json!("")), + ("goal", json!("max")), + ("value", json!("0")), + ("variables", json!("")), + ("constraints", json!([])), + ("nonNegative", json!(true)), + ("method", json!("grg")), + ] { + d.values.insert(k.into(), v); + } + // The options go back to their defaults too. + clear_solver_options(d); + for key in ["optionsBackup", "modelNote", "askModelOverwrite"] { + d.values.remove(key); + } + } + Err(e) => { + d.values.insert("modelNote".into(), json!(crate::clean_error(&e))); + } + } + } + let shown = d.values.get("modelArea").and_then(Json::as_bool).unwrap_or(false); + if ui.selectable_label(shown, "Load/Save").clicked() { + d.values.insert("modelArea".into(), json!(!shown)); + } + if ui.button("Options").clicked() { + open_solver_options(d); + } + }); + if d.values.get("modelArea").and_then(Json::as_bool) == Some(true) { + ui.horizontal(|ui| { + ui.label("Model Range:"); + edit(ui, d, "modelRange", 140.0, "Model Range:"); + let range = text(d, "modelRange"); + let ready = !range.trim().is_empty(); + if ui.add_enabled(ready, egui::Button::new("Load")).clicked() { + d.values.remove("askModelOverwrite"); + match app.run("data.solverLoadModel", json!({"from": range.trim()})) { + Ok(r) => { + if let Some(m) = r.get("model").and_then(Json::as_object) { + // The loaded model's options only: one it omits takes its default. + clear_solver_options(d); + for (k, x) in m { + d.values.insert(k.clone(), if k == "value" { json!(x.to_string()) } else { x.clone() }); + } + } + d.values.insert("modelNote".into(), json!("The model was loaded.")); + } + Err(e) => { + d.values.insert("modelNote".into(), json!(crate::clean_error(&e))); + } + } + } + if ui.add_enabled(ready, egui::Button::new("Save")).clicked() { + save_solver_model(app, d, &range, false); + } + }); + // Save Model asks before it replaces data in the model range; the question goes when + // the range is edited, so Replace writes only where it asked about. + let asked = d.values.get("askModelOverwrite").and_then(Json::as_str).map(str::to_string); + if asked.as_deref().is_some_and(|a| a != text(d, "modelRange").trim()) { + d.values.remove("askModelOverwrite"); + d.values.remove("modelNote"); + } else if let Some(asked) = asked { + ui.horizontal_wrapped(|ui| { + ui.label(format!("{asked} already holds data. Replace it with the model?")); + if ui.button("Replace").clicked() { + save_solver_model(app, d, &asked, true); + } + if ui.button("Don't Replace").clicked() { + d.values.remove("askModelOverwrite"); + d.values.remove("modelNote"); + } + }); + } + } + let note = text(d, "modelNote"); + if !note.is_empty() { + ui.label(egui::RichText::new(note).small()); + } ui.add_space(8.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let t = crate::theme::Tokens::get(ui.ctx()); - let solve = ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(t.on_accent)).fill(t.accent)); - if solve.clicked() && !text(d, "objective").trim().is_empty() { - *confirm = true; - } - if ui.button(" Close ").clicked() { - *open = false; - } + // A row as tall as the buttons (see `ok_cancel`): the Load/Save area and the notes can make + // the form shorter, and a right-to-left layout straight in the window would fill what is + // left. + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let t = crate::theme::Tokens::get(ui.ctx()); + let solve = ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(t.on_accent)).fill(t.accent)); + if solve.clicked() && !text(d, "objective").trim().is_empty() { + *confirm = true; + } + if ui.button(" Close ").clicked() { + *open = false; + } + }); }); } diff --git a/crates/ui-egui/src/dialogs/insert_function.rs b/crates/ui-egui/src/dialogs/insert_function.rs index b662d7197..38a695a80 100644 --- a/crates/ui-egui/src/dialogs/insert_function.rs +++ b/crates/ui-egui/src/dialogs/insert_function.rs @@ -1,33 +1,97 @@ -use super::show::ok_cancel; -use super::{Dialog, Json, SheetApp, vec2}; +use super::show::{ok_cancel, take_first_focus}; +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(); + // 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. + 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() + }; + // 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) + .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) + }); + // 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; + } + 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(function_category_label(f["category"].as_str().unwrap_or(""))); - let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{name:<18} {category}")).min_size(vec2(480.0, 20.0))); + 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 +102,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}("), @@ -58,15 +126,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/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs index 8a2acc010..83f5e0fef 100644 --- a/crates/ui-egui/src/dialogs/show.rs +++ b/crates/ui-egui/src/dialogs/show.rs @@ -1,5 +1,5 @@ use super::about::about; -use super::analysis::{analysis_toolpak, goal_seek, solver, solver_results}; +use super::analysis::{analysis_toolpak, cancel_solver_options, goal_seek, in_solver_options, solver, solver_results}; use super::charts::{recommended_charts, select_data}; use super::command_search::command_search; use super::cond_format_rules::manage_rules; @@ -182,7 +182,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "goalSeek" => goal_seek(app, ui, &mut d, &mut confirm), "pasteSpecial" => paste_special(app, ui, &mut d, &mut confirm, &mut open), "analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open), - "solver" => solver(ui, &mut d, &mut confirm, &mut open), + "solver" => solver(app, ui, &mut d, &mut confirm, &mut open), "solverResults" => solver_results(app, ui, &mut d, &mut open), "spelling" => spelling(app, ui, &mut d, &mut open), "assignAction" => crate::control_ui::assign_dialog(app, ui, &mut d, &mut open), @@ -380,7 +380,12 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } } if ctx.input(|i| i.key_pressed(Key::Escape)) || !win_open { - open = false; + // In Solver Options, Escape (or closing the window) cancels the options only. + if in_solver_options(&d) { + cancel_solver_options(&mut d); + } else { + open = false; + } } if confirm && d.name == "insertPicture" && d.picture_target != app.view_key() { app.message = Some((d.title.clone(), "Return to the original worksheet to insert this picture, or cancel and choose another cell.".into())); @@ -468,7 +473,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/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/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 88bed777b..05a44cff0 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -426,11 +426,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 15775cf5d..b4afb17f6 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -433,11 +433,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 1383f876f..dc3a82535 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -400,10 +400,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" => "Σύστημα", @@ -591,7 +588,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/es.rs b/crates/ui-egui/src/i18n/es.rs index 9eb7f968a..3e5772ae8 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -430,11 +430,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 e6a1e4edb..e61adc13c 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -319,11 +319,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 87726f66b..0a7b3a802 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -304,10 +304,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", @@ -469,7 +466,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 202872cb9..e8c10cb28 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -313,10 +313,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", "印刷する内容"), @@ -486,7 +484,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/ko.rs b/crates/ui-egui/src/i18n/ko.rs index bc189e74a..49cbf7cc5 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -304,11 +304,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 a89f7b322..b63b5a504 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -311,10 +311,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 5954d5806..beacdb956 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -298,10 +298,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", @@ -461,7 +458,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 5b4233669..ca761a78f 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -304,11 +304,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", @@ -484,7 +482,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 8f8f5940f..64cf8feda 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -305,10 +305,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", @@ -467,7 +464,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/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 42eef64b6..1897541e2 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -306,11 +306,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 ff4aec29b..7bd4b2c35 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -310,10 +310,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 4d3bd2df7..4ac2c8a7f 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -414,10 +414,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 1920d106d..ae91a2ec0 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -300,10 +300,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" => "Анімація", @@ -465,7 +462,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", @@ -722,9 +718,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 0a8db64f5..238d81bf8 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -312,10 +312,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 831ac8401..e67fdc51c 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -304,11 +304,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 e7e3c948f..699dbec7c 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, @@ -127,6 +148,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, @@ -176,6 +200,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); } @@ -214,17 +242,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 { @@ -248,11 +279,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())); @@ -263,6 +306,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); @@ -713,6 +760,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.rs b/crates/ui-egui/src/keytips/keytip_table.rs index d14af141b..87c85befd 100644 --- a/crates/ui-egui/src/keytips/keytip_table.rs +++ b/crates/ui-egui/src/keytips/keytip_table.rs @@ -92,6 +92,30 @@ 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`: 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 + && seq.len() == prefix.len() + 1 + && seq.starts_with(prefix) + && serde_json::from_str::(p).is_ok_and(|p| p == *params) => + { + 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 +221,50 @@ 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() { + 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!(!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", + "top", + "left", + "right", + "none", + "all", + "outside", + "thickOutside", + "doubleBottom", + "thickBottom", + "topBottom", + "topThickBottom", + "topDoubleBottom", + "inside", + ] { + assert!(item_letter_with("HB", "home.borders", &json!({"preset": preset})).is_some(), "{preset} has no letter"); + } + } + #[test] fn item_letter_is_one_level_deep() { assert_eq!(item_letter("HV", "dialog:pasteSpecial"), Some("HVS")); diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 253c8321e..cbd43b71b 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 @@ -245,6 +246,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 fac814b70..67e3ec63b 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", @@ -1679,6 +1689,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 502c7f8c0..a730dfa69 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -34,6 +34,7 @@ pub mod fonts; pub mod form_controls; pub mod formula_bar; mod formula_locale; +pub mod function_library; pub mod grid; pub mod i18n; pub mod icons; @@ -287,6 +288,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/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 2d5f42c5a..47f1eabd7 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -293,3 +293,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..c46447092 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. @@ -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})) { + 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"})); } }); @@ -292,12 +304,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, @@ -469,7 +480,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 +573,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/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index 906ad07f4..df07ad706 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_object: 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_object, 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_object, selected_object); 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/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); diff --git a/crates/ui-egui/tests/it/chart_resize.rs b/crates/ui-egui/tests/it/chart_resize.rs index e6b0cdf73..2ae8707df 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/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"); +} diff --git a/crates/ui-egui/tests/it/dialog_button_row.rs b/crates/ui-egui/tests/it/dialog_button_row.rs index 5bbaca51f..7b34fdae4 100644 --- a/crates/ui-egui/tests/it/dialog_button_row.rs +++ b/crates/ui-egui/tests/it/dialog_button_row.rs @@ -26,3 +26,37 @@ fn ok_and_cancel_sit_under_the_last_field() { let gap = ok.top() - last.bottom(); assert!((0.0..40.0).contains(&gap), "OK is {gap} points under the last option"); } + +#[test] +/// Solver Options: OK sits just under Require Bounds on Variables, so the window fits its +/// content rather than covering the sheet. +fn solver_options_buttons_sit_under_the_last_option() { + let mut s = Session::new(); + s.new_workbook(); + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 1000.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + crate::common::app(s), + ); + h.state_mut().open_dialog("solver", json!({"optionsView": true})); + h.run_steps(4); + let last = h.get_by_label("Require Bounds on Variables").rect(); + let ok = h.get_by_label(" OK ").rect(); + let gap = ok.top() - last.bottom(); + assert!((0.0..40.0).contains(&gap), "OK is {gap} points under the last option"); + // The Solver Parameters' Solve button sits under its last row too. + h.state_mut().open_dialog("solver", json!({})); + h.run_steps(4); + let gap = |h: &egui_kittest::Harness<'_, SheetApp>| h.get_by_label(" Solve ").rect().top() - h.get_by_label("Load/Save").rect().bottom(); + let fresh = gap(&h); + assert!((0.0..40.0).contains(&fresh), "Solve is {fresh} points under the last row"); + // It stays there when the form gets shorter again: Load/Save shown, then hidden. + for _ in 0..2 { + h.get_by_label("Load/Save").click(); + h.run_steps(4); + } + assert!((gap(&h) - fresh).abs() < 1.0, "Solve is {} points under the last row, not {fresh}", gap(&h)); +} diff --git a/crates/ui-egui/tests/it/file_menu.rs b/crates/ui-egui/tests/it/file_menu.rs index fb0779631..7cc455fa7 100644 --- a/crates/ui-egui/tests/it/file_menu.rs +++ b/crates/ui-egui/tests/it/file_menu.rs @@ -62,6 +62,74 @@ 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.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; + + 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/crates/ui-egui/tests/it/function_library.rs b/crates/ui-egui/tests/it/function_library.rs new file mode 100644 index 000000000..a1a7a1a37 --- /dev/null +++ b/crates/ui-egui/tests/it/function_library.rs @@ -0,0 +1,332 @@ +//! Formulas › Function Library: the labelled buttons, their category menus, the More Functions +//! 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}; +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(")); +} + +#[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"); +} + +#[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"); +} + +#[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(); + 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/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index 39baf5f66..22a498f1d 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -794,6 +794,289 @@ fn solver_dialog_loads_the_saved_model_and_results_can_restore() { assert_eq!(value(&h, "B1"), Value::Number(0.0)); } +#[test] +fn solver_dialog_saves_loads_and_resets_the_model() { + let mut s = blank(); + for (c, v) in [("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2")] { + s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + let mut h = harness(s); + h.state_mut().open_dialog("solver", json!({})); + { + let d = h.state_mut().dialog.as_mut().unwrap(); + for (k, v) in [ + ("objective", json!("B1")), + ("variables", json!("A1:A2")), + ("constraints", json!(["B2 <= 12", "A1 <= 4"])), + ("modelArea", json!(true)), + ("modelRange", json!("H1")), + ] { + d.values.insert(k.into(), v); + } + } + h.run_steps(2); + h.get_by_label("Save").click(); + h.run_steps(2); + let note = |h: &egui_kittest::Harness<'_, SheetApp>| h.state().dialog.as_ref().unwrap().values.get("modelNote").cloned(); + assert_eq!(note(&h), Some(json!("The model was saved to H1:H5."))); + // Reset All clears the form; Load fills it again from the saved cells. + h.get_by_label("Reset All").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().unwrap().values.get("objective"), Some(&json!(""))); + h.get_by_label("Load").click(); + h.run_steps(2); + let d = h.state().dialog.as_ref().unwrap(); + assert_eq!(d.values.get("objective"), Some(&json!("B1")), "{:?}", d.values.get("modelNote")); + assert_eq!(d.values.get("constraints"), Some(&json!(["B2 <= 12", "A1 <= 4"]))); + let saved = gridcraft_engine::cmd::solver::saved_model(&h.state().session.active().unwrap().wb, 0); + assert_eq!(saved.map(|m| m["variables"].clone()), Some(json!("A1:A2"))); +} + +#[test] +fn solver_dialog_asks_before_save_model_replaces_data() { + let mut s = blank(); + for (c, v) in [("A1", "0"), ("B1", "=3*A1"), ("H2", "kept")] { + s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + let mut h = harness(s); + let form = json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4"], "modelArea": true, "modelRange": "H1"}); + h.state_mut().open_dialog("solver", form); + h.run_steps(2); + let values = |h: &egui_kittest::Harness<'_, SheetApp>| h.state().dialog.as_ref().unwrap().values.clone(); + // Save over data asks first and writes nothing; Don't Replace keeps the data. + h.get_by_label("Save").click(); + h.run_steps(2); + assert_eq!(values(&h).get("askModelOverwrite"), Some(&json!("H1"))); + assert_eq!(value(&h, "H2"), Value::text("kept")); + h.get_by_label("Don't Replace").click(); + h.run_steps(2); + assert_eq!((values(&h).get("askModelOverwrite"), value(&h, "H2")), (None, Value::text("kept"))); + // Editing the range drops the question, so Replace never writes somewhere else. + h.get_by_label("Save").click(); + h.run_steps(2); + h.state_mut().dialog.as_mut().unwrap().values.insert("modelRange".into(), json!("J1")); + h.run_steps(2); + assert_eq!(values(&h).get("askModelOverwrite"), None); + assert!(h.query_by_label("Replace").is_none()); + // Replace writes the model in one undo step. + h.state_mut().dialog.as_mut().unwrap().values.insert("modelRange".into(), json!("H1")); + h.run_steps(2); + h.get_by_label("Save").click(); + h.run_steps(2); + h.get_by_label("Replace").click(); + h.run_steps(2); + assert_eq!(values(&h).get("modelNote"), Some(&json!("The model was saved to H1:H4."))); + assert_eq!(values(&h).get("askModelOverwrite"), None); + assert_eq!(value(&h, "H2"), Value::Number(1.0)); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(value(&h, "H2"), Value::text("kept")); +} + +#[test] +fn solver_reset_all_and_load_model_drop_the_old_models_options() { + let mut s = blank(); + for (c, v) in [("A1", "1"), ("B1", "=ABS(A1-4)")] { + s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + // A GRG Nonlinear model on cells, which has no Evolutionary options. + s.execute("data.solverSaveModel", json!({"to": "H1", "model": {"objective": "B1", "goal": "min", "variables": "A1"}})).unwrap(); + let mut h = harness(s); + let old = json!({"objective": "B1", "goal": "min", "variables": "A1", "method": "evolutionary", "maxEvaluations": "7", "integerOptimality": "5", "requireBounds": false, "convergence": "0.5", "randomSeed": "11", "populationSize": "40", "mutationRate": "0.5", "generationsWithoutImprovement": "5", "modelArea": true, "modelRange": "H1"}); + h.state_mut().open_dialog("solver", old.clone()); + h.run_steps(2); + let keys = [ + "maxEvaluations", + "integerOptimality", + "requireBounds", + "convergence", + "randomSeed", + "populationSize", + "mutationRate", + "generationsWithoutImprovement", + ]; + let left = |h: &egui_kittest::Harness<'_, SheetApp>| -> Vec<&str> { + let d = h.state().dialog.as_ref().unwrap(); + keys.into_iter().filter(|k| d.values.contains_key(*k)).collect() + }; + let kept = |h: &egui_kittest::Harness<'_, SheetApp>| -> Vec<&str> { + let d = h.state().dialog.as_ref().unwrap(); + keys.into_iter().filter(|k| d.values.get(*k) == old.get(*k)).collect() + }; + // Load Model keeps none of the old options: the loaded model's own, or none. + h.get_by_label("Load").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().unwrap().values.get("method"), Some(&json!("grg"))); + assert_eq!(kept(&h), Vec::<&str>::new()); + // Reset All drops them. + { + let d = h.state_mut().dialog.as_mut().unwrap(); + for (k, v) in old.as_object().unwrap() { + d.values.insert(k.clone(), v.clone()); + } + } + h.run_steps(2); + h.get_by_label("Reset All").click(); + h.run_steps(2); + assert_eq!(left(&h), Vec::<&str>::new()); + // A new Evolutionary model then solves with the default options. + { + let d = h.state_mut().dialog.as_mut().unwrap(); + for (k, v) in [ + ("objective", json!("B1")), + ("goal", json!("min")), + ("variables", json!("A1")), + ("constraints", json!(["A1 <= 10"])), + ("method", json!("evolutionary")), + ] { + d.values.insert(k.into(), v); + } + } + h.run_steps(2); + h.get_by_label(" Solve ").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("solverResults"), "{:?}", h.state().message); + let saved = gridcraft_engine::cmd::solver::saved_model(&h.state().session.active().unwrap().wb, 0).unwrap(); + assert_eq!( + (saved["requireBounds"].as_bool(), saved["randomSeed"].as_f64(), saved["convergence"].as_f64()), + (Some(true), Some(0.0), Some(0.0001)), + "{saved}" + ); + assert_eq!((saved["populationSize"].as_f64(), saved["generationsWithoutImprovement"].as_f64()), (Some(100.0), Some(50.0)), "{saved}"); + assert_eq!((saved["maxEvaluations"].as_f64(), saved["integerOptimality"].as_f64()), (Some(20000.0), Some(0.0)), "{saved}"); +} + +#[test] +fn solver_options_go_with_the_solve() { + let mut s = blank(); + for (c, v) in [("A1", "1"), ("B1", "=ABS(A1-4)")] { + s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + let mut h = harness(s); + h.state_mut().open_dialog("solver", json!({"objective": "B1", "goal": "min", "variables": "A1", "method": "evolutionary"})); + h.run_steps(2); + // Switching between the parameters and the options changes the window's height, and it + // re-centres over the next frames, so each switch is followed by a few frames. + h.get_by_label("Options").click(); + h.run_steps(4); + assert_eq!(h.state().dialog.as_ref().unwrap().title, "Solver Options"); + // Cancel keeps the options as they were; OK keeps the edits. + let put = |h: &mut egui_kittest::Harness<'_, SheetApp>, k: &str, v: serde_json::Value| { + h.state_mut().dialog.as_mut().unwrap().values.insert(k.into(), v); + }; + put(&mut h, "maxEvaluations", json!("7")); + h.get_by_label(" Cancel ").click(); + h.run_steps(4); + let d = h.state().dialog.as_ref().unwrap(); + assert_eq!((d.title.as_str(), d.values.get("maxEvaluations")), ("Solver Parameters", Some(&json!("20000")))); + h.get_by_label("Options").click(); + h.run_steps(4); + put(&mut h, "requireBounds", json!(false)); + put(&mut h, "randomSeed", json!("11")); + h.get_by_label(" OK ").click(); + h.run_steps(4); + h.get_by_label(" Solve ").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("solverResults"), "{:?}", h.state().message); + assert!(matches!(value(&h, "A1"), Value::Number(v) if (v - 4.0).abs() < 1e-6)); + let saved = gridcraft_engine::cmd::solver::saved_model(&h.state().session.active().unwrap().wb, 0).unwrap(); + assert_eq!((saved["randomSeed"].as_f64(), saved["requireBounds"].as_bool()), (Some(11.0), Some(false))); +} + +#[test] +fn solver_reset_all_resets_the_options() { + let mut s = blank(); + for (c, v) in [("A1", "1"), ("B1", "=ABS(A1-4)")] { + s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + let mut h = harness(s); + let options = json!({"objective": "B1", "goal": "min", "variables": "A1", "method": "evolutionary", "maxEvaluations": "7", "integerOptimality": "5", "randomSeed": "11", "requireBounds": false}); + h.state_mut().open_dialog("solver", options); + h.run_steps(2); + h.get_by_label("Reset All").click(); + h.run_steps(2); + let d = h.state().dialog.as_ref().unwrap(); + for key in ["maxEvaluations", "integerOptimality", "randomSeed", "requireBounds"] { + assert_eq!(d.values.get(key), None, "{key}"); + } + // Options shows the defaults again. + h.get_by_label("Options").click(); + h.run_steps(2); + let d = h.state().dialog.as_ref().unwrap(); + assert_eq!((d.values.get("maxEvaluations"), d.values.get("randomSeed")), (Some(&json!("20000")), Some(&json!("0")))); +} + +#[test] +fn escape_in_solver_options_cancels_the_options_only() { + let mut s = blank(); + for (c, v) in [("A1", "1"), ("B1", "=ABS(A1-4)")] { + s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + let mut h = harness(s); + h.state_mut().open_dialog("solver", json!({"objective": "B1", "goal": "min", "variables": "A1", "method": "evolutionary"})); + h.run_steps(2); + // A parameter edited before opening Options stays; the option edited there is undone. + h.state_mut().dialog.as_mut().unwrap().values.insert("constraints".into(), json!(["A1 <= 9"])); + h.get_by_label("Options").click(); + h.run_steps(2); + h.state_mut().dialog.as_mut().unwrap().values.insert("maxEvaluations".into(), json!("7")); + key(&mut h, Key::Escape, Modifiers::NONE); + let d = h.state().dialog.as_ref().expect("Escape leaves the Solver dialog open"); + assert_eq!((d.title.as_str(), d.values.get("maxEvaluations")), ("Solver Parameters", Some(&json!("20000")))); + assert_eq!(d.values.get("constraints"), Some(&json!(["A1 <= 9"]))); + // In the parameters, Escape closes the dialog as before. + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().dialog.is_none()); +} + +#[test] +fn solver_dialog_opened_in_its_options_shows_the_defaults_and_cancel_restores_them() { + let mut s = blank(); + for (c, v) in [("A1", "1"), ("B1", "=ABS(A1-4)")] { + s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + let mut h = harness(s); + let params = + json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["A1 <= 10"], "method": "evolutionary", "optionsView": true}); + let put = |h: &mut egui_kittest::Harness<'_, SheetApp>, k: &str, v: serde_json::Value| { + h.state_mut().dialog.as_mut().unwrap().values.insert(k.into(), v); + }; + let get = |h: &egui_kittest::Harness<'_, SheetApp>, k: &str| h.state().dialog.as_ref().unwrap().values.get(k).cloned(); + // With no saved model, the fields show the defaults, as after the Options button. + h.state_mut().open_dialog("solver", params.clone()); + h.run_steps(4); + assert_eq!(h.state().dialog.as_ref().unwrap().title, "Solver Options"); + assert!(h.query_all_by_label("Max Evaluations:").next().is_some()); + assert_eq!( + (get(&h, "maxEvaluations"), get(&h, "randomSeed"), get(&h, "integerOptimality")), + (Some(json!("20000")), Some(json!("0")), Some(json!("0"))) + ); + // Cancel restores them. + put(&mut h, "randomSeed", json!("11")); + put(&mut h, "requireBounds", json!(false)); + h.get_by_label(" Cancel ").click(); + h.run_steps(4); + assert_eq!(h.state().dialog.as_ref().unwrap().title, "Solver Parameters"); + assert_eq!((get(&h, "randomSeed"), get(&h, "requireBounds")), (Some(json!("0")), Some(json!(true)))); + // So does Escape. + h.state_mut().dialog = None; + h.state_mut().open_dialog("solver", params); + h.run_steps(4); + put(&mut h, "maxEvaluations", json!("7")); + key(&mut h, Key::Escape, Modifiers::NONE); + h.run_steps(2); + assert_eq!((h.state().dialog.as_ref().unwrap().title.as_str(), get(&h, "maxEvaluations")), ("Solver Parameters", Some(json!("20000")))); + // The next Solve uses the defaults. + h.get_by_label(" Solve ").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("solverResults"), "{:?}", h.state().message); + let saved = gridcraft_engine::cmd::solver::saved_model(&h.state().session.active().unwrap().wb, 0).unwrap(); + assert_eq!( + (saved["maxEvaluations"].as_f64(), saved["randomSeed"].as_f64(), saved["requireBounds"].as_bool()), + (Some(20000.0), Some(0.0), Some(true)), + "{saved}" + ); +} + #[test] fn solver_results_write_the_chosen_reports() { let mut s = blank(); diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 09a04d55c..f6da7ee7c 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()); @@ -1785,6 +1792,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()); @@ -2775,7 +2790,7 @@ fn keytip_q_() { #[test] fn generated_test_count_matches_the_table() { - const EXPECTED: usize = 368; + const EXPECTED: usize = 370; 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 a96b02a20..4e6b6743e 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -48,6 +48,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; @@ -64,6 +65,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; @@ -81,6 +83,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/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}"); +} 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()); +} 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/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index b4418018a..e3fc7a9dc 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -768,3 +768,38 @@ 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"); + + // 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); + 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"); +} 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/fmla.rs b/crates/xlsx/src/fmla.rs index 966088d5b..29dd20870 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -140,15 +140,119 @@ 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) => 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, 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") { + 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; + } + 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) +} + +/// `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, known) { + return Some(inner); + } + 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( + inner @ (Expr::Call(..) + | Expr::Ref(_) + | Expr::Name(_) + | Expr::Number(_) + | Expr::Text(_) + | Expr::Bool(_) + | Expr::Error(_) + | Expr::Paren(_) + | Expr::Array(_) + | Expr::Struct(_)), + ) => { + found = true; + inner + } + Some(inner) => { + found = true; + Expr::Paren(Box::new(inner)) + } + None => x, + }); + 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)] mod tests { use super::*; @@ -284,6 +388,76 @@ 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] + 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!(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!(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!( + 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, 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\"))", + "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!(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!(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 = read(&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/lib.rs b/crates/xlsx/src/lib.rs index c9cfe7acf..2723044a9 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -46,7 +46,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; @@ -84,7 +84,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 020969c77..9a26049d7 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}; @@ -32,6 +34,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 +226,14 @@ fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) { } } -pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { +/// 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<'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 +250,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()); @@ -268,7 +281,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 = 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"). + // 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).min(WorkbookView::MAX_TAB_RATIO), + }; + } // 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/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 1cf875524..a8cf40294 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; @@ -547,7 +547,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/tables.rs b/crates/xlsx/src/tables.rs index 23c936c4a..3cfd84c2d 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", @@ -243,6 +244,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "SORTBY", "STDEV.P", "STDEV.S", + "STOCKHISTORY", "SWITCH", "T.DIST", "T.DIST.2T", @@ -257,6 +259,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[ "TEXTSPLIT", "TOCOL", "TOROW", + "TRANSLATE", "TRIMRANGE", "UNICHAR", "UNICODE", @@ -352,6 +355,13 @@ 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")); + 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); assert_eq!(builtin_format(14), Some("m/d/yyyy")); assert_eq!(builtin_id("0.00%"), Some(10)); diff --git a/crates/xlsx/src/tests.rs b/crates/xlsx/src/tests.rs index da1ddf666..38b0b64b4 100644 --- a/crates/xlsx/src/tests.rs +++ b/crates/xlsx/src/tests.rs @@ -10,6 +10,7 @@ mod controls_tests; mod csv_tests; mod encrypted_tests; mod fixtures; +mod google_sheets_dummy; mod ink_roundtrip; mod literal_category_roundtrip; mod locale_numfmt; @@ -28,6 +29,7 @@ mod sheet_data_pin; 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/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/crates/xlsx/src/tests/google_sheets_dummy.rs b/crates/xlsx/src/tests/google_sheets_dummy.rs new file mode 100644 index 000000000..921f46242 --- /dev/null +++ b/crates/xlsx/src/tests/google_sheets_dummy.rs @@ -0,0 +1,103 @@ +//! #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 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, read_xlsx_with, 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, 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!( + 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#"IFERROR(__xludf.DUMMYFUNCTION("GOOGLETRANSLATE(""hello"",""en"",""fr"")"),"bonjour")bonjour"#, + 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"); + // 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!(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 = 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 and the Google-only one keep their wrapper. + assert_eq!(xml.matches("DUMMYFUNCTION").count(), 2, "{xml}"); + assert_google_book(&open(&saved)); +} diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 2298e513f..132fa4d75 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -1499,3 +1499,177 @@ 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}"); +} + +/// 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/tests/xls_style_tests.rs b/crates/xlsx/src/tests/xls_style_tests.rs new file mode 100644 index 000000000..344cf37ed --- /dev/null +++ b/crates/xlsx/src/tests/xls_style_tests.rs @@ -0,0 +1,353 @@ +//! 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) +} + +/// 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 { + 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); + 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)); + 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); +} + +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) }); + 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()); +} + +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)); + + // 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 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(); + 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/write.rs b/crates/xlsx/src/write.rs index f44b6fab1..72440a4c4 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}; @@ -212,6 +212,28 @@ 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\""); + } + let tab_ratio = wb.view.tab_ratio.min(WorkbookView::MAX_TAB_RATIO); + if tab_ratio != 600 { + let _ = write!(attrs, " tabRatio=\"{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> { @@ -280,7 +302,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}"); diff --git a/crates/xlsx/src/xls.rs b/crates/xlsx/src/xls.rs index ca3ec9eb3..84520f24e 100644 --- a/crates/xlsx/src/xls.rs +++ b/crates/xlsx/src/xls.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, HashSet}; 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}; @@ -131,7 +134,7 @@ struct Globals { sheets: Vec, date1904: bool, formats: HashMap, - xf_formats: Vec, + styles: styles::StyleRecords, sst: Vec>, links: Links, names: Vec, @@ -161,19 +164,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()); @@ -210,7 +205,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)) } @@ -280,10 +275,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..cc7c496f2 --- /dev/null +++ b/crates/xlsx/src/xls/styles.rs @@ -0,0 +1,405 @@ +//! 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()?; + 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(); + 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, + }; + f.family = Some(family); + f.charset = Some(charset); + 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)), + } +} + +/// 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, + 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, + } +} + +/// A fill pattern from its BIFF8 code (fls); unknown codes are no pattern. +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, + } +} + +/// 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 { + 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() +} + +/// 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) } +} + +/// 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(); + // 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() { + 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))); + } + } + wb.cell_styles = named; + out +} diff --git a/docs/architecture.md b/docs/architecture.md index edf1bb679..eb91fbd9b 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 (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). @@ -59,13 +59,20 @@ 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. 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, @@ -102,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 @@ -159,6 +174,8 @@ 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`) | | 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/control-protocol.md b/docs/control-protocol.md index 1ca0a3e9d..3d1ed8c41 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -59,7 +59,7 @@ connection; nothing sent after it runs. Blank lines are skipped. | `sheet.read` | `{range?, sheet?, formulas?, formatted?}` | Values (or formulas / displayed text) of a range | | `cell.get` | `{cell?}` | One cell: value, formula, displayed text, style, merge, comment, link | | `ui.inspect` | | UI state: ribbon tab, editor, open dialog, Backstage page, message, selected object, grid rect, scroll, perf, `windows` (as `window.list`) and `activeWindow` | -| `ui.object.select` | `{id?, kind?}` | Selects a floating object on the active sheet (no `id` clears the selection); replies `{selected, rect}`, the rect in window points | +| `ui.object.select` | `{id?, kind?}` | Selects a floating object on the active sheet and replies `{selected, rect}`, the rect in window points; with no `id` it clears the selection and replies `null` | | `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points; refused with an error if it would leave more than 10,000 synthetic events or 8 MiB of synthetic input queued (pointer, key, text and move events share this budget) | | `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing); same shared queue limits | | `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing); same shared queue limits, and one `ui.text` string may be at most 1 MiB | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6f01e5c42..07474d4c4 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 (form controls: buttons, scroll bars and spin buttons read and written; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** minor (form controls: buttons, scroll bars and spin buttons read and written; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -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, @@ -39,6 +60,20 @@ 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"` 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 | |---|---|---|---|---|---| | `.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), form controls (buttons, scroll bars and spin buttons: control properties parts, `` and VML `x:ClientData`; a VML-only control is read too), 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), other form controls (check boxes, list boxes, drop-downs; written in the same `` element and VML drawing as the modelled ones), 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` | @@ -54,7 +89,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 | — | @@ -66,10 +101,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 | @@ -100,7 +135,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | 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 | +| 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 | @@ -110,7 +145,10 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| -| 2026-10-31 | minor | XLSX: buttons, scroll bars and spin buttons read and written (control properties parts, ``, VML `x:ClientData`); other form controls stay with pass-through | +| 2026-11-01 | minor | XLSX: buttons, scroll bars and spin buttons read and written (control properties parts, ``, VML `x:ClientData`); other form controls stay with pass-through | +| 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-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-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 | @@ -134,6 +172,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/function-parity.md b/docs/function-parity.md index 818090feb..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: 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). @@ -86,11 +86,14 @@ 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 | 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) | +| MOD quotient limit | done: Excel 16.0 returns `#NUM!` when `ABS(number/divisor) >= 1.1259E12`; lower quotients retain the remainder | +| 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 | @@ -147,6 +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-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?` | diff --git a/docs/gaps.md b/docs/gaps.md index 76e0f8298..77cbebd70 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (§4: chart resizing is no longer a gap; earlier: §8: PivotTable Enable show details; earlier: PivotTable Show Details) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (§4: chart resizing is no longer a gap; earlier: §13: Solver Options; earlier: §13: Solver Load/Save Model and Reset All) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -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:** 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`, @@ -122,8 +122,10 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - Solver (`data.solver`, #327) has Simplex LP with integer/binary branch and bound, a local nonlinear method (augmented Lagrangian, not GRG), the Evolutionary method, and Answer and Limits reports. Report refusal leaves the solution, saved model and history unchanged; a - successful solve and its reports share one Undo step (#1050). AllDifferent constraints, - the Solver Options dialog, the Sensitivity and Population reports and Load/Save Model remain. + successful solve and its reports share one Undo step (#1050). Load/Save Model writes the + model on cells of its sheet and reads it back; Reset All removes it. Solver Options sets the + evaluation limit, Integer Optimality and the Evolutionary options, which are saved with the + model. AllDifferent constraints and the Sensitivity and Population reports remain. The Analysis ToolPak (`data.analysisToolPak`) has 13 of 19 tools. Forecast Sheet (`data.forecastSheet`) and the FORECAST.ETS family are in; the sheet has no Forecast Start (hindcast) option yet. **Estimate:** 12–20 h. @@ -183,7 +185,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| -| 2026-10-31 | minor | §4: charts, like every floating object, move and resize from eight handles (#190), so resizing is no longer listed as missing; the evidence is one paragraph again, without the stale "PRs #158, #161 open" | +| 2026-11-01 | minor | §4: charts, like every floating object, move and resize from eight handles (#190), so resizing is no longer listed as missing; the evidence is one paragraph again, without the stale "PRs #158, #161 open" | +| 2026-10-31 | minor | §13: Solver Options (Max Evaluations, Integer Optimality and the Evolutionary options), saved with the model | +| 2026-10-31 | minor | §13: Solver Load/Save Model (`data.solverSaveModel`, `data.solverLoadModel`) and Reset All (`data.solverReset`), with buttons in the Solver Parameters dialog | | 2026-10-24 | minor | §8: PivotTable Enable show details: `pivot.options` `enableShowDetails` turns Show Details off for a PivotTable, read and written in XLSX as `enableDrill` | | 2026-10-23 | minor | §8: PivotTable Show Details: double-clicking a value cell lists its source records on a new sheet (#2364) | | 2026-10-22 | minor | §20: a native window per workbook window, New Window on the same workbook and Arrange All on the desktop; left: Arrange All across monitors, the web's one window, pen pressure (estimate 10–14 h → 5–8 h) | 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. | 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. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index be1dec8b6..b51f7875a 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-31 · **Change:** minor (form controls; catalog 280 / 296; earlier: chart resizing no longer listed as missing; earlier: 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-11-01 · **Change:** minor (form controls; catalog 280 / 296; earlier: chart resizing no longer listed as missing; no score change; earlier: what-if and analysis: Solver Options; the Insert Function dialog is named as in Excel; no score change; earlier: Solver Load/Save Model and Reset All; 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: @@ -90,7 +90,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. Paper sizes match Japanese Excel: B4/B5/B6 are the JIS sizes, ISO B4/B5/B6 stay reachable by name, and Japanese Postcard (はがき), Japanese Double Postcard (往復はがき), the chou/kaku envelopes and A6 are available. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | | Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Kinds survive XLSX round trip (#158, #161). [chart-parity.md](chart-parity.md) | | Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | -| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer and Limits reports; no Sensitivity report or options dialog). Missing: Forecast Sheet, Analyze Data | +| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer and Limits reports; Load/Save Model, Reset All and Solver Options; no Sensitivity report), Forecast Sheet. Missing: Analyze Data | | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes, form buttons, scroll bars and spin buttons. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | | Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | @@ -315,7 +315,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`, @@ -335,8 +335,11 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| -| 2026-10-31 | minor | Form controls (buttons, scroll bars, spin buttons) in Insert objects and Automation; catalog 276 / 292 → 280 / 296 (Developer › Controls) | -| 2026-10-31 | minor | Charts move and resize from eight handles like every floating object (#190): the Essentials chart work is polish only (5–8 h), and the chart rows and the interaction discount no longer list resizing as missing; the Mainstream chart row no longer lists trendlines as missing (no score change) | +| 2026-11-01 | minor | Form controls (buttons, scroll bars, spin buttons) in Insert objects and Automation; catalog 276 / 292 → 280 / 296 (Developer › Controls) | +| 2026-11-01 | minor | Charts move and resize from eight handles like every floating object (#190): the Essentials chart work is polish only (5–8 h), and the chart rows and the interaction discount no longer list resizing as missing; the Mainstream chart row no longer lists trendlines as missing (no score change) | +| 2026-10-31 | minor | The Insert Function dialog is named as in Excel (it was called Formula Builder); no score change | +| 2026-10-31 | minor | What-if and analysis: Solver Options (no score change) | +| 2026-10-31 | minor | What-if and analysis: Solver Load/Save Model and Reset All; Forecast Sheet moved from Missing to the list of tools, as gaps.md §13 records (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 b395e70df..c341d29ef 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 (form controls: buttons, sliders and spin buttons on the sheet; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (form controls: buttons, sliders and spin buttons on the sheet; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 280 / 296) · **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; 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 | @@ -31,7 +32,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) | @@ -157,7 +158,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,8 +197,11 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| -| 2026-10-31 | minor | Form controls: buttons, sliders (scroll bars) and spin buttons placed, worked, selected and edited on the sheet (Insert › Controls, Assign Action, Format Control); catalog 280 / 296 | -| 2026-10-31 | minor | Floating objects: one selection for charts, pictures, shapes and slicers, eight resize handles with matching cursors (right-to-left sheets included), Escape drops the selection | +| 2026-11-01 | minor | Form controls: buttons, sliders (scroll bars) and spin buttons placed, worked, selected and edited on the sheet (Insert › Controls, Assign Action, Format Control); catalog 280 / 296 | +| 2026-11-01 | minor | Floating objects: one selection for charts, pictures, shapes and slicers, eight resize handles with matching cursors (right-to-left sheets included), Escape drops the selection | +| 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-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 | | 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 | 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